From ff12faba94f736a9d32d83cf0c5474dcd673a75d Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:09:44 -0500 Subject: [PATCH 01/31] app: Undo names whose step it takes, and the bubble after it has a Redo (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5b (after 5a, #487). ## What changes Cmd-Z stays linear: it takes the last change, whoever made it. It now says whose that is. - **The Edit menu names it.** When the step Undo would take was made by someone else, the row reads **"Undo Claude's Draw"**, and Redo likewise. The person's own steps read "Undo", so working alone looks as it always has. The title is dynamic, read when the menu opens; it reads the owner's `historyLine` only once someone besides the person has acted on the document (`Host.step_authors`). - **The bubble after it has a Redo.** Pressing Undo (key or menu) on someone else's step shows **"Undid Claude's Edit"** on the document's pane, in place of the plain "Undo" its run would show, with a **Redo** that puts the step back. Redo acts only while that step is still next to redo. - **`ActivityFeed.pushAction`.** A feed item may carry a button. It alone takes clicks, on a small layer of its own, so the rest of the feed still never covers the work. An item with a button stays 6 s instead of 2.4 s. `draw` returns the tag of the button pressed. - **`Editor.stepToTake`** finds the step an undo or redo would take, where it leaves the line, and its author. It is shared by the menu title, the bubble and `fizzy.undo`'s `expect` (which #487 had inline). Muting the run's own "Undo" reuses `Host.activity_muted` (the demo-seek flag) for the duration of that one command. It is put back before the feed draws, and the code says so. ## SDK impact - [x] None (the fingerprint does not move; `test-sdk-version` passes). ## Verified - **Integration** (`headless: the whole editor comes up…`): an author labelled "Claude" writes into the document in front. - `stepToTake` names Claude. - `editor.undo()` (Cmd-Z's path) restores the text, and the pane's last feed item reads "Undid Claude's …" with a Redo tagged with that step. - Pressing it brings the text back. - **Seen on macOS** in a sandbox fizzy built from this branch: an MCP client named `claude-code` wrote into an open `note.txt`, then Cmd-Z was pressed through the input tape. - The pane showed **"Undid Claude's Edit · Redo"** above the history transport. - Clicking Redo at its spot brought Claude's line back (`text.read`: 3 lines again). - The Edit menu's title was not looked at on screen; it is the same lookup the test checks. - Gates on macOS: `check-fmt`, `zig build test` (574/575, 1 skipped), `zig build test-integration` (53/53 steps), `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/ActivityFeed.zig | 67 +++++++++++++++++++++++++++++++++------ src/editor/Editor.zig | 63 +++++++++++++++++++++++++++++++++--- src/editor/Keybinds.zig | 41 ++++++++++-------------- src/editor/menu_model.zig | 25 +++++++++++++-- tests/integration.zig | 26 +++++++++++++++ 5 files changed, 181 insertions(+), 41 deletions(-) diff --git a/app/ActivityFeed.zig b/app/ActivityFeed.zig index c70ae3d57..0448fce19 100644 --- a/app/ActivityFeed.zig +++ b/app/ActivityFeed.zig @@ -1,7 +1,9 @@ //! The commands just run on one document (`Host.activity`), shown small at the bottom right of //! its pane as they run: each fades in, stays a moment and fades out, the older ones rising as //! newer ones come. Drawn on a layer that takes no input, so it never covers the work, and on wall -//! time, so whatever runs the commands (a person, an agent, a tape) shows the same. +//! time, so whatever runs the commands (a person, an agent, a tape) shows the same. One that +//! offers an action ("Undid Claude's Draw" with its Redo) takes clicks on its button only, and +//! stays longer to be reached. const ActivityFeed = @This(); const std = @import("std"); @@ -16,6 +18,8 @@ seen: u64 = 0, const max = 5; const life_ns: i128 = 2400 * std.time.ns_per_ms; +/// An item with an action stays this long. +const action_life_ns: i128 = 6000 * std.time.ns_per_ms; const fade_ns: i128 = 250 * std.time.ns_per_ms; const pad: f32 = 8; const gap: f32 = 4; @@ -24,6 +28,19 @@ const Item = struct { title: [48]u8, len: u8, born: i128, + action: ?Action = null, + + fn life(self: Item) i128 { + return if (self.action != null) action_life_ns else life_ns; + } +}; + +/// A button on an item: its label, and the caller's own tag for it, which `draw` answers when +/// it is pressed. +const Action = struct { + label: [16]u8, + len: u8, + tag: u64, }; /// A feed showing only what runs from now on. @@ -57,14 +74,26 @@ pub fn push(self: *ActivityFeed, title: []const u8, now: i128) void { self.len += 1; } -/// Draw the feed in the bottom right of `area` (physical), newest lowest. -pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) void { - // The oldest go first. - while (self.len > 0 and now - self.items[0].born > life_ns) { - std.mem.copyForwards(Item, self.items[0 .. self.len - 1], self.items[1..self.len]); - self.len -= 1; +/// `push`, with a button labelled `label` on it: `draw` answers `tag` when it is pressed. +pub fn pushAction(self: *ActivityFeed, title: []const u8, label: []const u8, tag: u64, now: i128) void { + self.push(title, now); + var action: Action = .{ .label = undefined, .len = @intCast(@min(label.len, 16)), .tag = tag }; + @memcpy(action.label[0..action.len], label[0..action.len]); + self.items[self.len - 1].action = action; +} + +/// Draw the feed in the bottom right of `area` (physical), newest lowest. Answers the tag of the +/// action pressed this frame, if one was. +pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: i128) ?u64 { + // Those whose time is up go. + var kept: usize = 0; + for (self.items[0..self.len]) |item| { + if (now - item.born > item.life()) continue; + self.items[kept] = item; + kept += 1; } - if (self.len == 0) return; + self.len = kept; + if (self.len == 0) return null; dvui.refresh(null, @src(), null); const r = area.toNatural(); @@ -89,9 +118,10 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: const age = now - item.born; // In, then out, over `fade_ns` each; rising into place as it comes in. const in = @min(1, @as(f32, @floatFromInt(age)) / @as(f32, @floatFromInt(fade_ns))); - const out = @min(1, @as(f32, @floatFromInt(life_ns - age)) / @as(f32, @floatFromInt(fade_ns))); + const out = @min(1, @as(f32, @floatFromInt(item.life() - age)) / @as(f32, @floatFromInt(fade_ns))); const alpha = @max(0, @min(in, out)); - const w = font.textSize(item.title[0..item.len]).w + pad * 2; + const action_w = if (item.action) |a| font.textSize(a.label[0..a.len]).w + pad * 2 else 0; + const w = font.textSize(item.title[0..item.len]).w + pad * 2 + action_w; const rect: dvui.Rect = .{ .x = r.w - 12 - w, .y = bottom - h + (1 - in) * h * 0.5, .w = w, .h = h }; // Glass has no fade: it grows in from, and shrinks out to, a little smaller. const k = 0.6 + 0.4 * alpha; @@ -102,6 +132,7 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: bottom -= (h + gap) * in; } core.dialogs.glassBubbles(layer.data().id, bubbles[0..self.len], rs.s, .{}); + var pressed: ?u64 = null; for (self.items[0..self.len], rects[0..self.len], alphas[0..self.len], 0..) |item, rect, alpha, n| { dvui.labelNoFmt(@src(), item.title[0..item.len], .{}, .{ .rect = rect, @@ -111,5 +142,21 @@ pub fn draw(self: *ActivityFeed, area: dvui.Rect.Physical, id_extra: usize, now: .margin = .{}, .color_text = .{ .color = ink.opacity(0.85 * alpha) }, }); + const a = item.action orelse continue; + // The button alone takes input, on a layer of its own over the feed's. + const bw = font.textSize(a.label[0..a.len]).w + pad * 2; + var hit: dvui.FloatingWidget = undefined; + hit.init(@src(), .{}, .{ .rect = .{ .x = r.x + rect.x + rect.w - bw, .y = r.y + rect.y, .w = bw, .h = rect.h }, .id_extra = id_extra *% 8 +% n }); + defer hit.deinit(); + if (dvui.button(@src(), a.label[0..a.len], .{}, .{ + .expand = .both, + .font = font, + .padding = .{ .x = pad, .y = pad / 2, .w = pad, .h = pad / 2 }, + .margin = .{}, + .background = false, + .border = .{}, + .color_text = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(alpha) }, + })) pressed = a.tag; } + return pressed; } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 7e72b22f2..9d9f22d98 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2978,7 +2978,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (claim_pill == null) if (ds.transport.place(area, now)) |bar| { area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s); }; - ds.feed.draw(area, @truncate(ds.doc_id), now); + if (ds.feed.draw(area, @truncate(ds.doc_id), now)) |step| editor.redoFromFeed(doc, step); } // A menu inside the document only when its owner names one. The context is created *after* @@ -6189,19 +6189,74 @@ pub fn redo(editor: *Editor) !void { /// An undo or a redo of the active document, through its owner's command: its transport shows, /// and a key held down goes further each press (`HistoryTransport.press`), the command run that -/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. -fn historyStep(editor: *Editor, action: enum { undo, redo }) !void { +/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. An undo of +/// a step someone else made says so on the document, with a Redo (`ActivityFeed.pushAction`), in +/// place of the plain "Undo" its run would show. +fn historyStep(editor: *Editor, action: HistoryAction) !void { const doc = editor.activeDoc() orelse return; const name = @tagName(action); - const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name); + const host = &editor.app.host; + const shows = host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name); const ds = if (shows) editor.doc_surfaces.get(doc.id) else null; const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1; for (0..by) |_| { if (!editor.activeDocCommandEnabled(name)) break; + // Who made a step is kept only once someone besides the person has acted on it. + const others: ?StepToTake = if (action == .undo and host.step_authors.contains(doc.id)) blk: { + const t = editor.stepToTake(doc, .undo, host.arena()) catch break :blk null; + break :blk if (t.by.kind == .person) null else t; + } else null; + const t = others orelse { + try editor.runActiveDocCommand(name); + continue; + }; + // The run's own "Undo" goes unrecorded (as while a demo seeks), so the bubble below is + // the one it shows. The flag is put back before the feed draws. + const muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = muted; try editor.runActiveDocCommand(name); + var buf: [96]u8 = undefined; + const said = std.fmt.bufPrint(&buf, "Undid {s}'s {s}", .{ authorName(t.by), t.step.title }) catch "Undid a change"; + if (editor.doc_surfaces.get(doc.id)) |d| d.feed.pushAction(said, "Redo", t.step.id, dvui.frameTimeNS()); } } +/// Redo `doc`'s step `id` from its bubble's Redo: only while it is still the next one to redo. +pub fn redoFromFeed(editor: *Editor, doc: sdk.DocHandle, id: u64) void { + const t = editor.stepToTake(doc, .redo, editor.app.host.arena()) catch return; + if (t.step.id == id) _ = doc.owner.historyGoTo(doc, t.to); +} + +pub const HistoryAction = enum { undo, redo }; + +/// The step an undo or a redo of a document would take, the step it leaves on top (`to`, for +/// `historyGoTo`; 0 before the first), and who made it. +pub const StepToTake = struct { step: sdk.Plugin.HistoryLine.Entry, to: u64, by: sdk.Author }; + +/// What an undo or a redo of `doc` would take, from its owner's line (`historyLine`): +/// `error.NoLine` when the owner keeps none, `error.Nothing` when there is nothing to take. +pub fn stepToTake(editor: *Editor, doc: sdk.DocHandle, action: HistoryAction, arena: std.mem.Allocator) error{ NoLine, Nothing }!StepToTake { + const line = doc.owner.historyLine(doc, arena) orelse return error.NoLine; + const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) { + .undo => blk: { + if (line.at == 0) return error.Nothing; + break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 }; + }, + .redo => blk: { + if (line.at >= line.entries.len) return error.Nothing; + break :blk .{ line.entries[line.at], line.entries[line.at].id }; + }, + }; + return .{ .step = step, .to = to, .by = editor.app.host.stepAuthor(doc.id, step.id) }; +} + +/// Who made a step, as a person reads it: "the person", else the author's label ("Claude"). +pub fn authorName(a: sdk.Author) []const u8 { + if (a.kind == .person) return "the person"; + return if (a.label.len > 0) a.label else @tagName(a.kind); +} + pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { // Darwin goes through `darwin_spawn` rather than `std.process.run`: the latter walks the // `environ` array captured at startup, which any `unsetenv` elsewhere in the process (SDL, diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 846e1e3f0..cf373111f 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -442,40 +442,31 @@ fn cmdRedoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Cal /// `historyGoTo`), so it reaches a document that is not active. With `expect`, only when that /// step is the one it would take, so a caller never takes back a step made since it looked /// (`plans/SHARED_HISTORY_PLAN.md`, step 5). Answers the step and whose it was. -fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: enum { undo, redo }) !void { +fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: Editor.HistoryAction) !void { const doc = if (args.path.len == 0) editor.activeDoc() orelse return call.fail("no document is active", .{}) else editor.docFromPath(args.path) orelse return call.fail("no open document at {s}", .{args.path}); - const line = doc.owner.historyLine(doc, call.arena) orelse { + const t = editor.stepToTake(doc, action, call.arena) catch |err| switch (err) { + error.Nothing => return call.fail("nothing to {s}", .{@tagName(action)}), // An owner with no line undoes the active document only, through its own command. - const active = editor.activeDoc(); - if (args.expect != 0 or active == null or active.?.id != doc.id) - return call.fail("{s} keeps no history line, so it can only undo the active document, with no expect", .{doc.owner.id}); - return switch (action) { - .undo => editor.undo(), - .redo => editor.redo(), - }; - }; - const host = &editor.app.host; - const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) { - .undo => blk: { - if (line.at == 0) return call.fail("nothing to undo", .{}); - break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 }; - }, - .redo => blk: { - if (line.at >= line.entries.len) return call.fail("nothing to redo", .{}); - break :blk .{ line.entries[line.at], line.entries[line.at].id }; + error.NoLine => { + const active = editor.activeDoc(); + if (args.expect != 0 or active == null or active.?.id != doc.id) + return call.fail("{s} keeps no history line, so it can only {s} the active document, with no expect", .{ doc.owner.id, @tagName(action) }); + return switch (action) { + .undo => editor.undo(), + .redo => editor.redo(), + }; }, }; - const whose = host.stepAuthor(doc.id, step.id); - const by = if (whose.kind == .person) "the person" else if (whose.label.len > 0) whose.label else @tagName(whose.kind); - if (args.expect != 0 and args.expect != step.id) return call.fail( + const by = Editor.authorName(t.by); + if (args.expect != 0 and args.expect != t.step.id) return call.fail( "step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward", - .{ args.expect, @tagName(action), step.id, step.title, by }, + .{ args.expect, @tagName(action), t.step.id, t.step.title, by }, ); - if (!doc.owner.historyGoTo(doc, to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), step.id }); - try call.returns(.{ .step = step.id, .title = step.title, .by = by }); + if (!doc.owner.historyGoTo(doc, t.to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), t.step.id }); + try call.returns(.{ .step = t.step.id, .title = t.step.title, .by = by }); } /// Set while `tick` is running a command because of a live key event. That event is never /// marked handled, so it goes on to reach the focused widget under its own steam — a clipboard diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index 080d17fa1..e9c7329fe 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -112,6 +112,27 @@ fn canSaveAll(editor: *Editor) bool { return false; } +/// "Undo Claude's Draw" when the step it would take is someone else's; "Undo" for the person's +/// own, so working alone reads as it always has. +fn undoTitle(editor: *Editor) [:0]const u8 { + return historyTitle(editor, .undo, "Undo", &undo_title); +} +fn redoTitle(editor: *Editor) [:0]const u8 { + return historyTitle(editor, .redo, "Redo", &redo_title); +} + +var undo_title: [96:0]u8 = undefined; +var redo_title: [96:0]u8 = undefined; + +fn historyTitle(editor: *Editor, action: Editor.HistoryAction, plain: [:0]const u8, buf: *[96:0]u8) [:0]const u8 { + const doc = editor.activeDoc() orelse return plain; + // Who made a step is kept only once someone besides the person has acted on the document. + if (!editor.app.host.step_authors.contains(doc.id)) return plain; + const t = editor.stepToTake(doc, action, editor.app.host.arena()) catch return plain; + if (t.by.kind == .person) return plain; + return std.fmt.bufPrintZ(buf, "{s} {s}'s {s}", .{ plain, Editor.authorName(t.by), t.step.title }) catch plain; +} + fn canUndo(editor: *Editor) bool { return editor.activeDocCommandEnabled("undo"); } @@ -193,8 +214,8 @@ const edit_items = [_]Item{ .native_always_enabled = true, } }, .separator, - .{ .command = .{ .id = "fizzy.undo", .title = .{ .static = "Undo" }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } }, - .{ .command = .{ .id = "fizzy.redo", .title = .{ .static = "Redo" }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } }, + .{ .command = .{ .id = "fizzy.undo", .title = .{ .dynamic = undoTitle }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } }, + .{ .command = .{ .id = "fizzy.redo", .title = .{ .dynamic = redoTitle }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } }, // Transform / Grid Layout are pixel-art concepts, not fizzy's own — pixi parents its own // section here rather than fizzy knowing about them. .{ .plugin_section = "fizzy.menu.edit" }, diff --git a/tests/integration.zig b/tests/integration.zig index 60e38840b..f85dc3c92 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7533,6 +7533,32 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text); } + // The person's Cmd-Z taking a step someone else made: the menu names it, and the pane says + // so with a Redo that puts it back. + { + const front = editor.activeDoc() orelse return error.TestUnexpectedResult; + const before = (try read(host, "")).text; + const front_write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// by Claude\\n\" }}", .{main_zig}); + defer gpa.free(front_write); + const outer = host.act(.{ .kind = .agent, .plugin = "test", .session = 1, .label = "Claude" }); + try std.testing.expect(host.callCommand("text.write", front_write, host.arena()) == .ok); + host.endAct(outer); + const taken = try editor.stepToTake(front, .undo, host.arena()); + try std.testing.expectEqualStrings("Claude", fizzy.Editor.authorName(taken.by)); + + try editor.undo(); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + const ds = editor.doc_surfaces.get(front.id) orelse return error.TestUnexpectedResult; + const said = ds.feed.items[ds.feed.len - 1]; + try std.testing.expect(std.mem.startsWith(u8, said.title[0..said.len], "Undid Claude's ")); + const redo = said.action orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(taken.step.id, redo.tag); + editor.redoFromFeed(front, redo.tag); + try std.testing.expectEqualStrings("// by Claude\n", (try read(host, "")).text); + try editor.undo(); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + } + // Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab // closes once it lands. try std.testing.expect(doc.owner.isDirty(doc)); From f892f9bbc04005c89a18cc0ffe2fd4164444c363 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:06:31 -0500 Subject: [PATCH 02/31] about: the About dialog and Help menu follow the app (#533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #518 ## What changes An app built on fizzy (Graphl, with its own `app-display-name`) got fizzy's About dialog and Help menu whatever it did. - **Which build is fizzy**: the new build option `app_is_fizzy` (`AppInfo.is_fizzy`) is true when fizzy is the build's **root package** (`b.pkg_hash` is empty). An app reaches fizzy as a dependency (`buildApp`), so it is never classed as fizzy. This is structural: a fizzy build that renames itself, or a fork, isn't misclassified by its names, and nothing in any build.zig has to remember to set it. - **About dialog**: - The title is `AppInfo.about_title_z` ("About "). - The fox and the fizzyed.it link show only for fizzy. - The "GitHub:" row shows only when `AppInfo.own_repo_url` is true: fizzy, or an app that set `-Drepo-url` (`repo-url` defaults to fizzy's for every app). - The "Automatic updates are not included in this build." line is gone. - **Status bar**: the app-name button runs `fizzy.about` through `editor.app.host.runCommand`. Fizzy owns no documents, so no claim can hold it. - **Help menu**: - The `fizzy.about` item reads "Check for Updates…" only with the updater and a feed of the app's own (`auto_update.impl and AppInfo.own_repo_url`, comptime). Otherwise it reads "About ". - `fizzy.about` uses lucide `info` rather than `download`. - `fizzy.restart` is "Restart ". - **Report a Bug** goes to a new `-Dapp-issues-url` (`AppInfo.issues_url`). - The default comes from `build/issues_url.zig`'s `derive`, a pure function unit-tested as `fizzy-issues-url-tests`: an explicitly set non-empty `repo-url` with all trailing `/` and then `.git` trimmed, plus `/issues`; else fizzy's issues for fizzy; else empty. `-Drepo-url=` (empty) gives empty, not `/issues`. - Empty disables the command through its `isEnabled`. - **Menus grey with their commands**: a menu `CommandItem` with no `enabled` of its own now falls back to `host.commandEnabled(id)`, in both the in-app bar (`Menu.zig`) and the native `.bar` path (`backend_native.menuEnabled`). Report a Bug, Restart and every other command with an `isEnabled` grey out with no per-item predicate. - **Packaging**: `app_is_fizzy`, `app_repo_url_set` and `app_issues_url` are carried through `Config` and set in `pack_opts` as well as `build_opts`, so the Velopack executable compiles. A hook for an app's own About body (logo, links, text) is left for a follow-up, as the issue says. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test` (101/101 steps, including `fizzy-issues-url-tests`), `zig build test-integration` (53/53), `zig build check-tests-reached` and `zig build test-sdk-version` (main's fingerprint) pass. - The packaging path: `zig build package` (Debug, no signing) succeeded, 50/50 steps. That compiles `exe_for_package` against `pack_opts` and runs `dotnet vpk pack` locally. - I did not open the dialog or menu to look at them. - [ ] Windows: - [ ] Linux: - [x] Web: `zig build check-web` passes. ## Follow-ups - Letting an app's plugin replace a fizzy command (`fizzy.about`, `fizzy.reportBug`) is a separate SDK PR, #536 (part of #518). Once it lands, Help's About label should follow a replacement's title. - An app built with Velopack and no `repo-url` still checks fizzy's update feed, because `repo-url` defaults to fizzy's. That is packaging identity, #520, and is not fixed here. - A hook for an app's own About body (logo, links, text). - The About dialog is still modal (the corner-button issue). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/AppInfo.zig | 12 +++++++ build/app.zig | 27 +++++++++++++++- build/issues_url.zig | 38 ++++++++++++++++++++++ src/backend/backend_native.zig | 2 +- src/editor/Infobar.zig | 5 ++- src/editor/Keybinds.zig | 14 +++++--- src/editor/Menu.zig | 2 +- src/editor/dialogs/AboutFizzy.zig | 53 ++++++++++++++++--------------- src/editor/menu_model.zig | 9 ++++-- 9 files changed, 125 insertions(+), 37 deletions(-) create mode 100644 build/issues_url.zig diff --git a/app/AppInfo.zig b/app/AppInfo.zig index 4a95e2915..bf6d7ce9f 100644 --- a/app/AppInfo.zig +++ b/app/AppInfo.zig @@ -32,6 +32,9 @@ version: []const u8, repo_url: []const u8, repo_url_fallback: []const u8, +/// Where Report a Bug goes (`-Dapp-issues-url`). Empty disables it. +issues_url: []const u8, + /// Plugin registry catalog endpoint. An app that ships its own store points this at its own /// registry; empty means "no store". registry_url: []const u8, @@ -46,8 +49,17 @@ pub const current: AppInfo = .{ .repo_url = build_opts.app_repo_url, .repo_url_fallback = build_opts.app_repo_url_fallback, .registry_url = build_opts.app_registry_url, + .issues_url = build_opts.app_issues_url, }; +/// Fizzy itself rather than an app built on it: the build's root package, not a dependency. What +/// is fizzy's own (the fox, fizzyed.it) shows only here. +pub const is_fizzy = build_opts.app_is_fizzy; + +/// `repo_url` is this app's own: fizzy's, or one the app set. Every app gets fizzy's by default, +/// so an app that set none has no repo to show and no updates of its own to offer. +pub const own_repo_url = build_opts.app_repo_url.len > 0 and (is_fizzy or build_opts.app_repo_url_set); + /// Null-terminated forms, for the C APIs that need them (SDL metadata, window title). pub const display_name_z: [:0]const u8 = build_opts.app_display_name ++ ""; diff --git a/build/app.zig b/build/app.zig index 4cbeea111..ca7638ce0 100644 --- a/build/app.zig +++ b/build/app.zig @@ -79,6 +79,9 @@ pub const Config = struct { app_registry_url: []const u8, app_repo_url: []const u8, app_repo_url_fallback: []const u8, + app_is_fizzy: bool, + app_repo_url_set: bool, + app_issues_url: []const u8, app_layout_path: ?std.Build.LazyPath, static_workbench: bool, static_text: bool, @@ -193,7 +196,8 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options // the latest release via the GitHub Releases API. Override at build time // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. - const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy"; + const repo_url_opt = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)"); + const app_repo_url = repo_url_opt orelse "https://github.com/fizzyedit/fizzy"; // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` // yields no update. Lets a build survive a repo move/rename: ship a binary @@ -227,6 +231,18 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption([]const u8, "app_registry_url", app_registry_url); build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); + // Fizzy itself rather than an app built on it: fizzy is the root package, an app reaches it + // as a dependency (`buildApp`). Structural, so a fork or a fizzy build that renames itself + // is still told apart from an app. + const app_is_fizzy = b.pkg_hash.len == 0; + // Where Report a Bug goes. Not `repo-url` as such, which is the update feed and defaults to + // fizzy's: an app reports to its own repo only when it named one (`issues_url.zig`). + const app_issues_url = b.option([]const u8, "app-issues-url", "Where Report a Bug goes; empty disables it (default: an explicit repo-url's issues, fizzy's for fizzy)") orelse + try @import("issues_url.zig").derive(b.allocator, repo_url_opt, app_is_fizzy); + const app_repo_url_set = repo_url_opt != null and repo_url_opt.?.len > 0; + build_opts.addOption(bool, "app_is_fizzy", app_is_fizzy); + build_opts.addOption(bool, "app_repo_url_set", app_repo_url_set); + build_opts.addOption([]const u8, "app_issues_url", app_issues_url); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); // Off, the app runs only the plugins it links (`buildApp`'s list): no plugin dylib is built, // and nothing loads at runtime — not fizzy's own dylibs, not the user plugins directory, not @@ -305,6 +321,9 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .app_registry_url = app_registry_url, .app_repo_url = app_repo_url, .app_repo_url_fallback = app_repo_url_fallback, + .app_is_fizzy = app_is_fizzy, + .app_repo_url_set = app_repo_url_set, + .app_issues_url = app_issues_url, .app_layout_path = app_layout_path, .static_workbench = static_workbench, .static_text = static_text, @@ -403,6 +422,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. pack_opts.addOption([]const u8, "app_registry_url", app_registry_url); pack_opts.addOption([]const u8, "app_repo_url", app_repo_url); pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); + pack_opts.addOption(bool, "app_is_fizzy", cfg.app_is_fizzy); + pack_opts.addOption(bool, "app_repo_url_set", cfg.app_repo_url_set); + pack_opts.addOption([]const u8, "app_issues_url", cfg.app_issues_url); pack_opts.addOption(bool, "velopack_enabled", true); pack_opts.addOption(bool, "static_workbench", static_workbench); pack_opts.addOption(bool, "static_text", static_text); @@ -666,6 +688,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // dvui half (`sdk/replay/`) compiles on its own below, and is covered by // `tests/integration.zig`. .{ "fizzy-tape-tests", "sdk/tape/root.zig" }, + // Where Report a Bug goes when the build isn't told. std-only (see issues_url.zig); the + // build is its only user. + .{ "fizzy-issues-url-tests", "build/issues_url.zig" }, }) |entry| { try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = entry[0], diff --git a/build/issues_url.zig b/build/issues_url.zig new file mode 100644 index 000000000..bb598f6d0 --- /dev/null +++ b/build/issues_url.zig @@ -0,0 +1,38 @@ +//! Where Report a Bug goes when the build isn't told (`-Dapp-issues-url`). std-only, so the rule +//! is tested in `zig build test` (`fizzy-issues-url-tests`) although the build is its only user. + +const std = @import("std"); + +pub const fizzy_issues = "https://github.com/fizzyedit/fizzy/issues"; + +/// The issues page of the repo an app named (`-Drepo-url`, null when it named none), trimmed of +/// trailing slashes and `.git`; else fizzy's own, for fizzy itself; else empty, which disables +/// Report a Bug. `repo-url` itself defaults to fizzy's (it is the update feed), so only an +/// explicit one says where an app's reports go. +pub fn derive(gpa: std.mem.Allocator, repo: ?[]const u8, is_fizzy: bool) ![]const u8 { + var r = repo orelse ""; + while (std.mem.endsWith(u8, r, "/")) r = r[0 .. r.len - 1]; + if (std.mem.endsWith(u8, r, ".git")) r = r[0 .. r.len - ".git".len]; + if (r.len > 0) return std.fmt.allocPrint(gpa, "{s}/issues", .{r}); + return if (is_fizzy) fizzy_issues else ""; +} + +test derive { + const gpa = std.testing.allocator; + const cases = [_]struct { repo: ?[]const u8, is_fizzy: bool, want: []const u8 }{ + .{ .repo = "https://github.com/me/app", .is_fizzy = false, .want = "https://github.com/me/app/issues" }, + .{ .repo = "https://github.com/me/app//", .is_fizzy = false, .want = "https://github.com/me/app/issues" }, + .{ .repo = "https://github.com/me/app.git", .is_fizzy = false, .want = "https://github.com/me/app/issues" }, + .{ .repo = "https://github.com/me/app.git/", .is_fizzy = false, .want = "https://github.com/me/app/issues" }, + .{ .repo = "https://github.com/me/toolkit", .is_fizzy = false, .want = "https://github.com/me/toolkit/issues" }, + .{ .repo = "", .is_fizzy = false, .want = "" }, + .{ .repo = "", .is_fizzy = true, .want = fizzy_issues }, + .{ .repo = null, .is_fizzy = false, .want = "" }, + .{ .repo = null, .is_fizzy = true, .want = fizzy_issues }, + }; + for (cases) |c| { + const got = try derive(gpa, c.repo, c.is_fizzy); + defer if (got.len > 0 and got.ptr != fizzy_issues.ptr) gpa.free(got); + try std.testing.expectEqualStrings(c.want, got); + } +} diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 604c9193b..e95fabc49 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -293,7 +293,7 @@ fn menuEnabled(_: ?*anyopaque, ref: platform.menu.Ref) bool { // A `visible` item that isn't is shown greyed rather than removed: rebuilding the // retained NSMenu on every state change isn't worth it for the same information. if (item.visible) |f| if (!f(fizzy.editor())) return false; - const enabled = item.enabled orelse return true; + const enabled = item.enabled orelse return fizzy.editor().app.host.commandEnabled(item.id); return enabled(fizzy.editor()); }, // A plugin's item is its command's, on both bars; no command is always enabled. diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index c7e891e8a..727fe82a9 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -101,8 +101,11 @@ pub fn draw(_: Infobar, editor: *fizzy.Editor) !void { } dvui.label(@src(), AppInfo.current.name, .{}, .{ .font = font, .gravity_y = 0.5, .margin = .all(0) }); + // Through the command, so an app that replaces `fizzy.about` replaces this too. if (button.clicked()) { - Dialogs.AboutFizzy.request(); + editor.app.host.runCommand("fizzy.about") catch |err| { + dvui.log.err("about command failed: {s}", .{@errorName(err)}); + }; } if (update_notify.badgeVisible()) { diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index cf373111f..c70f8cc76 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -22,6 +22,7 @@ const sdk = @import("fizzy_sdk"); const Keymap = @import("app").keymap.Keymap; const adapter = @import("app").keymap.dvui_adapter; const restart = @import("app").restart; +const AppInfo = @import("app").AppInfo; pub const Keybinds = @This(); @@ -172,9 +173,9 @@ const fizzy_commands = [_]FizzyCommand{ // Menu-bar-only actions. They had no command at all before Stage 1 — they existed solely as // `NativeMenuAction` switch arms — so they were unreachable from the palette and unbindable. .{ .id = "fizzy.debugToggleDvuiMenuOnMacOS", .title = "Show DVUI Menu (macOS)", .bind = null, .run = cmdToggleDvuiMenuOnMacOS }, - .{ .id = "fizzy.about", .title = "About Fizzy", .bind = null, .run = cmdAbout, .icon = icons.tvg.lucide.download }, - .{ .id = "fizzy.restart", .title = "Restart Fizzy", .bind = null, .run = cmdRestart, .isEnabled = canRestart, .icon = icons.tvg.lucide.@"rotate-ccw" }, - .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug, .icon = icons.tvg.lucide.bug }, + .{ .id = "fizzy.about", .title = AppInfo.about_title_z, .bind = null, .run = cmdAbout, .icon = icons.tvg.lucide.info }, + .{ .id = "fizzy.restart", .title = "Restart " ++ AppInfo.current.display_name, .bind = null, .run = cmdRestart, .isEnabled = canRestart, .icon = icons.tvg.lucide.@"rotate-ccw" }, + .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug, .isEnabled = canReportBug, .icon = icons.tvg.lucide.bug }, .{ .id = "fizzy.capture", .title = "Capture the Last 30 Seconds", .bind = null, .run = cmdCapture, .params = CaptureArgs.params, .runWith = CaptureArgs.bind(cmdCaptureWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.record", .title = "Tracks: Record…", .bind = null, .run = cmdTracks, .params = TracksRecord.params, .runWith = TracksRecord.bind(cmdTracksRecord), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.replay", .title = "Tracks: Replay…", .bind = null, .run = cmdTracks, .params = TracksReplay.params, .runWith = TracksReplay.bind(cmdTracksReplay), .icon = icons.tvg.lucide.gauge }, @@ -766,7 +767,12 @@ fn writeTraceFile(editor: *Editor, path: []const u8, ms: u32) ![]const u8 { } fn cmdReportBug(_: *anyopaque) anyerror!void { - _ = dvui.openURL(.{ .url = "https://github.com/fizzyedit/fizzy/issues" }); + if (AppInfo.current.issues_url.len > 0) _ = dvui.openURL(.{ .url = AppInfo.current.issues_url }); +} + +/// An app with nowhere to send reports greys Report a Bug out rather than sending them to fizzy. +fn canReportBug(_: *anyopaque) bool { + return AppInfo.current.issues_url.len > 0; } /// Register every fizzy action in the Host command registry. Called once during `Editor.init`. diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 731c1e330..e0ee3458a 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -162,7 +162,7 @@ fn drawModelItem( if (c.visible) |f| { if (!f(editor)) return; } - const enabled = if (c.enabled) |f| f(editor) else true; + const enabled = if (c.enabled) |f| f(editor) else editor.app.host.commandEnabled(c.id); const hotkey = hotkeyFor(editor, c.id); // The icon lives once, on the registered `Command` (`sdk.Command.icon`) — every // fizzy command is registered there too (`Keybinds.registerCommands`), so this and diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig index c74aa5b5e..affcbea7d 100644 --- a/src/editor/dialogs/AboutFizzy.zig +++ b/src/editor/dialogs/AboutFizzy.zig @@ -6,6 +6,7 @@ const build_opts = @import("build_opts"); const auto_update = @import("app").update.auto_update; const update_notify = @import("app").update.update_notify; const assets = @import("assets"); +const AppInfo = @import("app").AppInfo; /// True if the about dialog is already showing. pub fn active(win: *dvui.Window) bool { @@ -24,7 +25,7 @@ pub fn request() void { var mutex = fizzy.core.dialogs.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, - .title = "About Fizzy", + .title = AppInfo.about_title_z, .ok_label = "", .cancel_label = "", .resizeable = false, @@ -200,29 +201,31 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const body_small = body.larger(-1.0); const heading = dvui.Font.theme(.heading); - // Fox at the top, centered. Use a fixed natural size (96×96) so it - // doesn't blow out the dialog regardless of the source PNG's resolution. - // `.imageFile` so dvui decodes once and caches the texture — `fromImageFileBytes` - // re-decoded the PNG and leaked the pixels every frame, which halved the frame rate - // for as long as this dialog was open. - const fox_src: dvui.ImageSource = .{ .imageFile = .{ - .bytes = assets.files.@"fox.png", - .name = "fox.png", - .interpolation = .nearest, - } }; - _ = dvui.image(@src(), .{ .source = fox_src, .shrink = .ratio }, .{ - .gravity_x = 0.5, - .min_size_content = .{ .w = 96, .h = 96 }, - }); - - // Website link. - { - var link_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5, .padding = .{ .y = 4, .h = 6 } }); - defer link_row.deinit(); - dvui.link(@src(), .{ .url = "https://fizzyed.it", .label = "fizzyed.it" }, .{ - .font = heading, - .color_text = .{ .color = dvui.themeGet().color(.highlight, .fill) }, + if (comptime AppInfo.is_fizzy) { + // Fox at the top, centered. Use a fixed natural size (96×96) so it + // doesn't blow out the dialog regardless of the source PNG's resolution. + // `.imageFile` so dvui decodes once and caches the texture — `fromImageFileBytes` + // re-decoded the PNG and leaked the pixels every frame, which halved the frame rate + // for as long as this dialog was open. + const fox_src: dvui.ImageSource = .{ .imageFile = .{ + .bytes = assets.files.@"fox.png", + .name = "fox.png", + .interpolation = .nearest, + } }; + _ = dvui.image(@src(), .{ .source = fox_src, .shrink = .ratio }, .{ + .gravity_x = 0.5, + .min_size_content = .{ .w = 96, .h = 96 }, }); + + // Website link. + { + var link_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5, .padding = .{ .y = 4, .h = 6 } }); + defer link_row.deinit(); + dvui.link(@src(), .{ .url = "https://fizzyed.it", .label = "fizzyed.it" }, .{ + .font = heading, + .color_text = .{ .color = dvui.themeGet().color(.highlight, .fill) }, + }); + } } _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 8 } }); @@ -244,7 +247,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } // GitHub link (clickable). - if (build_opts.app_repo_url.len > 0) { + if (AppInfo.own_repo_url) { var gh_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5 }); defer gh_row.deinit(); dvui.labelNoFmt(@src(), "GitHub:", .{}, .{ .font = body_small, .gravity_y = 0.5, .padding = .{ .w = 6 } }); @@ -255,8 +258,6 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } else { dvui.labelNoFmt(@src(), "Updates: configure build with -Drepo-url", .{}, .{ .font = body_small, .gravity_x = 0.5 }); } - } else { - dvui.labelNoFmt(@src(), "Automatic updates are not included in this build.", .{}, .{ .font = body_small, .gravity_x = 0.5 }); } // `std.c.getenv` requires libc (not available on wasm32-freestanding). diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index e9c7329fe..c5bd8e01e 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -14,6 +14,8 @@ const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const Editor = @import("Editor.zig"); +const AppInfo = @import("app").AppInfo; +const auto_update = @import("app").update.auto_update; /// A label that depends on state ("Show Explorer" / "Hide Explorer"). pub const Title = union(enum) { @@ -49,7 +51,7 @@ pub const CommandItem = struct { /// AppKit menus are retained, so the native side disables the item instead of rebuilding /// the whole bar every frame. visible: ?*const fn (*Editor) bool = null, - /// Greyed out when false. Null means always enabled. + /// Greyed out when false. Null means the command's own enablement (`Host.commandEnabled`). enabled: ?*const fn (*Editor) bool = null, /// Keep the macOS item enabled even when `enabled` says otherwise. /// @@ -247,8 +249,9 @@ const demo_items = if (Editor.dvui_demo) [_]Item{ const help_items = [_]Item{ // The About dialog hosts the update check and install controls, which is why this and the - // macOS app menu's "About fizzy" are the same command. - .{ .command = .{ .id = "fizzy.about", .title = .{ .static = "Check for Updates…" } } }, + // macOS app menu's "About fizzy" are the same command. Named for the updates only in a build + // that has an updater with a feed of the app's own; otherwise it is the dialog's own title. + .{ .command = .{ .id = "fizzy.about", .title = .{ .static = if (auto_update.impl and AppInfo.own_repo_url) "Check for Updates…" else AppInfo.about_title_z } } }, .separator, // A demo of fizzy played by fizzy itself (`Editor.Demo`); the rest are in the palette. .{ .command = .{ .id = "fizzy.demo.tour", .title = .{ .static = "Take the Tour" }, .sf_symbol = "play.circle" } }, From 99f330a97ddcbcc82019dc93af730b51b4c87fa9 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:07:33 -0500 Subject: [PATCH 03/31] app: control server streams the app's events (SSE) (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md`, step 2. Follows fizzyedit/fizzy#490 (merged), with `main` merged in. ## What changes `GET /events` on the control socket streams the app's events as Server-Sent Events. That's every event on the host's bus: fizzy's `fizzy.document.*`, `fizzy.folder.*` and `fizzy.files.*`, plus anything a plugin emits. `?topics=a,b` picks names, or prefixes ending in `*`; with no topics a client gets everything. Dark behind `FIZZY_CONTROL=1`, like the rest of the server. ```sh curl -N --unix-socket ~/fizzy-profile/control.sock 'localhost/events?topics=fizzy.document.*' event: fizzy.document.opened data: {"path":"/…/hello.txt"} event: fizzy.document.saved data: {"path":"/…/hello.txt"} ``` - **One conversion, many clients.** The relay converts each event's ZON payload to JSON once. `Server.publish` appends the same SSE message to every stream that wants it. A payload that isn't ZON goes as `{"zon": ""}`. - **Nothing when unused.** The relay is a pseudo-plugin, like the `fizzy_plugin` that owns fizzy's own commands. It subscribes to `*` only while a stream is open and is withdrawn when the last one closes, so with no stream an emit still costs the bus one atomic load. Idle with a stream open, `devtools.stats` reads 0 frames: the keep-alive is written by the stream's own thread, with no UI wake. - **Streams end cleanly, even when the client has gone quiet:** - Each connection is an `Io` task, which `stop` cancels. std interrupts a task's blocked read or write on every OS. A socket `shutdown` doesn't end a blocked write on Windows, and closing the socket under one is `unreachable` in std there. - A client that falls more than 8 MB behind gets nothing more buffered, and on POSIX its socket is shut down at once. - A keep-alive comment every 15 s finds clients that went away. - At quit, `routes.stop` withdraws the relay before the server stops. - A stream is registered as it's answered, on the UI thread. Events published while its headers go out are buffered for it, and the relay doesn't drop out between frames. - **A std trap, worked around:** `BodyWriter.flush` flushes only the connection's writer, never the body writer's own buffer. The body writer gets no buffer, so each write reaches the connection at once. ### Changed from the plan - **No new SDK seam.** The plan proposed `host.publish(topic, zon)` for plugins' topics. The host already has that bus (`Host.emit` / `subscribe`, with prefix and `*` patterns), so the relay listens to it. No fingerprint move. - **Sessions are not in this step.** Nothing on the server holds state yet: claims need an owner, and recordings are the agent plugin's until step 5. A session id with nothing behind it would be a seam without a consumer. Sessions come with step 5, when `agent.*` commands want claims and tapes. I'll update the plan. ## Review An independent review pass found 8 issues. All fixed, with tests: - **High:** a client that stopped reading hung the quit. Its thread blocked in a write and `stop` waited for it. - **High:** a stream registered only after its headers went out. Its first events could be lost, and the relay could switch off for a frame. - Queued requests were dropped when memory ran out. - Every event paid a ZON parse even when no stream wanted it. - `HEAD /events` was refused. - An event name containing a line break could forge SSE messages. - Unsendable payloads were dropped silently. They now arrive as `null`. - The relay's state was module-global. It now lives on `App`. Verifying the stall fix on Windows turned up a test bug, not a server one. A test client reading "to the end" sometimes hangs there, because Windows' AF_UNIX doesn't always tell a reader the other side closed. The test client now reads `Content-Length` bytes, as HTTP clients do, and the pitfall is written in `app/AGENTS.md`. One real-world trace remains: a `curl -N /events` on Windows may not notice fizzy closing the stream at quit. ## SDK impact - [x] None. It uses `Host.subscribe` / `withdrawPlugin` as they are. `test-sdk-version` passes. - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - **Unit tests:** a stream gets only the topics it asked for, in order, and ends when the server stops; topic parsing and matching. - **Integration test:** with a real `Host` and server, the bus listens only while a stream is open, an emitted and delivered event reaches the stream as JSON, and after `routes.stop` nothing listens. - **Live:** `curl -N /events` while driving fizzy over the same socket received the document events. `HEAD /events` gets headers only. Fizzy quits at once with a stalled stream client attached that never reads. - **Gates:** fmt, `zig build`, `test` (583/584, the skip is pre-existing), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: fizzy's `fizzy-control-server-tests.exe`, now 11 tests, passed 30/30 runs in the Windows 11 ARM64 VM. The tests include: a stalled stream client, overrun, a stopped server with a blocked writer, an idle keep-alive connection at stop, the handover, and an event published as its stream is answered. The x86_64 cross-compile links. - [ ] Linux: CI. Cross-compiles for x86_64-linux without libc. - [x] Web: `check-web`; nothing is added there. ## Follow-ups - Step 3: `/openapi.json`, which can list `/events` and the declared events (`Host.declareEvent`) too. - `settled` as an event, for scripts that wait for the UI to settle: the automation service knows it. It needs that service to emit, which is a change of its own. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/AGENTS.md | 4 + app/App.zig | 3 + app/control/Server.zig | 488 +++++++++++++++++++++++++++++++++++++++-- app/control/routes.zig | 75 ++++++- src/editor/Editor.zig | 4 +- tests/integration.zig | 67 ++++++ 6 files changed, 613 insertions(+), 28 deletions(-) diff --git a/app/AGENTS.md b/app/AGENTS.md index 14e9a6e8f..79dc303f1 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -42,3 +42,7 @@ each directory is. Windows `accept` treats the cancelled wait that closing causes as `unreachable` (a panic), and connecting to the socket's path to wake it reaches whoever listens there now. `Future.cancel` interrupts it on every OS (`control/Server.zig`). +- **On Windows, a socket test must not read to EOF when it need not.** Its AF_UNIX sometimes never + tells a reader that the other side closed, so a test client reading "until the end" hangs about + one run in twenty. Read `Content-Length` bytes, as HTTP clients do (`control/Server.zig`'s + `TestClient`). It sent a day after server bugs that were not there. diff --git a/app/App.zig b/app/App.zig index d2fa0c2cc..cc4efce3d 100644 --- a/app/App.zig +++ b/app/App.zig @@ -45,6 +45,7 @@ const Host = sdk.Host; const PluginLoader = @import("root.zig").store.Loader; const SettingsWatcher = @import("watch/SettingsWatcher.zig"); const ControlServer = @import("control/Server.zig"); +const ControlRelay = @import("control/routes.zig").Relay; const ExecutableWatcher = @import("watch/ExecutableWatcher.zig"); const FolderWatcher = @import("watch/FolderWatcher.zig"); const Keymap = @import("keymap/Keymap.zig"); @@ -265,6 +266,8 @@ settings_watcher: ?SettingsWatcher = null, /// (plans/CONTROL_SERVER_PLAN.md, steps 1–3 land dark), answered each frame, stopped at quit. /// Always void on wasm, which has no socket. control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, +/// What relays the app's events to the control server's streams (`control.routes.Relay`). +control_relay: if (builtin.target.cpu.arch == .wasm32) void else ControlRelay = if (builtin.target.cpu.arch == .wasm32) {} else .{}, /// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts /// the app into it, keeping every document. Null while the setting is off, and wherever diff --git a/app/control/Server.zig b/app/control/Server.zig index 8638b41a3..8e00458f2 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -1,11 +1,15 @@ //! The control server's transport: HTTP/1.1 on a Unix socket only this user can open, served by -//! threads of its own (plans/CONTROL_SERVER_PLAN.md). Nothing here touches the app: a request is -//! handed to the UI thread (`take`, once a frame), and the connection's thread waits until it is +//! `Io` tasks of its own (plans/CONTROL_SERVER_PLAN.md). Nothing here touches the app: a request is +//! handed to the UI thread (`take`, once a frame), and the connection's task waits until it is //! answered (`Request.answer`), then writes the response. //! -//! Each connection has a thread of its own, so a client holding a connection open never makes -//! another wait, and keep-alive works as curl and every HTTP library expect. The UI thread answers -//! requests in the order they arrived. Idle, the server costs a thread blocked in `accept`. +//! Each connection is a task of its own (`Io.concurrent`, a thread each), so a client holding a +//! connection open never makes another wait, and keep-alive works as curl and every HTTP library +//! expect. The UI thread answers requests in the order they arrived. Idle, the server costs a task +//! blocked in `accept`. Tasks, not plain threads, because `stop` must end a read or a write blocked +//! on a client that went quiet, and std interrupts a task's blocked call on every OS (`cancel`), +//! where neither `shutdown` nor closing the socket interrupts a blocked write on Windows (closing it +//! is `unreachable` in std there). //! //! Lifted from the agent plugin's socket server (`fizzyedit/agent` `src/Server.zig`), which kept a //! private ZON-lines wire behind a token; here the socket's permissions are the authorization. @@ -19,6 +23,12 @@ const Io = std.Io; /// The most a request body may hold. A command's arguments can be a whole drawing, but not this. pub const max_body = 64 * 1024 * 1024; +/// The most a stream may fall behind: a client that reads no faster than events come is closed +/// rather than kept buffering without end. +pub const max_backlog = 8 * 1024 * 1024; +/// How long a stream may stay quiet before a keep-alive comment: how a client that went away +/// without closing is noticed. +pub const keep_alive_ms = 15_000; gpa: std.mem.Allocator, io: Io, @@ -41,14 +51,42 @@ socket_inode: ?Io.File.INode = null, /// Requests waiting for the UI thread, under `lock`. lock: Io.Mutex = .init, pending: std.ArrayListUnmanaged(*Request) = .empty, +/// Connections streaming events (`publish`), under `lock`. +streams: std.ArrayListUnmanaged(*Stream) = .empty, + +/// A connection that asked for events: what it wants, and what is waiting to be written to it. +pub const Stream = struct { + /// Event names or prefixes ending in `*` (`fizzy.document.*`), from `?topics=a,b`; `*` alone + /// takes everything. In the connection's arena. + topics: []const []const u8, + /// Under the server's `lock`: whole SSE messages, appended by `publish`, written and emptied + /// by the connection's thread. `gpa`-owned. + backlog: std.ArrayListUnmanaged(u8) = .empty, + /// It fell more than `max_backlog` behind: nothing more is buffered for it, and it ends once its + /// write returns (at once on POSIX, where `publish` shuts the socket down) or at `stop`. + overrun: bool = false, + ready: Io.Event = .unset, + conn: *Conn, + + fn wants(self: *const Stream, name: []const u8) bool { + for (self.topics) |t| { + if (t.len > 0 and t[t.len - 1] == '*') { + if (std.mem.startsWith(u8, name, t[0 .. t.len - 1])) return true; + } else if (std.mem.eql(u8, t, name)) return true; + } + return false; + } +}; const Conn = struct { stream: Io.net.Stream, - thread: ?std.Thread = null, + task: ?Io.Future(void) = null, done: std.atomic.Value(bool) = .init(false), /// A request of this connection's is queued or being answered, under `lock`: `stop` leaves its /// socket open so the answer ("fizzy is closing") can still be written. waiting: bool = false, + /// It streams events, under `lock`. + streaming: bool = false, }; /// One request on its way through the UI thread. Everything in it lives in the connection's @@ -62,12 +100,35 @@ pub const Request = struct { /// Set by `answer`. `body` is `gpa`-owned and the connection's thread frees it. response: Response = .{}, done: Io.Event = .unset, + conn: *Conn, + /// The connection's, which its thread does not touch while it waits for `done`. + arena: std.mem.Allocator, + /// Set by `answerStream`. + stream: Stream = undefined, /// The path without its query. pub fn path(self: *const Request) []const u8 { return self.target[0 .. std.mem.indexOfScalar(u8, self.target, '?') orelse self.target.len]; } + /// Answer by streaming events to this connection from now on, from the UI thread. The stream + /// is registered here, before the answer: what is published from now on is buffered for it + /// while its headers go out, and `take` counts it in the same frame. + pub fn answerStream(self: *Request, server: *Server) void { + const topics = topicsOf(self.arena, self.target) catch + return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + self.stream = .{ .topics = topics, .conn = self.conn }; + { + server.lock.lockUncancelable(server.io); + defer server.lock.unlock(server.io); + server.streams.append(server.gpa, &self.stream) catch + return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + self.conn.streaming = true; + } + self.response = .{ .stream = true, .status = .ok }; + self.done.set(server.io); + } + /// Answer, from the UI thread. `body` is copied with the server's `gpa`. pub fn answer(self: *Request, server: *Server, status: std.http.Status, content_type: []const u8, body: []const u8) void { self.response = .{ @@ -81,6 +142,9 @@ pub const Request = struct { }; pub const Response = struct { + /// Answered by turning the connection into an event stream (`GET /events`): the status and + /// body are not sent, the connection stays open and `publish` writes to it. + stream: bool = false, status: std.http.Status = .internal_server_error, /// Static: a string literal, never freed. content_type: []const u8 = "text/plain", @@ -112,32 +176,71 @@ pub fn start(self: *Server) !void { self.accept_task = try self.io.concurrent(run, .{self}); } -/// Requests the UI thread should answer now. On the UI thread, each frame; each must be answered. -pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) void { +/// Requests the UI thread should answer now, each of which must be answered, and how many +/// connections stream events. On the UI thread, each frame. +pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) usize { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); - out.appendSlice(self.gpa, self.pending.items) catch return; + // Out of memory: left queued for the next frame, never dropped unanswered. + out.appendSlice(self.gpa, self.pending.items) catch return self.streams.items.len; self.pending.clearRetainingCapacity(); + return self.streams.items.len; +} + +/// Whether any stream wants the event `name`: the caller skips building a message nobody reads. +pub fn wants(self: *Server, name: []const u8) bool { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + for (self.streams.items) |st| if (!st.overrun and st.wants(name)) return true; + return false; +} + +/// Send the event `name`, its payload already JSON on one line, to every stream that wants it. +/// The message is built once and copied to each. From any thread. +pub fn publish(self: *Server, name: []const u8, json: []const u8) void { + // A line break would end the `event:` line early, and let a name forge messages of its own. + if (std.mem.indexOfAny(u8, name, "\r\n") != null) return; + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + for (self.streams.items) |st| { + if (st.overrun or !st.wants(name)) continue; + const fits = st.backlog.items.len + name.len + json.len + 16 <= max_backlog; + if (fits) { + if (st.backlog.print(self.gpa, "event: {s}\ndata: {s}\n\n", .{ name, json })) |_| { + st.ready.set(self.io); + continue; + } else |_| {} + } + // Too far behind: shut down, which ends a blocked write on POSIX (not on Windows, where the + // task ends when its write returns or `stop` cancels it). Nothing more is buffered either way. + st.overrun = true; + if (!st.conn.done.load(.acquire)) st.conn.stream.shutdown(self.io, .both) catch {}; + st.ready.set(self.io); + } } pub fn stop(self: *Server) void { self.stopping.store(true, .release); + // The connections whose requests this answers ("fizzy is closing"): each writes it and ends. + var answered: std.ArrayListUnmanaged(*Conn) = .empty; + defer answered.deinit(self.gpa); { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); - // Whatever the UI thread will never answer now, answered: its connection's thread is - // waiting, and writes it before it sees `stopping` and ends. - for (self.pending.items) |r| r.answer(self, .service_unavailable, "text/plain", "fizzy is closing\n"); + for (self.pending.items) |r| { + answered.append(self.gpa, r.conn) catch {}; + r.answer(self, .service_unavailable, "text/plain", "fizzy is closing\n"); + } self.pending.clearRetainingCapacity(); - // The rest are reading their next request: end those reads. Only the ones still open, - // since shutting down a closed descriptor is a panic in std. - for (self.conns.items) |c| if (!c.done.load(.acquire) and !c.waiting) c.stream.shutdown(self.io, .both) catch {}; } if (self.accept_task) |*t| t.cancel(self.io); self.accept_task = null; - // No more are accepted: wait for each connection's thread, its read ended above. + // No more are accepted, so `conns` holds still. Every other task, reading its next request or + // streaming, blocked on a quiet client or not, is cancelled. for (self.conns.items) |c| { - if (c.thread) |t| t.join(); + if (c.task) |*t| { + if (std.mem.indexOfScalar(*Conn, answered.items, c) != null) t.await(self.io) else t.cancel(self.io); + } self.gpa.destroy(c); } self.conns.deinit(self.gpa); @@ -149,6 +252,7 @@ pub fn stop(self: *Server) void { } else |_| {} } self.pending.deinit(self.gpa); + self.streams.deinit(self.gpa); } fn run(self: *Server) void { @@ -179,7 +283,7 @@ fn run(self: *Server) void { self.gpa.destroy(conn); continue; }; - conn.thread = std.Thread.spawn(.{}, serveConn, .{ self, conn }) catch { + conn.task = self.io.concurrent(serveConn, .{ self, conn }) catch { _ = self.conns.pop(); stream.close(self.io); self.gpa.destroy(conn); @@ -193,6 +297,9 @@ fn serveConn(self: *Server, conn: *Conn) void { // Closed under `lock`, with `done`, so `stop` never shuts down a descriptor already closed. self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); + // The sending side first: a client reading to the end then sees it end. Windows' AF_UNIX + // otherwise sometimes never tells a client still reading that the socket closed. + conn.stream.shutdown(self.io, .send) catch {}; conn.stream.close(self.io); conn.done.store(true, .release); } @@ -206,7 +313,8 @@ fn reap(self: *Server) void { i += 1; continue; } - if (c.thread) |t| t.join(); + // Done, so this returns at once. + if (c.task) |*t| t.await(self.io); self.gpa.destroy(c); _ = self.conns.swapRemove(i); } @@ -245,6 +353,8 @@ fn serve(self: *Server, conn: *Conn) void { .target = arena.dupe(u8, head.head.target) catch return, .content_type = if (head.head.content_type) |t| arena.dupe(u8, t) catch return else null, .body = "", + .conn = conn, + .arena = arena, }; var body_buf: [4096]u8 = undefined; const body_reader = head.readerExpectContinue(&body_buf) catch return; @@ -264,6 +374,7 @@ fn serve(self: *Server, conn: *Conn) void { // Uncancelable: `&req` is queued, and an early return would leave the UI thread writing // into a frame that is gone. req.done.waitUncancelable(self.io); + if (req.response.stream) return self.serveStream(&head, &req.stream); const body = req.response.body orelse "out of memory\n"; defer if (req.response.body) |b| self.gpa.free(b); const sent = head.respond(body, .{ @@ -280,6 +391,69 @@ fn serve(self: *Server, conn: *Conn) void { } } +/// Write events to the connection until the client goes, falls too far behind, or the server +/// stops. `stream` was registered by `answerStream`; it is removed here, whatever happens. +fn serveStream(self: *Server, head: *std.http.Server.Request, stream: *Stream) void { + defer { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + for (self.streams.items, 0..) |st, i| if (st == stream) { + _ = self.streams.swapRemove(i); + break; + }; + stream.backlog.deinit(self.gpa); + } + // No buffer of its own: `BodyWriter.flush` sends only the connection's, so whatever the body + // writer held would wait. Its writes go straight into the connection's buffer instead. + var body = head.respondStreaming(&.{}, .{ .respond_options = .{ + .keep_alive = false, + .transfer_encoding = .none, + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-cache" }, + }, + } }) catch return; + body.flush() catch return; + var out: std.ArrayListUnmanaged(u8) = .empty; + defer out.deinit(self.gpa); + while (true) { + const quiet = if (stream.ready.waitTimeout(self.io, .{ .duration = .{ .raw = .fromMilliseconds(keep_alive_ms), .clock = .awake } })) |_| false else |err| switch (err) { + error.Timeout => true, + error.Canceled => return, + }; + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + if (self.stopping.load(.acquire) or stream.overrun) return; + // Taken whole: `publish` goes on appending to an empty list while this one is written. + std.mem.swap(std.ArrayListUnmanaged(u8), &out, &stream.backlog); + stream.ready.reset(); + } + if (out.items.len > 0) { + body.writer.writeAll(out.items) catch return; + } else if (quiet) { + body.writer.writeAll(": keep-alive\n\n") catch return; + } else continue; + body.flush() catch return; + out.clearRetainingCapacity(); + } +} + +/// `?topics=a,b` as a list; everything when absent or empty. +fn topicsOf(arena: std.mem.Allocator, target: []const u8) ![]const []const u8 { + const query = target[(std.mem.indexOfScalar(u8, target, '?') orelse return &.{"*"}) + 1 ..]; + var list: std.ArrayListUnmanaged([]const u8) = .empty; + var params = std.mem.tokenizeScalar(u8, query, '&'); + while (params.next()) |param| { + const eq = std.mem.indexOfScalar(u8, param, '=') orelse continue; + if (!std.mem.eql(u8, param[0..eq], "topics")) continue; + const value = std.Uri.percentDecodeInPlace(try arena.dupe(u8, param[eq + 1 ..])); + var names = std.mem.tokenizeScalar(u8, value, ','); + while (names.next()) |n| try list.append(arena, std.mem.trim(u8, n, " ")); + } + return if (list.items.len == 0) &.{"*"} else list.items; +} + // ---- tests --------------------------------------------------------------------------------- fn noWake() void {} @@ -302,7 +476,26 @@ const TestClient = struct { w.interface.flush() catch return; var rbuf: [1024]u8 = undefined; var r = stream.reader(self.io, &rbuf); - self.reply_len = r.interface.readSliceShort(&self.reply) catch 0; + // As an HTTP client does: the head, then `Content-Length` bytes when it says, else to the + // end (a stream). Never to the end when it need not: whether Windows' AF_UNIX tells a + // reader the other side closed is not something to test here. + while (self.reply_len < self.reply.len) { + const n = r.interface.readSliceShort(self.reply[self.reply_len..][0..1]) catch return; + if (n == 0) return; + self.reply_len += 1; + if (std.mem.endsWith(u8, self.reply[0..self.reply_len], "\r\n\r\n")) break; + } + const head = self.reply[0..self.reply_len]; + const key = "content-length: "; + if (std.ascii.indexOfIgnoreCase(head, key)) |i| { + const end = std.mem.indexOfPos(u8, head, i, "\r\n") orelse return; + const len = std.fmt.parseInt(usize, head[i + key.len .. end], 10) catch return; + const want = @min(len, self.reply.len - self.reply_len); + r.interface.readSliceAll(self.reply[self.reply_len..][0..want]) catch return; + self.reply_len += want; + } else { + self.reply_len += r.interface.readSliceShort(self.reply[self.reply_len..]) catch 0; + } } }; @@ -334,7 +527,7 @@ test "a request reaches the UI thread and its answer goes back" { defer taken.deinit(gpa); var waited: usize = 0; while (taken.items.len == 0 and waited < 2000) : (waited += 1) { - server.take(&taken); + _ = server.take(&taken); if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; } try std.testing.expectEqual(@as(usize, 1), taken.items.len); @@ -420,7 +613,7 @@ test "what cannot be served is refused before its body, and never reaches the ap } var taken: std.ArrayListUnmanaged(*Request) = .empty; defer taken.deinit(gpa); - server.take(&taken); + _ = server.take(&taken); try std.testing.expectEqual(@as(usize, 0), taken.items.len); } @@ -455,7 +648,7 @@ test "a server that took over the path keeps it when the old one stops" { defer taken.deinit(gpa); var waited: usize = 0; while (taken.items.len == 0 and waited < 2000) : (waited += 1) { - new.take(&taken); + _ = new.take(&taken); if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; } try std.testing.expectEqual(@as(usize, 1), taken.items.len); @@ -463,3 +656,252 @@ test "a server that took over the path keeps it when the old one stops" { thread.join(); try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nnew")); } + +test "a stream gets the events it asked for, once each, until the server stops" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + errdefer server.stop(); + var client: TestClient = .{ .io = io, .path = path, .request = "GET /events?topics=fizzy.document.*,chat.turn_ended HTTP/1.1\r\nHost: x\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var streams: usize = 0; + var waited: usize = 0; + while (streams == 0 and waited < 2000) : (waited += 1) { + streams = server.take(&taken); + for (taken.items) |r| r.answerStream(&server); + taken.clearRetainingCapacity(); + if (streams == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), streams); + server.publish("fizzy.document.saved", "{\"path\":\"/a\"}"); + server.publish("fizzy.folder.opened", "{\"path\":\"/p\"}"); + server.publish("chat.turn_ended", "{}"); + // Let the stream write before the stop ends it. + io.sleep(.fromMilliseconds(50), .awake) catch {}; + server.stop(); + thread.join(); + + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.indexOf(u8, reply, "content-type: text/event-stream\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\nevent: fizzy.document.saved\ndata: {\"path\":\"/a\"}\n\nevent: chat.turn_ended\ndata: {}\n\n")); +} + +test "topics come from the query, and none means everything" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{"*"}), try topicsOf(a, "/events")); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{"*"}), try topicsOf(a, "/events?topics=")); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "fizzy.document.*", "x.y" }), try topicsOf(a, "/events?a=1&topics=fizzy.document.%2A,%20x.y")); + const st: Stream = .{ .topics = &.{ "fizzy.document.*", "x.y" }, .conn = undefined }; + try std.testing.expect(st.wants("fizzy.document.saved")); + try std.testing.expect(st.wants("x.y")); + try std.testing.expect(!st.wants("x.yz")); + try std.testing.expect(!st.wants("fizzy.folder.opened")); +} + +/// Connects, asks for events, then reads nothing until `release`: a paused `curl | less`. +const StalledClient = struct { + io: Io, + path: []const u8, + release: Io.Event = .unset, + + fn run(self: *StalledClient) void { + const addr = Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(self.io) catch return; + defer stream.close(self.io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(self.io, &wbuf); + w.interface.writeAll("GET /events HTTP/1.1\r\nHost: x\r\n\r\n") catch return; + w.interface.flush() catch return; + self.release.waitUncancelable(self.io); + } +}; + +fn openStreams(server: *Server) usize { + server.lock.lockUncancelable(server.io); + defer server.lock.unlock(server.io); + return server.streams.items.len; +} + +fn answerStreams(server: *Server, want: usize) !void { + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(server.gpa); + var waited: usize = 0; + while (openStreams(server) < want and waited < 2000) : (waited += 1) { + _ = server.take(&taken); + for (taken.items) |r| r.answerStream(server); + taken.clearRetainingCapacity(); + if (openStreams(server) < want) server.io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(want, openStreams(server)); +} + +test "a client that stops reading neither holds up the quit nor buffers without end" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + var client: StalledClient = .{ .io = io, .path = path }; + const thread = try std.Thread.spawn(.{}, StalledClient.run, .{&client}); + defer { + client.release.set(io); + thread.join(); + } + try answerStreams(&server, 1); + // Until its task blocks writing and the backlog passes the cap; how much the socket takes first + // is the OS's (Windows grows its buffers), so publish until then, up to far more than any. + const chunk = "\"" ++ "x" ** (64 * 1024) ++ "\""; + var published: usize = 0; + while (!overrun(&server) and published < 256 * 1024 * 1024) : (published += chunk.len) server.publish("e", chunk); + try std.testing.expect(overrun(&server)); + // Buffered no further: the backlog stays under the cap whatever else is published. + for (0..64) |_| server.publish("e", chunk); + server.lock.lockUncancelable(io); + const kept = if (server.streams.items.len > 0) server.streams.items[0].backlog.items.len else 0; + server.lock.unlock(io); + try std.testing.expect(kept <= max_backlog); + // And the quit (the deferred `stop`) returns, its write blocked or not. +} + +fn overrun(server: *Server) bool { + server.lock.lockUncancelable(server.io); + defer server.lock.unlock(server.io); + // Gone already counts: on POSIX the shutdown ended it at once. + return server.streams.items.len == 0 or server.streams.items[0].overrun; +} + +test "a stopped server ends a stream whose client stopped reading" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + // A failed expectation still stops it, so the test binary is not left running. + errdefer server.stop(); + var client: StalledClient = .{ .io = io, .path = path }; + const thread = try std.Thread.spawn(.{}, StalledClient.run, .{&client}); + defer { + client.release.set(io); + thread.join(); + } + try answerStreams(&server, 1); + // Enough to block its write, not enough to overrun. + const chunk = "\"" ++ "x" ** (64 * 1024) ++ "\""; + for (0..16) |_| server.publish("e", chunk); + io.sleep(.fromMilliseconds(50), .awake) catch {}; + // Returns: the stream's socket is shut down under its blocked write. + server.stop(); +} + +test "an event published as the stream is answered is not lost" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var client: TestClient = .{ .io = io, .path = path, .request = "GET /events HTTP/1.1\r\nHost: x\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + _ = server.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + // Registered by the answer itself, on this thread: the publish right after reaches it even + // though its connection has not written the headers yet. + taken.items[0].answerStream(&server); + server.publish("first", "1"); + io.sleep(.fromMilliseconds(50), .awake) catch {}; + server.stop(); + thread.join(); + try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nevent: first\ndata: 1\n\n")); +} + +test "a stopped server ends a keep-alive connection waiting for its next request" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + errdefer server.stop(); + // Asks once, keeps the connection, and sends nothing more until `release`. + const Idle = struct { + io: Io, + path: []const u8, + got: usize = 0, + release: Io.Event = .unset, + fn run(self: *@This()) void { + const addr = Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(self.io) catch return; + defer stream.close(self.io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(self.io, &wbuf); + w.interface.writeAll("GET / HTTP/1.1\r\nHost: x\r\n\r\n") catch return; + w.interface.flush() catch return; + var rbuf: [256]u8 = undefined; + var r = stream.reader(self.io, &rbuf); + var reply: [256]u8 = undefined; + // The answer below is 2 bytes, after a head of known shape: read to its end. + while (self.got < reply.len) { + const n = r.interface.readSliceShort(reply[self.got..][0..1]) catch return; + if (n == 0) return; + self.got += 1; + if (std.mem.endsWith(u8, reply[0..self.got], "\r\n\r\nhi")) break; + } + self.release.waitUncancelable(self.io); + } + }; + var client: Idle = .{ .io = io, .path = path }; + const thread = try std.Thread.spawn(.{}, Idle.run, .{&client}); + defer { + client.release.set(io); + thread.join(); + } + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + _ = server.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + taken.items[0].answer(&server, .ok, "text/plain", "hi"); + // Its task is back reading the next request when the server stops: that read ends. + io.sleep(.fromMilliseconds(50), .awake) catch {}; + server.stop(); +} diff --git a/app/control/routes.zig b/app/control/routes.zig index b32d957ab..c4a301a17 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -6,6 +6,8 @@ //! GET /commands every command: id, title, owner, whether it is enabled, its params //! GET /commands/{id} one of them //! POST /commands/{id} run it: the body is its arguments, the response its result +//! GET /events the app's events as Server-Sent Events, `?topics=a,b` to choose +//! (a name, or a prefix ending in `*`); every event when absent //! //! Bodies are JSON, converted at this edge (`json.zig`). A request sent as `application/zon` is //! passed through as ZON and answered in ZON. Errors are HTTP's: 404 for an unknown command, 400 @@ -18,6 +20,8 @@ const json = @import("json.zig"); const Server = @import("Server.zig"); pub const Answer = struct { + /// Stream the app's events to this connection instead (`GET /events`). + stream: bool = false, status: std.http.Status, /// Static: a string literal. content_type: []const u8 = "application/json", @@ -28,16 +32,73 @@ const zon_type = "application/zon"; /// Answer what waits on `server`. On the UI thread, once a frame, before plugins' `beginFrame`, so /// a command run here is seen by the frame that follows. -pub fn answerPending(server: *Server, host: *sdk.Host) void { +pub fn answerPending(server: *Server, relay: *Relay, host: *sdk.Host) void { var taken: std.ArrayListUnmanaged(*Server.Request) = .empty; defer taken.deinit(server.gpa); - server.take(&taken); + var streaming = server.take(&taken) > 0; for (taken.items) |req| { var arena: std.heap.ArenaAllocator = .init(server.gpa); defer arena.deinit(); const a = handle(host, req.method, req.path(), req.content_type, req.body, arena.allocator()); - req.answer(server, a.status, a.content_type, a.body); + if (a.stream) { + req.answerStream(server); + streaming = true; + } else req.answer(server, a.status, a.content_type, a.body); } + relay.follow(host, server, streaming); +} + +/// Stop relaying and stop `server`. On the UI thread, at quit: after it, no event reaches the +/// stopped server. +pub fn stop(server: *Server, relay: *Relay, host: *sdk.Host) void { + relay.follow(host, server, false); + server.stop(); +} + +/// The subscription that relays the app's events to the server's streams, owned beside the server +/// (`App.control_relay`). Its plugin is a pseudo-plugin, as fizzy's own commands have +/// (`fizzy_plugin` in fizzy's `Keybinds.zig`): never in `host.plugins`, so no hook fires on it. It +/// listens only while a stream is open, so with none an emitted event costs the bus its one atomic +/// load, as before. +pub const Relay = struct { + plugin: sdk.Plugin = .{ + .state = undefined, // the server, set when it starts listening + .vtable = &relay_vtable, + .id = "fizzy.control", + .display_name = "Control server", + }, + on: bool = false, + + const relay_vtable: sdk.Plugin.VTable = .{}; + + fn follow(self: *Relay, host: *sdk.Host, server: *Server, on: bool) void { + if (on == self.on) return; + if (on) { + self.plugin.state = server; + host.subscribe(.{ .event = "*", .owner = &self.plugin, .handler = relayEvent }) catch return; + } else host.withdrawPlugin(&self.plugin); + self.on = on; + } +}; + +/// One event, as JSON once, to every stream that wants it (`Server.publish`). +fn relayEvent(state: *anyopaque, event: sdk.events.Event) void { + const server: *Server = @ptrCast(@alignCast(state)); + // Built only for a stream that reads it: a client on one topic costs the others nothing. + if (!server.wants(event.name)) return; + var arena_state: std.heap.ArenaAllocator = .init(server.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const data = json.jsonFromZon(arena, event.payload) catch |err| switch (err) { + // A payload that is not ZON goes as its text, which JSON can carry while it is UTF-8. + error.NotZon => if (std.unicode.utf8ValidateSlice(event.payload)) + std.json.Stringify.valueAlloc(arena, .{ .zon = event.payload }, .{}) catch "null" + else + "null", + // Said as `null` rather than dropped: the client still learns that it happened. + error.NotUtf8, error.OutOfMemory, error.NotJson, error.NotAnObject => "null", + }; + server.publish(event.name, data); } /// Answer one request. Everything returned lives in `arena`. @@ -65,6 +126,14 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ for (host.commands.items) |*c| try list.append(arena, try describe(c, arena)); return ok(arena, list.items); } + if (std.mem.eql(u8, trimmed, "/events")) { + return switch (method) { + .GET => .{ .status = .ok, .stream = true, .body = "" }, + // The headers alone: a stream that sends no body would never end. + .HEAD => .{ .status = .ok, .content_type = "text/event-stream", .body = "" }, + else => notAllowed(arena, "GET"), + }; + } const prefix = "/commands/"; if (std.mem.startsWith(u8, trimmed, prefix)) { const id = std.Uri.percentDecodeInPlace(try arena.dupe(u8, trimmed[prefix.len..])); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9d9f22d98..ae958c830 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4232,7 +4232,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.host.deliverEvents(); // Requests from the control server, so the frame that follows sees what they ran. if (comptime builtin.target.cpu.arch != .wasm32) { - if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.host); + if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.control_relay, &editor.app.host); } // A seek replays a tape's commands where nobody watches: not shown as activity. editor.app.host.activity_muted = editor.demo.player.state == .seeking; @@ -6419,7 +6419,7 @@ pub fn deinit(editor: *Editor) !void { // command would reach goes. if (comptime builtin.target.cpu.arch != .wasm32) { if (editor.app.control) |*c| { - c.stop(); + @import("app").control.routes.stop(c, &editor.app.control_relay, &editor.app.host); editor.app.gpa.free(c.socket_path); editor.app.control = null; } diff --git a/tests/integration.zig b/tests/integration.zig index f85dc3c92..070035425 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6859,6 +6859,73 @@ test "control: commands are listed, described and run over the routes" { try std.testing.expectEqualStrings("{\"error\":\"line 200 is past the end\"}", failed.body); } +const ControlStreamClient = struct { + path: []const u8, + reply: [4096]u8 = undefined, + len: usize = 0, + fn run(self: *ControlStreamClient) void { + const io = std.testing.io; + const addr = std.Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(io) catch return; + defer stream.close(io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(io, &wbuf); + w.interface.writeAll("GET /events?topics=t.* HTTP/1.1\r\nHost: x\r\n\r\n") catch return; + w.interface.flush() catch return; + var rbuf: [256]u8 = undefined; + var r = stream.reader(io, &rbuf); + self.len = r.interface.readSliceShort(&self.reply) catch 0; + } +}; + +test "control: the app's events reach a stream, and the bus listens only while one is open" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + const control = @import("app").control; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + + const Wake = struct { + fn none() void {} + }; + var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; + try server.start(); + var relay: control.routes.Relay = .{}; + try std.testing.expect(!host.listening.load(.acquire)); + + var client: ControlStreamClient = .{ .path = path }; + const thread = try std.Thread.spawn(.{}, ControlStreamClient.run, .{&client}); + // Frames, as the app would run them, until the stream is open and the relay listens. + var frames: usize = 0; + while (frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &relay, &host); + server.lock.lockUncancelable(io); + const open = server.streams.items.len; + server.lock.unlock(io); + if (open == 1) break; + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expect(host.listening.load(.acquire)); + + host.emitZon("t.done", .{ .n = 1, .mode = .fast }); + host.emitZon("u.other", .{ .n = 2 }); + host.deliverEvents(); + io.sleep(.fromMilliseconds(50), .awake) catch {}; + control.routes.stop(&server, &relay, &host); + thread.join(); + // Stopped: nothing listens, so an emit costs the bus its one load again. + try std.testing.expect(!host.listening.load(.acquire)); + const reply = client.reply[0..client.len]; + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\nevent: t.done\ndata: {\"n\":1,\"mode\":\"fast\"}\n\n")); +} + test "a failed open is not a load in flight: settled and live tapes go on" { const gpa = std.testing.allocator; const Openings = @FieldType(fizzy.Editor, "openings"); From f847dc2ad88d9048572818bd3233759c84f4b104 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:10:18 -0500 Subject: [PATCH 04/31] layout: corner button stays dark under a modal; Output tabs on the left (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #519 ## What changes 1. **The corner button no longer lights under a modal.** With About open, hovering a place's corner faded the button in (and its ring), but the click went to the dialog. `Region.cornerButton`'s `near` read only the raw `mouse_pt`. It now also requires that no modal subwindow other than the current one is open. That check is `core.dialogs.modalOverCurrent()`, the modal-only half of `canvasPointerInputSuppressed`, pulled out as a small pub fn; `canvasPointerInputSuppressed` now calls it and behaves as before. The cheap rect test runs first, so the subwindow walk happens only while the pointer is at the corner. - Only modals count, not "pointer over any other subwindow". Otherwise pixi's glass bubbles near a canvas corner, tooltips or a nested place's controls would fade the button out. 2. **The Output panel's tab strip is back on the left.** `OutputPanel.drawTabStrip` set `gravity_x = 1.0`, though the file's doc comment says the strip is on the left, and on the right it sat under the corner button. Dropped, so the strip is the first child of the horizontal box again, with the log to its right. An earlier revision of this PR moved the button into a `FloatingWidget` to get it over focus rings. Review found that caused input regressions: - an unclipped hit rect over the neighbour in a squeezed place; - `near` failing over any other subwindow; - clicks landing on the full 36 pt float while the glass was still growing in; - the zones and ring still drawn under focus rings anyway. It is reverted; the button stays in the place's front-to-back block as on `main`. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release (`core.dialogs.modalOverCurrent` added; `canvasPointerInputSuppressed` unchanged in behaviour). `test-sdk-version` passes, so the fingerprint did not move. - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build` (46/46), `zig build test` (99/99 steps), `zig build test-integration` (53/53 steps), `zig build check-web` (17/17) and `zig build test-sdk-version` (12/12) all pass. A verdict tape (`FIZZY_VERDICT` + `FIZZY_DEMO`, sandbox `--profile`) also passes, with no leaks or warnings. It drags the Sidebar out by its corner button (a float appears), resets, then opens About and presses and drags at the same corner: no float appears. Then it closes About. **Not seen with eyes:** the display was asleep, so screen captures were black. I have no screenshot of the button staying dark under About or of the Output strip on the left. - [ ] Windows: - [ ] Linux: - [ ] Web: compiles (`check-web`), not run ## Follow-ups - **Focus rings and error outlines drawing over the corner button's glass (the rest of #519)** needs a dvui change. Front-to-back blocks must be kept after later draw-last strokes (the `front_len` approach in the issue's source report). That is planned with the dvui pin bump in #526. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/Region.zig | 5 ++++- core/dialogs.zig | 14 +++++++++++--- src/editor/OutputPanel.zig | 1 - 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/layout/Region.zig b/app/layout/Region.zig index d2d764243..dae8c493b 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -679,8 +679,11 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, const picker_here = self.state.picker.is_open and std.mem.eql(u8, self.state.picker.region, opts.name); const filled = regionHasContent(self, opts, keywords); const dragging_this = self.state.view_drag.active() and std.mem.eql(u8, self.state.view_drag.name, opts.name); + // Not under a modal dialog, which takes the press: the button lit and the click did nothing. + // The rect test first, so the subwindow walk runs only with the pointer at the corner. const near = mouse.x >= rs.r.x + rs.r.w - corner_reach * rs.s and mouse.x <= rs.r.x + rs.r.w and - mouse.y >= rs.r.y and mouse.y <= rs.r.y + corner_reach * rs.s; + mouse.y >= rs.r.y and mouse.y <= rs.r.y + corner_reach * rs.s and + !core.dialogs.modalOverCurrent(); const pressing = dvui.dataGet(null, box.data().id, "_chooser_press", bool) orelse false; if (self.state.view_drag.active()) ViewDrag.tick(self); // Not while a view is carried: the button is the way to pick or lift a view, and over a drag diff --git a/core/dialogs.zig b/core/dialogs.zig index 2ce2a2c12..1e2f79e6e 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -716,13 +716,21 @@ pub fn defaultDialogCallAfter(id: dvui.Id, response: dvui.enums.DialogResponse) /// - Any other window over the pointer — a non-modal dialog (e.g. Export), a menu, a float over /// the canvas's own — blocks it only while the pointer is over that window. pub fn canvasPointerInputSuppressed() bool { + if (modalOverCurrent()) return true; const cw = dvui.currentWindow(); + const target = cw.subwindows.windowFor(cw.mouse_pt); + return target != .zero and target != dvui.subwindowCurrentId(); +} + +/// True while a modal subwindow other than the one being drawn into is open: it takes every +/// press, wherever the pointer is. The part of `canvasPointerInputSuppressed` for a control that +/// shows on hover (a place's corner button) and should not light when its click cannot land. +pub fn modalOverCurrent() bool { const here = dvui.subwindowCurrentId(); - for (cw.subwindows.stack.items[1..]) |sub| { + for (dvui.currentWindow().subwindows.stack.items[1..]) |sub| { if (sub.modal and sub.id != here) return true; } - const target = cw.subwindows.windowFor(cw.mouse_pt); - return target != .zero and target != here; + return false; } /// In-dialog preview canvases (Grid Layout): allow pan/zoom while the pointer is over the diff --git a/src/editor/OutputPanel.zig b/src/editor/OutputPanel.zig index b6cc72b49..4a564edcf 100644 --- a/src/editor/OutputPanel.zig +++ b/src/editor/OutputPanel.zig @@ -207,7 +207,6 @@ fn drawTabStrip(scopes: []const []const u8) void { .expand = .vertical, .min_size_content = .{ .w = 120 }, .background = false, - .gravity_x = 1.0, .color_fill = .{ .color = dvui.themeGet().color(.control, .fill) }, }); defer strip.deinit(); From 04b2f4fc454b0e2933c9b04ee0dcac7264ee8ff8 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:10:23 -0500 Subject: [PATCH 05/31] dvui: front-to-back blocks stay above later focus rings; snapshot builds (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #526 Part of #519 ## What changes Repins dvui to `foxnne/dvui-dev` `fdc03d4` (tag `fizzy-sdk-0.2.30-2`). That is the previous pin `f646ec0` plus the changes below, with no upstream rebase. 1. **`dvui.testing.snapshot` builds on Zig 0.16** (`e2c9a85`, #526). It was still on the pre-0.16 file API, so any test calling it failed to compile. This takes upstream's own port (`30adbcaa` through `2fc3f883`) whole: `snapshot(src, frame, opts)` gains `SnapshotOptions` (a custom path), the snapshot directory may be absolute, and messages go through `std.log` instead of stderr (on 0.16, stderr output fails a test run). Upstream's `test snapshot` comes with it, so the function is compiled. This commit comes out empty at the next rebase. 2. **Closed `RenderFrontToBack` blocks are drawn after draw-last strokes** (`f07c334`, reworked in `fdc03d4`; the draw-order part of #519). - **The bug:** a closed block went in at the *start* of the subwindow's `render_cmds_after`, while draw-last strokes (`WidgetData.focusBorder`, a text entry's error ring, `.after` strokes) were appended to the *end*. So the explorer filter's focus ring lay on the pane corner button's glass. - **Main case:** closed blocks now go in a list of their own, `Subwindow.render_cmds_front`. `endRendering` draws it after `render_cmds_after`. Draw-last commands stay O(1) appends, and nothing counts or has to be reset beyond clearing the new list with the others. - **Order among blocks:** each block goes at the start of the list, so the first block closed is still drawn last. Within a block, its own commands come first, then its draw-last commands, then the blocks closed inside it. - **Deferred drawing:** `Window.defer_render_cmds_front` is set by `RenderFrontToBack` (to its own list) and by `Picture` (which draws it last at `stop`). - **Older code:** a queue deferred without a front list, by code that predates it, gets a closing block at the start of its after list, as before. In fizzy: `backend/src/viewports.zig`'s `Picture.subwindow` takes a subwindow's lists to draw them into a viewport. It now takes `render_cmds_front` too and draws it last, in dvui's order. `docs/DEPENDENCIES.md` lists both changes and the tags. (The first cut of this PR, tag `fizzy-sdk-0.2.30`, counted the blocks at the end of `render_cmds_after`. Review replaced that with this design. The tag stays, unpinned, because tags are never moved.) Known minor: dvui's `Debug` widget outline (`Debug.zig`) is a draw-last stroke, so it now draws under a front-to-back block. Parts 1 and 3 of #519 (the corner button under a modal, the Output tabs) are #535's. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0x78f2a3cc371828ef` → `0x2897dc6ec36f713b`), `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag For plugin authors: - `Subwindow.render_cmds_front` and `Window.defer_render_cmds_front` are new. - Code that takes a subwindow's lists to draw elsewhere should take `render_cmds_front` too and draw it last. - Code that installs its own deferred queues should set `defer_render_cmds_front` alongside `defer_render_cmds_after`. - `dvui.testing.snapshot` takes a third argument (`.{}`). ## Verified - [x] macOS: - fizzy, with the URL pin: `scripts/check-fmt.sh`, `zig build`, `zig build check-web` and `zig build test-sdk-version` passed. `zig build test`: 99/99 steps. `zig build test-integration`: 53/53 steps, 561/561 tests. `scripts/check-examples.sh`: all 8 builds ok. - dvui: `zig build test -Dbackend=testing` passed 266/266. That includes `test snapshot` and the new test "closed RenderFrontToBack blocks are drawn after draw-last strokes, before or after they closed". The new test covers sibling blocks, a nested block, strokes before and after, deferred queues installed as `Picture.start` does, the fallback for a queue without a front list, and the list cleared with the frame. It fails when blocks go back into `render_cmds_after`. - The testing backend makes no texture targets, so a real `Picture` is not exercised. - **Not seen on screen:** the display was asleep when I tried a sandbox fizzy with a tape that focuses the explorer filter, and the capture came back black. - [ ] Windows: - [ ] Linux: - [ ] Web: `check-web` only ## Follow-ups - Look at a focused explorer filter under the sidebar's corner button on a desktop. - The draw-order change is worth proposing upstream (the Debug outline could then go after the front list). The snapshot change drops out at the next rebase onto upstream. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- backend/src/viewports.zig | 4 ++++ docs/DEPENDENCIES.md | 19 ++++++++++++++++++- sdk/build.zig.zon | 6 +++--- sdk/src/version.zig | 2 +- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index bdb66d6ca..a80cd3cbc 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -329,12 +329,16 @@ pub const Picture = struct { const cw = dvui.currentWindow(); const cmds = sw.render_cmds; const after = sw.render_cmds_after; + const front = sw.render_cmds_front; if (take) { sw.render_cmds = .empty; sw.render_cmds_after = .empty; + sw.render_cmds_front = .empty; } + // In dvui's order (`Window.endRendering`): front-to-back blocks over the draw-last strokes. cw.renderCommands(cmds.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); cw.renderCommands(after.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); + cw.renderCommands(front.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); } /// Done: drawing goes to the frame's own target again. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 1709f29d8..491768dee 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -88,6 +88,21 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov `dvui.osWindow` takes this path, and `backend/src/os_windows.zig` shows the floating window in an OS window of its own. Upstream: to propose with `run-dvui` (fizzyedit/example-app) as the demonstration. +9. **testing.snapshot: port to std.Io** (`e2c9a85`). Upstream's own port (`30adbcaa` to + `2fc3f883`), taken whole: `dvui.testing.snapshot` was still on the pre-0.16 file API, so a + test calling it failed to compile (#526). It takes `SnapshotOptions` now, and logs through + `std.log`. Upstream: already there; comes out empty at the next rebase. +10. **RenderFrontToBack: closed blocks are drawn after draw-last commands** (`f07c334`, reworked + in `fdc03d4`). A closed block went in at the start of `render_cmds_after` and draw-last strokes + (`focusBorder`, a text entry's error ring) were appended after it, so a focus ring drawn later + lay on top of a block meant to cover it: the pane corner button's glass under the explorer + filter's ring (#519). Closed blocks now go in a list of their own, + `Subwindow.render_cmds_front`, drawn after `render_cmds_after` (and, while drawing is deferred, + `Window.defer_render_cmds_front`, which `RenderFrontToBack` and `Picture` set). Code that takes + a subwindow's lists to draw elsewhere takes this one too, last, as `backend/src/viewports.zig` + does. `Debug`'s widget outline, a draw-last stroke, now draws under such a block. Upstream: + worth proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack + blocks are drawn after draw-last strokes, before or after they closed". Each of 5 and 6 is also a branch of its own on upstream `main` (`window-draw-retained`, `screens-set`), from which the upstream PR is opened. @@ -96,7 +111,9 @@ Tags: `fizzy-sdk-0.2.13` → `712f7f7` (the stack before this rebase, still what builds); `fizzy-sdk-0.2.16` → `4428108` (SDK 0.2.16 to 0.2.18); `fizzy-sdk-0.2.19` → `f8b32db`; `fizzy-sdk-0.2.19-2` → `a90bc70` (0.2.19 was not released yet, and a tag never moves, so the second pin of one version takes a suffix, as the SDL fork's tags do; SDK 0.2.19 to -0.2.22); `fizzy-sdk-0.2.23` → `f646ec0`, what `sdk/build.zig.zon` pins now. +0.2.22); `fizzy-sdk-0.2.23` → `f646ec0` (SDK 0.2.23 to 0.2.29); `fizzy-sdk-0.2.30` → `f07c334` +(never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-2` → +`fdc03d4`, what `sdk/build.zig.zon` pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends (`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index d2d07f997..0b02ea6fb 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -39,9 +39,9 @@ // `fizzy-sdk-` tag in the fork before the pin lands. `fizzy-dev` itself stays a // working branch and can be rebased freely — the tag, never. .dvui = .{ - // fizzy-sdk-0.2.23 - .url = "https://github.com/foxnne/dvui-dev/archive/f646ec0b17899a2c2f222ecce834c82e235e0486.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmTQyBAHtzJRJ2BzYtusSfj5mYzF4owYGcDHfyNY_", + // fizzy-sdk-0.2.30-2 + .url = "https://github.com/foxnne/dvui-dev/archive/fdc03d4fc77f8a79f3fc8bbb14534769028e0069.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmdBcBAHqXrD_1KInoFLi4qYmeOwR9QOxzbL4kIMR", //.path = "../../dvui-dev", }, .zf = .{ diff --git a/sdk/src/version.zig b/sdk/src/version.zig index ad46c1417..9f4b39242 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x78f2a3cc371828ef; +pub const recorded_sdk_shape_fingerprint: u64 = 0x2897dc6ec36f713b; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From 75ab57821939d64f738122bbbf7382bf8d2645e4 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:39:38 -0500 Subject: [PATCH 06/31] app: document ids are unique when two load at once (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #521 ## What changes `App.newFileID` was a plain `+= 1` on a `u64`. Plugins call it through `Host.allocDocId`, and the text, image and archive plugins do that inside `loadDocument`, which runs on the load worker thread (`Plugin.zig`'s contract, `FileLoadJob.workerMain`). Two loads at once, or a load racing a UI-thread `createDocument`, could get the same id. The counter is now a `std.atomic.Value(usize)` and `newFileID` does `fetchAdd(1, .monotonic) + 1`. `usize` because wasm32 has no 64-bit atomics; 2^32 documents can't fit in a wasm32 page anyway. No other code touches the field. Reported by Graphl. ## SDK impact - [x] None ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`. The race itself isn't reproduced; the change is the counter's type. - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/App.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/App.zig b/app/App.zig index cc4efce3d..487a9b949 100644 --- a/app/App.zig +++ b/app/App.zig @@ -209,7 +209,10 @@ open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, /// and only then the host's recorder. tracks: ?Tracks = null, -file_id_counter: u64 = 0, +/// Atomic: plugins allocate ids through `Host.allocDocId` from `loadDocument`, on the load +/// worker thread, while the UI thread may be creating a document of its own. `usize`, since +/// wasm32 has no 64-bit atomics (and no room for 2^32 documents). +file_id_counter: std.atomic.Value(usize) = .init(0), /// Filled from the async SDL save dialog callback, then applied inside `tick` (when `currentWindow` is valid). pending_save_as_path: ?[]u8 = null, @@ -707,8 +710,7 @@ pub fn docById(app: *App, id: u64) ?sdk.DocHandle { } pub fn newFileID(app: *App) u64 { - app.file_id_counter += 1; - return app.file_id_counter; + return app.file_id_counter.fetchAdd(1, .monotonic) + 1; } /// Heap-owned path like `untitled-1`, unique among open-document basenames. From bf1b1735f52fdf9afca03770b087dba332028355 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:39:42 -0500 Subject: [PATCH 07/31] menus: plugin menus are clickable in the title bar, and sit before Help (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #517 ## What changes A plugin's top-level menu in the in-app menu bar (Windows, Linux, web, and macOS's debug bar): - **is clickable on Windows and Linux.** Only fizzy's own titles called `pushTitleBarInteractiveRect` (from `menuItem`); a plugin's `MenuContribution.draw` can't, so a press on its title was a caption hit and dragged the window. `Menu.draw` now pushes the menu bar's own rect once, and `menuItem` no longer pushes per title. The bar widget (`widgets.menu`, no `expand`) sizes to its titles, so its rect is exactly theirs and the strip beyond the last title still drags the window. No wrapper widgets, so every title keeps its widget id. - The push is gated on `titlebar_hit_tested`, as `registerFloatingTitleBarRects` is for the dropdowns, so the debug in-app bar on macOS (View > "Show DVUI Menu (macOS)") agrees with its dropdowns. - The strip test (strip height, `r.y < strip_h`, push) is one helper, `Editor.pushTitleStripRect`, shared with the sidebar rail's icons, which keep their `custom_titlebar` gate. - **sits between View and Help.** The bar walked `host.menus` in registration order, and fizzy registers File/Edit/View/Help after the bundled plugins, so a plugin menu came before File (or after Help, for a plugin loaded later). The order is now `src/editor/menu_order.zig`, a std-only iterator: fizzy's menus but Help, then plugin menus in registration order, then Help, skipping hidden ones. `Menu.draw` walks it with one draw-and-catch. - Fizzy's own are told apart by `draw == drawModelMenu`, not `owner == null`, which a plugin may leave unset. - Help is the one whose `ctx` is the new `menu_model.help`, not an id string. The native macOS builder now uses `menu_model.help` too. - A unit test (`fizzy-menu-order-tests`) covers fizzy's menus first, plugins in registration order (one loaded after fizzy's), Help last, a hidden menu skipped, and a plugin with no `owner` still counted as a plugin. The native macOS bar already put plugin menus before Help (`platform.menu.setExtras`), so its order is unchanged. The stale "Order = bar order" comment in `Editor.zig` is corrected. No SDK change. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified `scripts/check-fmt.sh`, `zig build`, `zig build test` (with the new ordering test), `zig build check-web` and `zig build check-tests-reached` on macOS. The bar's push branch now compiles on macOS too (`titlebar_hit_tested`). No bundled plugin registers a top-level menu, so there was nothing to click locally. - [ ] macOS: builds; the in-app bar isn't drawn there unless the debug toggle is on (not checked by eye) - [ ] Windows: not run (title-bar behaviour unverified) - [ ] Linux: not run (title-bar behaviour unverified) - [ ] Web: compiles (`check-web`); order not checked by eye ## Follow-ups A Windows or Linux check with a plugin that registers a top-level menu (pixi's, or `fizzyedit/example-plugin` with one added): click its title, see it between View and Help, and check the empty strip right of Help still drags the window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- build/app.zig | 3 ++ src/backend/backend_native.zig | 2 +- src/editor/Editor.zig | 13 ++++-- src/editor/Menu.zig | 30 ++++++------ src/editor/Sidebar.zig | 6 +-- src/editor/menu_model.zig | 3 ++ src/editor/menu_order.zig | 85 ++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 src/editor/menu_order.zig diff --git a/build/app.zig b/build/app.zig index ca7638ce0..7dc592116 100644 --- a/build/app.zig +++ b/build/app.zig @@ -683,6 +683,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. .{ "fizzy-glass-look-tests", "core/gfx/glass_look.zig" }, + // The in-app menu bar's order: fizzy's menus, then plugins', then Help. std-only (see + // menu_order.zig); `Menu.draw` walks it. + .{ "fizzy-menu-order-tests", "src/editor/menu_order.zig" }, // Demo automation's std-only core (`sdk/tape/`): the tape format and its codecs, the // sequencer that replays it deterministically, the script builder and key spelling. The // dvui half (`sdk/replay/`) compiles on its own below, and is covered by diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index e95fabc49..99a07422e 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -245,7 +245,7 @@ const native_menus: []const platform.menu.Menu = blk: { .aliases = sub.aliases, .title = sub.title, .entries = &done, - .help = std.mem.eql(u8, sub.id, "fizzy.menu.help"), + .help = sub == menu_model.help, }; } const done = menus; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ae958c830..58dde9117 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2207,9 +2207,8 @@ pub fn postInit(editor: *Editor) !void { .draw = drawSettingsPane, }); - // Menu bar contributions (non-macOS in-app bar). The File/Edit draw bodies still live - // in fizzy's `Menu.zig`; a later step could move them into the workbench / pixel-art - // plugins so those self-register. Order = bar order. + // Menu bar contributions (non-macOS in-app bar). Registered after the plugins', so + // registration order isn't bar order: `Menu.draw` puts plugin menus between these and Help. // One registration per `menu_model.menu_bar` entry, all pointing at the same generic // renderer with the model node as `ctx`. There is no longer a per-menu draw function that // could disagree with the macOS builder walking the same tree. @@ -4642,6 +4641,14 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA /// only those over fizzy's strip: AppKit's region is its own height. Each where it stands on the /// stack: those drawn later this frame (dialogs land at the frame's end) are still last frame's, /// which is the frame the OS's hit test answers for; one closed since goes with the stack. +/// Mark `r` as taking presses rather than dragging the window, if it reaches into the title-bar +/// strip at the top of the window. Something lower needs no rect: the OS asks only about the +/// strip. +pub fn pushTitleStripRect(r: dvui.Rect.Physical) void { + const strip_h = (Constants.titlebar_top_buffer + Constants.titlebar_height) * dvui.windowNaturalScale(); + if (r.y < strip_h) fizzy.backend.pushTitleBarInteractiveRect(r); +} + fn registerFloatingTitleBarRects() void { if (!fizzy.backend.titlebar_hit_tested) return; // The first is the window itself. diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index e0ee3458a..1a7722f3d 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -6,6 +6,7 @@ const Editor = fizzy.Editor; const settings = fizzy.settings; const builtin = @import("builtin"); const model = @import("menu_model.zig"); +const menu_order = @import("menu_order.zig"); const widgets = fizzy.core.widgets; pub var mouse_distance: f32 = std.math.floatMax(f32); @@ -45,15 +46,26 @@ pub fn draw(editor: *Editor) !dvui.App.Result { dvui.themeSet(theme); } - // Fizzy owns only the menu bar container + theme; the top-level menus are - // plugin (and fizzy built-in) contributions, drawn in registration order. - for (editor.app.host.menus.items) |*menu| { - if (menu.hidden) continue; + // Fizzy owns only the menu bar container + theme; the top-level menus are contributions, + // drawn in bar order (`menu_order`), which isn't registration order. + var it: menu_order.Iterator(fizzy.sdk.Host.MenuContribution) = .{ + .menus = editor.app.host.menus.items, + .own_draw = &drawModelMenu, + .help_ctx = @ptrCast(@constCast(model.help)), + }; + while (it.next()) |menu| { menu.draw(menu.ctx) catch |err| { dvui.log.err("Menu contribution failed: {s}", .{fizzy.sdk.Plugin.errorNameOf(menu.owner, err)}); }; } + // Where the OS asks fizzy where a press in the title bar goes (Windows, Linux, and macOS for + // the debug bar), the bar's titles must take it instead of it dragging the window. The menu + // sizes to its titles, so its rect is exactly theirs, fizzy's and a plugin's alike: a + // plugin's title draws itself and can't push one. Its dropdowns are floating windows, which + // the editor registers wholesale (`Editor.registerFloatingTitleBarRects`). + if (fizzy.backend.titlebar_hit_tested) Editor.pushTitleStripRect(m.data().rectScale().r); + return .ok; } @@ -243,16 +255,6 @@ pub fn menuItem(src: std.builtin.SourceLocation, label_str: []const u8, init_opt dvui.labelNoFmt(@src(), label_str, .{}, label_opts); - // Register top-level menu items as interactive rects where fizzy draws its own title bar - // (Windows, Linux) so clicks land on the item instead of dragging the window. We only push - // items that overlap the title bar strip — submenus are floating windows, which the editor - // registers wholesale (`Editor.registerFloatingTitleBarRects`). - if (fizzy.backend.custom_titlebar) { - const r = mi.data().rectScale().r; - const strip_h = (Constants.titlebar_top_buffer + Constants.titlebar_height) * dvui.windowNaturalScale(); - if (r.y < strip_h) fizzy.backend.pushTitleBarInteractiveRect(r); - } - mi.deinit(); return ret; diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index a115b22cf..fca4c18c3 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -205,11 +205,7 @@ fn drawIcon(editor: *Editor, c: *Chooser, view: *Layout.Surface, index: usize) f // Register the icon as interactive in the title bar so clicks reach DVUI even when it // overlaps the top drag strip where fizzy draws its own title bar (Windows, Linux). Only the // topmost icon(s) actually sit inside the strip — anything below is registered harmlessly. - if (fizzy.backend.custom_titlebar) { - const r = it.data().rectScale().r; - const strip_h = (Constants.titlebar_top_buffer + Constants.titlebar_height) * dvui.windowNaturalScale(); - if (r.y < strip_h) fizzy.backend.pushTitleBarInteractiveRect(r); - } + if (fizzy.backend.custom_titlebar) Editor.pushTitleStripRect(it.data().rectScale().r); Chooser.icon(view, it.selected, it.hovered(), rail_icon, index); diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index c5bd8e01e..0f9bec040 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -265,6 +265,9 @@ pub const menu_bar = [_]Submenu{ .{ .id = "fizzy.menu.help", .title = "Help", .items = &help_items }, }; +/// Help, which every bar keeps last, after the menus plugins add. +pub const help = &menu_bar[menu_bar.len - 1]; + /// Whether a plugin's `parent_menu_id` refers to `sub`, under its current id or a legacy one. pub fn menuMatches(sub: Submenu, parent_menu_id: []const u8) bool { if (std.mem.eql(u8, sub.id, parent_menu_id)) return true; diff --git a/src/editor/menu_order.zig b/src/editor/menu_order.zig new file mode 100644 index 000000000..cd9dbc97e --- /dev/null +++ b/src/editor/menu_order.zig @@ -0,0 +1,85 @@ +//! The in-app menu bar's order, which isn't registration order. +//! +//! Fizzy registers File/Edit/View/Help after the bundled plugins, and a plugin loaded later +//! lands after Help. The bar is ordered as the native macOS bar is (`platform.menu.setExtras` +//! inserts before Help): fizzy's menus but Help, then every plugin's in registration order, then +//! Help. std-only, so it tests without a window; `Menu.draw` walks it. +//! +//! Fizzy's own menus are the ones drawn by its model renderer, not the ones with no `owner`: a +//! plugin may leave `owner` unset. + +const std = @import("std"); + +/// Walks `menus` (a slice of `MenuContribution`, or anything with its `hidden`, `draw` and `ctx` +/// fields) in bar order, skipping hidden ones. `own_draw` is fizzy's model renderer, and +/// `help_ctx` the `ctx` of fizzy's Help. +pub fn Iterator(comptime T: type) type { + return struct { + menus: []T, + own_draw: @FieldType(T, "draw"), + help_ctx: ?*anyopaque, + phase: enum { fizzy, plugins, help } = .fizzy, + i: usize = 0, + + pub fn next(self: *@This()) ?*T { + while (true) { + if (self.i == self.menus.len) { + self.i = 0; + self.phase = switch (self.phase) { + .fizzy => .plugins, + .plugins => .help, + .help => return null, + }; + continue; + } + const menu = &self.menus[self.i]; + self.i += 1; + if (menu.hidden) continue; + const own = menu.draw == self.own_draw; + const help = own and menu.ctx == self.help_ctx; + const in_phase = switch (self.phase) { + .fizzy => own and !help, + .plugins => !own, + .help => help, + }; + if (in_phase) return menu; + } + } + }; +} + +test "fizzy's menus, then plugins' in registration order, then Help" { + const Draw = *const fn (ctx: ?*anyopaque) anyerror!void; + const fns = struct { + fn model(_: ?*anyopaque) anyerror!void {} + fn plugin(_: ?*anyopaque) anyerror!void {} + }; + const Owner = struct { id: u8 }; + var owner: Owner = .{ .id = 0 }; + const Menu = struct { + id: []const u8, + owner: ?*Owner = null, + hidden: bool = false, + ctx: ?*anyopaque = null, + draw: Draw, + }; + var file: u8 = 0; + var view: u8 = 0; + var help: u8 = 0; + var menus = [_]Menu{ + .{ .id = "a", .owner = &owner, .draw = &fns.plugin }, + // No owner, yet not fizzy's: still a plugin's. + .{ .id = "b", .draw = &fns.plugin }, + .{ .id = "hidden", .owner = &owner, .hidden = true, .draw = &fns.plugin }, + .{ .id = "file", .ctx = &file, .draw = &fns.model }, + .{ .id = "view", .ctx = &view, .draw = &fns.model }, + .{ .id = "help", .ctx = &help, .draw = &fns.model }, + // Loaded after fizzy registered its own. + .{ .id = "c", .owner = &owner, .draw = &fns.plugin }, + }; + + var it: Iterator(Menu) = .{ .menus = &menus, .own_draw = &fns.model, .help_ctx = &help }; + const want = [_][]const u8{ "file", "view", "a", "b", "c", "help" }; + for (want) |id| try std.testing.expectEqualStrings(id, it.next().?.id); + try std.testing.expect(it.next() == null); +} From 573afeddb947e754fcaa1ef912396b75e027ff19 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:39:46 -0500 Subject: [PATCH 08/31] app: a folder walker that watches a large tree within limits (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #516. This is the first of two stacked PRs; #534 wires it in and fixes the issue. ## What changes `app/watch/folder_events.zig` gains the half of the folder watcher that decides which folders a folder-by-folder backend (inotify) watches. It is std-only and unit-tested on a temp tree. Nothing calls it yet except the dot-folder event filter, which `FolderWatcher` now reaches as `underSegment(folder, path, dotName)`; the rest of the wiring is #534. **Why.** On inotify, nightwatch's `watch` adds one watch per directory, recursing everywhere: no skip list, no cap, and it keeps walking after the kernel runs out (#516). `Watches` replaces that walk. **What is watched** - **Skipped:** hidden folders, `node_modules` and `zig-pkg`, at any depth below the root (`underSegment(root, path, skipWatchDir)`). - "Below" means the root followed by a separator: `/pp/node_modules` is not under `/p`. `inside()` holds that rule for every check. - **Still watched:** ordinary names like `target` and `zig-out`. `IgnoreRules` drops their events. - **Symlinks** are not followed. - **Unknown entry types:** an entry whose type the listing doesn't give (DT_UNKNOWN) is stat'ed without following links. **Counting live watches** - Live watches count against a cap of 16384. - The watched paths are kept sorted. A folder's subtree is found by binary search in O(log n + k), without building the prefix, so no path is too long for the search. - A walk collects what it adds and merges it in one pass at the end. A rename rewrites its run, which stays in order, and merges it back. **Giving watches back** - A folder that is deleted, moved out of the root, or moved into a skipped name gives back its watches and those below it. A watch that has moved is removed under its new name. - A folder renamed over an empty watched one leaves one key, not two. - A folder renamed within view keeps its watches. - A folder renamed out of a skipped name into view is walked, and its contents are announced. - A watched folder is never walked twice. **Why a watch was refused** is asked, not guessed: `ctx.why(path)` returns a `Refusal`. | `Refusal` | Meaning | What happens | |---|---|---| | `.pool` | The OS is out of watches. | The walk stops, said once per stretch of being behind. | | `.own` | A problem with that folder, named with the errno. | Lost (below). | | `.gone` | A race: the folder went first. | Nothing is said. | | `.again` | It might work now. | Tried once more. A second refusal is judged the same way, and if it is `.again` again, the folder is lost. | **Folders that can't be watched** - Lost folders are a set, not a counter. Each is said once and never re-probed by later walks. - A folder leaves the set when it goes, or moves (it is tried again under its new name). `whole()` can then become true again. - These count as lost: - permission, loop or bad-name errors when opening a folder - a path too long to walk - These go on the frontier for a retry instead: - running out of fds or kernel memory - an I/O error **Out of memory: one rule.** Any allocation failure anywhere sets a sticky `degraded`, said once, and `whole()` stays false for the session. A watch whose key can't be kept is removed, so no live watch is ever untracked. A test fails each of the first 200 allocations of a session in turn (walk, a new folder, renames, a folder going) and checks, after every one, that the backend's live watches equal the tracked set. **The frontier** - A walk that stops remembers where: the refused folder, plus each partly walked folder above it. - Each entry records what it is still owed: its own entries and everything below, or only what its subfolders hold. - A folder's entries are announced before anything below it is walked. A resumed folder therefore never repeats them, and a subfolder that was finished is not walked again. A folder that a walk finishes is taken off the frontier, and off the list `catchUp` is working through. - Entries follow renames and go with deletions; one helper, `reframe`, does both. - `catchUp` goes on from the frontier, never from the root. `retry()` lets a timer try again. `waiting()` says whether time can help; it can't for the cap. **Announcing.** What a new folder held before its watch landed is announced through `ctx.created`. The exceptions are a folder the backend already reported (a move-in), and one reached silently by the root's first walk, which is announced once its own event is handled. The first walk itself announces nothing. **`whole()`** is true when every folder is watched: nothing waiting, nothing lost, not degraded. **`Doorbell`** wakes a thread from one that must not block, allocate under a lock or touch an `Io` (nightwatch's reader). It is a pipe with an atomic flag in front, so a burst of rings writes one byte. `ring` and `answer` both swap the flag with acq_rel ordering; the comment on `Doorbell` explains why that is enough. It is POSIX-only (`has_doorbell`). A poll failure is returned, not spun on. ## SDK impact - [x] None ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test` (99/99 steps), `zig build check-tests-reached`, `zig build check-web`. `zig build test` reports 609/610 tests passed, 1 skipped. `folder_events.zig` has 34 tests. Among them: - a walk that stops between two subfolders and is resumed announces each entry exactly once and tries only the folders not yet watched - lost folders are said once, not re-probed, forgotten when they go, and tried again under a new name - `.again` then the pool blocks, while `.again` twice loses the folder - a folder moved out of the root goes - the separator-boundary rule - the allocation-failure sweep - the earlier cases: refusals, retry, cap, announcing, renames, cancel, and the doorbell - [x] Windows: `zig build check -Dtarget=x86_64-windows-gnu` compiles the unit tests (the `Doorbell` tests skip there), and `zig build -Dtarget=x86_64-windows-gnu` cross-compiles the app. Neither was run. - [x] Linux: `zig build -Dtarget=x86_64-linux-gnu` cross-compiles. The tests are std-only and run in CI on Linux. - [x] Web: `zig build check-web`. ## Follow-ups - An I/O error partway through listing a folder that owes announcements re-announces that folder's entries on retry, rather than risk missing any. - DT_UNKNOWN and over-long paths have no test: neither is easy to reproduce on a temp tree. - The wiring, the errno probe, the retry timer, and the `setProjectFolder`/Recents changes are in #534. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/watch/FolderWatcher.zig | 3 +- app/watch/folder_events.zig | 1372 ++++++++++++++++++++++++++++++++++- 2 files changed, 1346 insertions(+), 29 deletions(-) diff --git a/app/watch/FolderWatcher.zig b/app/watch/FolderWatcher.zig index 3ff5f0301..00e7c9e7f 100644 --- a/app/watch/FolderWatcher.zig +++ b/app/watch/FolderWatcher.zig @@ -35,7 +35,6 @@ const Allocator = std.mem.Allocator; const PathEvent = sdk.events.PathEvent; const folder_events = @import("folder_events.zig"); -const underDotSegment = folder_events.underDotSegment; /// One side of the double buffer. The watcher thread fills `shared`; `tick` swaps it with /// `staging` under the mutex and then reads at leisure, so no plugin call ever runs with the @@ -144,7 +143,7 @@ const Impl = if (have_impl) struct { // Cheap dot-directory reject before taking the lock. The authoritative `IgnoreRules` // pass happens on the main thread in `tick`; this one exists only so a `git checkout` // or a build churning `.zig-cache` can't flood the ring before we get there. - if (underDotSegment(folder, path)) return; + if (folder_events.underSegment(folder, path, folder_events.dotName)) return; self.mutex.lock(); self.shared.push(path, old_path, kind, object); diff --git a/app/watch/folder_events.zig b/app/watch/folder_events.zig index 2dfec2d69..736fe2334 100644 --- a/app/watch/folder_events.zig +++ b/app/watch/folder_events.zig @@ -1,4 +1,5 @@ -//! Buffering and filtering for `FolderWatcher` — the half that runs on nightwatch's thread. +//! Buffering and filtering for `FolderWatcher` — the half that runs on nightwatch's thread — +//! and the walk that decides which folders it watches. //! //! Split out and std-only on purpose. `FolderWatcher.zig` reaches `fizzy.zig` and `dvui`, so it //! can only be exercised through a live editor; this is where the bugs would actually live (an @@ -9,29 +10,674 @@ //! `sdk.events.PathEvent`, and the SDK imports dvui, which would drag this file back out of //! std-only territory and into the module whose tests never run. const std = @import("std"); +const builtin = @import("builtin"); -/// True when any path segment *below* `root` starts with a dot — `.git`, `.zig-cache`, `.env`. +/// True when a path segment *below* `root`, on the way to `path` or `path`'s own name, is one +/// `named` picks out: `dotName` for `.git`, `.zig-cache`, `.env`; `skipWatchDir` for the folders +/// a watch leaves alone. /// /// Pure string work: no allocation, no filesystem, no host call, so it is safe to run on the -/// watcher's own thread. It is not the authoritative ignore check — fizzy's `IgnoreRules` is, -/// and that runs later on the UI thread. This exists so a `git checkout` or a build churning a -/// cache directory can't fill the ring before anyone gets to apply the real rules. +/// watcher's own thread. With `dotName` it is not the authoritative ignore check — fizzy's +/// `IgnoreRules` is, and that runs later on the UI thread. It exists so a `git checkout` or a +/// build churning a cache directory can't fill the ring before anyone gets to apply the real +/// rules. /// /// Only what is below `root` counts: a project folder may itself live under `~/.config`, which -/// is no reason to ignore every file in it. -pub fn underDotSegment(root: []const u8, path: []const u8) bool { - if (!std.mem.startsWith(u8, path, root)) return false; - var rest = path[root.len..]; - while (rest.len > 0) { - while (rest.len > 0 and (rest[0] == '/' or rest[0] == '\\')) rest = rest[1..]; - if (rest.len == 0) return false; - if (rest[0] == '.') return true; - const next = std.mem.indexOfAny(u8, rest, "/\\") orelse return false; - rest = rest[next..]; - } +/// is no reason to ignore every file in it. A path not under `root` is not ours: false. +pub fn underSegment(root: []const u8, path: []const u8, comptime named: fn ([]const u8) bool) bool { + if (!inside(path, root)) return false; + var it = std.mem.tokenizeAny(u8, path[root.len..], "/\\"); + while (it.next()) |seg| if (named(seg)) return true; return false; } +/// True when `path` is `dir` or below it: `dir` and then a separator, not merely the same +/// first bytes (`/p` is not under `/pp`). A `dir` that ends in a separator (`/`) is its own. +pub fn inside(path: []const u8, dir: []const u8) bool { + if (!std.mem.startsWith(u8, path, dir)) return false; + if (path.len == dir.len or dir.len == 0) return true; + return isSep(path[dir.len]) or isSep(dir[dir.len - 1]); +} + +fn isSep(c: u8) bool { + return c == '/' or c == '\\'; +} + +/// A hidden entry's name: `.git`, `.env`. A dot inside a name is an extension. +pub fn dotName(name: []const u8) bool { + return name.len > 0 and name[0] == '.'; +} + +/// The most folders one open folder may watch. inotify spends a watch per directory out of a +/// per-user pool (`fs.inotify.max_user_watches`) that every other program shares; a home folder +/// would take all of it. Past this many the tree is still browsable, only no longer live. +pub const max_dir_watches: u32 = 16384; + +/// Folders a watch never descends into, by name: hidden ones (`.git`, `.zig-cache`, `.venv`, +/// `.bun`) and the dependency trees a tool writes, `node_modules` and `zig-pkg`. They hold most +/// of a project's directories and none of what a person edits. Anything else a project chooses +/// to ignore (`target`, `zig-out`, …) is still watched, and `IgnoreRules` drops its events. +pub fn skipWatchDir(name: []const u8) bool { + if (name.len == 0 or name[0] == '.') return true; + return std.mem.eql(u8, name, "node_modules") or std.mem.eql(u8, name, "zig-pkg"); +} + +/// Why watching more folders stopped for now. +pub const Blocked = enum { + /// `max_dir_watches` are in use. + cap, + /// The OS has no watches left (inotify's ENOSPC: `fs.inotify.max_user_watches`). + refused, +}; + +/// What `Watches` has to say, through `ctx.say`. Each is said once, not once per folder. +pub const Said = union(enum) { + /// A limit was reached with `watched` folders watched; folders left out are watched as + /// watches are freed. + blocked: struct { why: Blocked, watched: usize }, + /// A folder can't be watched for a reason of its own (a permission, a mount that refuses + /// inotify, a name too long), so it is left out. + skipped: struct { path: []const u8, reason: []const u8 }, + /// An allocation failed: what is watched can no longer be vouched for, for this folder's + /// session. Said once. + degraded, +}; + +/// Why the backend refused to watch a folder, as `ctx.why(path)` finds out: the backend itself +/// only says it failed. +pub const Refusal = union(enum) { + /// The OS is out of watches: no folder would fare better until some are freed. + pool, + /// The folder went between the open and the watch. + gone, + /// It can be watched now after all: try once more. + again, + /// That folder's own problem, named. + own: []const u8, +}; + +/// What a folder is still owed in announcements (`ctx.created`) when a walk gets to it. +const Owed = enum(u2) { + none, + /// What its subfolders hold, not its own entries: they were said before the walk stopped. + below, + /// Its own entries, and everything below them. + all, +}; + +/// How a walk treats the folders it meets. +const Pass = struct { + /// The folder the walk starts at may be watched already, with children not yet seen. + resume_top: bool = false, + owed: Owed = .none, + /// Go through folders already watched too, to announce what is in them. + through_known: bool = false, +}; + +/// The folders watched under one root, for a backend that watches folder by folder (inotify, +/// kqueue). One thread owns it: `FolderWatcher`'s walker, which the backend's thread hands its +/// folder events to. `ctx` is the backend and its listener: `add(path) !void`, `remove(path)`, +/// `why(path) Refusal`, `say(Said)` and `created(path, is_dir)`. +/// +/// It counts the watches live, so folders coming and going never use up the cap, and a folder +/// already watched is not walked again. A walk that reaches a limit stops and remembers where +/// (`frontier`); `catchUp` goes on from there, never from the root, once watches are freed or +/// (`retry`) after a while. +/// +/// One rule for running out of memory, wherever it happens: the watch that can't be kept +/// track of is removed, `degraded` is set for good, and that is said once. +pub const Watches = struct { + gpa: std.mem.Allocator, + /// Borrowed; outlives this. + root: []const u8, + limit: u32 = max_dir_watches, + /// Set when the root is being closed: a walk in flight returns at the next folder. + cancel: *const std.atomic.Value(bool), + /// Every folder watched, sorted, so a folder and what is below it are found by binary + /// search. Owned. + paths: std.ArrayListUnmanaged([]u8) = .empty, + /// Watched by the walk in progress, merged into `paths` in one pass when it ends: a walk + /// adds folders by the thousand, and inserting each into a sorted list is quadratic. Owned. + fresh: std.ArrayListUnmanaged([]u8) = .empty, + /// The limit reached, until a watch is freed or added again. + blocked: ?Blocked = null, + /// The limit last said, until caught up: said once per time behind, not per retry. + said: ?Blocked = null, + /// Where walks stopped: folders not yet watched, and watched folders whose children were + /// not all seen, each with what it is still owed. Keys owned. + frontier: std.StringArrayHashMapUnmanaged(Owed) = .empty, + /// The frontier `catchUp` is working through, so a walk that finishes a folder still on + /// it can take it off rather than have it walked and announced again. Keys owned. + todo: std.StringArrayHashMapUnmanaged(Owed) = .empty, + /// Folders left out for reasons of their own, each said once and not tried again, until + /// it goes or moves. Keys owned. + lost: std.StringHashMapUnmanaged(void) = .empty, + /// An allocation failed; sticky. + degraded: bool = false, + /// The folder events that arrived during the root's first walk have been handled. Until + /// then a folder that appeared may already have been watched by that walk, silently. + settled: bool = false, + + const Watched = enum { added, skipped, blocked }; + + pub fn deinit(self: *Watches) void { + for (self.paths.items) |p| self.gpa.free(p); + self.paths.deinit(self.gpa); + for (self.fresh.items) |p| self.gpa.free(p); + self.fresh.deinit(self.gpa); + for (self.frontier.keys()) |p| self.gpa.free(p); + self.frontier.deinit(self.gpa); + for (self.todo.keys()) |p| self.gpa.free(p); + self.todo.deinit(self.gpa); + var it = self.lost.keyIterator(); + while (it.next()) |k| self.gpa.free(k.*); + self.lost.deinit(self.gpa); + } + + /// Every folder under the root is watched: nothing left out, nothing waiting for room. + /// Until then a plugin can't rely on change events alone. + pub fn whole(self: *const Watches) bool { + return self.blocked == null and self.frontier.count() == 0 and self.lost.count() == 0 and !self.degraded; + } + + /// Something is left to try again later, and time may help: the OS freeing watches, an + /// fd. Not the cap: only a folder of this tree going frees room under it. + pub fn waiting(self: *const Watches) bool { + return self.frontier.count() > 0 and self.blocked != .cap; + } + + /// Let the next `catchUp` try again, after a while. + pub fn retry(self: *Watches) void { + if (self.blocked != .cap) self.blocked = null; + } + + fn lowerBound(self: *const Watches, key: []const u8) usize { + return std.sort.lowerBound([]u8, self.paths.items, key, struct { + fn order(k: []const u8, item: []u8) std.math.Order { + return std.mem.order(u8, k, item); + } + }.order); + } + + pub fn contains(self: *const Watches, path: []const u8) bool { + const i = self.lowerBound(path); + return i < self.paths.items.len and std.mem.eql(u8, self.paths.items[i], path); + } + + /// Watch the root and every folder under it not skipped, when it is opened. + pub fn walk(self: *Watches, io: std.Io, ctx: anytype) void { + self.walkFrom(io, self.root, .{}, ctx); + } + + /// `dir` was created, and is watched only now: whatever was made in it before then had + /// no event, so it is announced — unless `announced`, when the backend already did (a + /// folder moved in from outside the tree, which inotify reports with all it holds). + pub fn appeared(self: *Watches, io: std.Io, dir: []const u8, announced: bool, ctx: anytype) void { + if (!inside(dir, self.root) or underSegment(self.root, dir, skipWatchDir)) return; + if (self.contains(dir)) { + // Watched by the root's first walk, which says nothing of what it finds. + if (!self.settled and !announced) self.walkFrom(io, dir, .{ .resume_top = true, .owed = .all, .through_known = true }, ctx); + return; + } + self.walkFrom(io, dir, .{ .owed = if (announced) .none else .all }, ctx); + } + + /// Go on from where walks stopped. Call after a batch of folder events, not per event. + pub fn catchUp(self: *Watches, io: std.Io, ctx: anytype) void { + // Each entry once per call: one refused again goes back on the frontier, not round + // this loop for ever. + std.debug.assert(self.todo.count() == 0); + std.mem.swap(std.StringArrayHashMapUnmanaged(Owed), &self.todo, &self.frontier); + defer { + for (self.todo.keys(), self.todo.values()) |p, owed| { + self.leftOut(p, owed, ctx); + self.gpa.free(p); + } + self.todo.clearRetainingCapacity(); + if (self.frontier.count() == 0) self.said = null; + } + while (self.blocked == null and !self.cancel.load(.acquire)) { + const kv = self.todo.pop() orelse break; + defer self.gpa.free(kv.key); + if (!underSegment(self.root, kv.key, skipWatchDir)) + self.walkFrom(io, kv.key, .{ .resume_top = true, .owed = kv.value }, ctx); + } + } + + fn walkFrom(self: *Watches, io: std.Io, dir: []const u8, pass: Pass, ctx: anytype) void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (dir.len > buf.len) { + _ = self.lose(dir, "NAMETOOLONG", ctx); + return; + } + @memcpy(buf[0..dir.len], dir); + _ = self.walkDir(io, &buf, dir.len, pass, true, ctx); + std.mem.sortUnstable([]u8, self.fresh.items, {}, struct { + fn lt(_: void, a: []u8, b: []u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + self.merge(self.fresh.items) catch { + for (self.fresh.items) |p| { + ctx.remove(p); + self.gpa.free(p); + } + self.degrade(ctx); + }; + self.fresh.clearRetainingCapacity(); + } + + /// Merge the sorted `run` (owned) into `paths`, from the back, in place. + fn merge(self: *Watches, run: []const []u8) !void { + try self.paths.ensureUnusedCapacity(self.gpa, run.len); + var i = self.paths.items.len; + self.paths.items.len += run.len; + const items = self.paths.items; + var j = run.len; + var k = items.len; + while (j > 0) { + k -= 1; + if (i > 0 and std.mem.order(u8, items[i - 1], run[j - 1]) == .gt) { + items[k] = items[i - 1]; + i -= 1; + } else { + items[k] = run[j - 1]; + j -= 1; + } + } + } + + /// `name` appended to the folder in `buf[0..len]`, or null when it doesn't fit. + fn join(buf: *[std.fs.max_path_bytes]u8, len: usize, name: []const u8) ?[]const u8 { + if (len + 1 + name.len > buf.len) return null; + buf[len] = std.fs.path.sep; + @memcpy(buf[len + 1 ..][0..name.len], name); + return buf[0 .. len + 1 + name.len]; + } + + /// An entry's kind, from the directory listing or, where that doesn't say (DT_UNKNOWN on + /// some filesystems), from a stat that doesn't follow a symlink. + fn kindOf(io: std.Io, d: std.Io.Dir, entry: std.Io.Dir.Entry) std.Io.File.Kind { + if (entry.kind != .unknown) return entry.kind; + const st = d.statFile(io, entry.name, .{ .follow_symlinks = false }) catch return .unknown; + return st.kind; + } + + /// False when the walk stopped before the tree ran out. + fn walkDir(self: *Watches, io: std.Io, buf: *[std.fs.max_path_bytes]u8, len: usize, pass: Pass, top: bool, ctx: anytype) bool { + if (self.cancel.load(.acquire)) return false; + const path = buf[0..len]; + if (self.lost.contains(path)) return true; + // A watched folder's subtree was walked when it was added; a folder that appeared + // since has its own event, and one a walk left out is on the frontier. + const known = self.contains(path); + if (known and !(top and pass.resume_top) and !pass.through_known) return true; + var d = std.Io.Dir.openDirAbsolute(io, path, .{ .iterate = true }) catch |err| { + self.unreadable(path, err, pass.owed, ctx); + return true; + }; + defer d.close(io); + if (!known) switch (self.watch(path, ctx)) { + .added => {}, + .skipped => return true, + .blocked => { + self.leftOut(path, pass.owed, ctx); + return false; + }, + }; + // Everything it holds is said before anything below it is walked, so a walk that + // stops further down owes this folder its subfolders' contents, never its own entries + // a second time. + if (pass.owed == .all) { + var it = d.iterate(); + while (it.next(io) catch { + // Some said, the rest not: say them all again later rather than miss any. + self.leftOut(path, .all, ctx); + return true; + }) |entry| { + const kind = kindOf(io, d, entry); + if (kind != .file and kind != .directory) continue; + const child = join(buf, len, entry.name) orelse { + self.loseLong(path, entry.name, ctx); + continue; + }; + ctx.created(child, kind == .directory); + } + } + const rest: Owed = if (pass.owed == .none) .none else .below; + const deeper: Owed = if (pass.owed == .none) .none else .all; + var it = d.iterate(); + while (true) { + const entry = (it.next(io) catch { + self.leftOut(path, rest, ctx); + return true; + }) orelse break; + if (skipWatchDir(entry.name)) continue; + // A symlink is not followed: it may point back up the tree, or out of it. + if (kindOf(io, d, entry) != .directory) continue; + const child = join(buf, len, entry.name) orelse { + self.loseLong(path, entry.name, ctx); + continue; + }; + if (!self.walkDir(io, buf, child.len, .{ .owed = deeper, .through_known = pass.through_known }, false, ctx)) { + if (!self.cancel.load(.acquire)) self.leftOut(buf[0..len], rest, ctx); + return false; + } + } + self.done(path, pass.owed); + return true; + } + + /// `path` has been walked to the end, and what it was owed said: it is no longer waited + /// for, wherever it was left out before. + fn done(self: *Watches, path: []const u8, owed: Owed) void { + inline for (.{ &self.frontier, &self.todo }) |map| { + if (map.getIndex(path)) |i| if (@intFromEnum(map.values()[i]) <= @intFromEnum(owed)) { + const k = map.keys()[i]; + map.swapRemoveAt(i); + self.gpa.free(k); + }; + } + } + + /// A folder that couldn't be opened to walk. + fn unreadable(self: *Watches, path: []const u8, err: std.Io.Dir.OpenError, owed: Owed, ctx: anytype) void { + switch (err) { + // Gone already: nothing to watch. + error.FileNotFound, error.NotDir => {}, + // Out of fds or kernel memory, or something unforeseen: worth another try later. + error.ProcessFdQuotaExceeded, error.SystemFdQuotaExceeded, error.SystemResources, error.Unexpected => self.leftOut(path, owed, ctx), + // Its own, and not passing (a permission, a loop, a bad name): left out, said once. + else => { + _ = self.lose(path, @errorName(err), ctx); + }, + } + } + + fn watch(self: *Watches, path: []const u8, ctx: anytype) Watched { + if (self.paths.items.len + self.fresh.items.len >= self.limit) return self.block(.cap, ctx); + return self.add(path, 2, ctx); + } + + /// Add a watch on `path`, asking why when refused; `tries` bounds the retries a refusal + /// that has since cleared up gets. + fn add(self: *Watches, path: []const u8, tries: u8, ctx: anytype) Watched { + ctx.add(path) catch |err| switch (err) { + error.OutOfMemory => { + self.degrade(ctx); + return .skipped; + }, + else => return switch (ctx.why(path)) { + .gone => .skipped, + .pool => self.block(.refused, ctx), + .again => if (tries > 1) self.add(path, tries - 1, ctx) else self.lose(path, "refused", ctx), + .own => |reason| self.lose(path, reason, ctx), + }, + }; + const key = self.gpa.dupe(u8, path) catch { + ctx.remove(path); + self.degrade(ctx); + return .skipped; + }; + self.fresh.append(self.gpa, key) catch { + self.gpa.free(key); + ctx.remove(path); + self.degrade(ctx); + return .skipped; + }; + self.blocked = null; + return .added; + } + + fn degrade(self: *Watches, ctx: anytype) void { + if (self.degraded) return; + self.degraded = true; + ctx.say(.degraded); + } + + /// `path` is left out for good, said once. + fn lose(self: *Watches, path: []const u8, reason: []const u8, ctx: anytype) Watched { + const gop = self.lost.getOrPut(self.gpa, path) catch { + self.degrade(ctx); + return .skipped; + }; + if (gop.found_existing) return .skipped; + gop.key_ptr.* = self.gpa.dupe(u8, path) catch { + self.lost.removeByPtr(gop.key_ptr); + self.degrade(ctx); + return .skipped; + }; + ctx.say(.{ .skipped = .{ .path = path, .reason = reason } }); + return .skipped; + } + + /// An entry of `dir` whose path is too long to walk. + fn loseLong(self: *Watches, dir: []const u8, name: []const u8, ctx: anytype) void { + const p = std.fmt.allocPrint(self.gpa, "{s}{c}{s}", .{ dir, std.fs.path.sep, name }) catch return self.degrade(ctx); + defer self.gpa.free(p); + _ = self.lose(p, "NAMETOOLONG", ctx); + } + + /// Lost folders at or under `dir` are no longer there to be lost. + fn forgetLost(self: *Watches, dir: []const u8) void { + while (true) { + var it = self.lost.keyIterator(); + const k = while (it.next()) |k| { + if (inside(k.*, dir)) break k.*; + } else return; + _ = self.lost.remove(k); + self.gpa.free(k); + } + } + + fn block(self: *Watches, why: Blocked, ctx: anytype) Watched { + if (self.said != why) ctx.say(.{ .blocked = .{ .why = why, .watched = self.paths.items.len + self.fresh.items.len } }); + self.said = why; + self.blocked = why; + return .blocked; + } + + fn leftOut(self: *Watches, path: []const u8, owed: Owed, ctx: anytype) void { + const gop = self.frontier.getOrPut(self.gpa, path) catch return self.degrade(ctx); + if (gop.found_existing) { + if (@intFromEnum(owed) > @intFromEnum(gop.value_ptr.*)) gop.value_ptr.* = owed; + return; + } + gop.key_ptr.* = self.gpa.dupe(u8, path) catch { + _ = self.frontier.swapRemove(path); + return self.degrade(ctx); + }; + gop.value_ptr.* = owed; + } + + /// Frontier entries at or under `src` follow it to `dst`, or go, with no `dst`. + fn reframe(self: *Watches, src: []const u8, dst: ?[]const u8, ctx: anytype) void { + var f: usize = 0; + while (f < self.frontier.count()) { + const p = self.frontier.keys()[f]; + if (!inside(p, src)) { + f += 1; + continue; + } + const owed = self.frontier.values()[f]; + self.frontier.swapRemoveAt(f); + defer self.gpa.free(p); + const to = dst orelse continue; + const renamed = std.mem.concat(self.gpa, u8, &.{ to, p[src.len..] }) catch { + self.degrade(ctx); + continue; + }; + defer self.gpa.free(renamed); + self.leftOut(renamed, owed, ctx); + } + if (self.frontier.count() == 0) self.said = null; + } + + /// The range of `paths` strictly below `dir`: the keys that start with `dir` and a + /// separator. Found without building that prefix, so no path is too long for it. + fn below(self: *const Watches, dir: []const u8) struct { usize, usize } { + const items = self.paths.items; + const start = std.sort.lowerBound([]u8, items, dir, struct { + /// `d` and a separator, against `item`; a root that already ends in one (`/`) + /// is its own prefix, and is itself before its range. + fn order(d: []const u8, item: []u8) std.math.Order { + const n = @min(d.len, item.len); + switch (std.mem.order(u8, d, item[0..n])) { + .eq => {}, + else => |o| return o, + } + if (item.len == d.len) return .gt; + if (d.len > 0 and isSep(d[d.len - 1])) return .lt; + return std.math.order(std.fs.path.sep, item[d.len]); + } + }.order); + var end = start; + while (end < items.len and items[end].len > dir.len and inside(items[end], dir)) end += 1; + return .{ start, end }; + } + + /// Where `dir` itself is in `paths`. + fn indexOf(self: *const Watches, dir: []const u8) ?usize { + const i = self.lowerBound(dir); + return if (i < self.paths.items.len and std.mem.eql(u8, self.paths.items[i], dir)) i else null; + } + + /// Take `dir` and what is below it out of `paths` without allocating. With `remove`, each + /// watch goes too, named as the backend knows it now: under `as` when it has been moved + /// there. Returns how many keys went. + fn drop(self: *Watches, dir: []const u8, remove: bool, as: ?[]const u8, ctx: anytype) usize { + const start, const end = self.below(dir); + const at = self.indexOf(dir); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = end - start + @intFromBool(at != null); + for (self.paths.items[start..end]) |k| self.dropKey(k, dir, remove, as, &buf, ctx); + self.paths.replaceRangeAssumeCapacity(start, end - start, &.{}); + // `dir` sorts before everything below it, so its index survived the range going. + if (at) |i| self.dropKey(self.paths.orderedRemove(i), dir, remove, as, &buf, ctx); + return n; + } + + fn dropKey(self: *Watches, k: []u8, dir: []const u8, remove: bool, as: ?[]const u8, buf: *[std.fs.max_path_bytes]u8, ctx: anytype) void { + defer self.gpa.free(k); + if (!remove) return; + const to = as orelse return ctx.remove(k); + // Too long to name: the watch stays, untracked. Said as such. + const name = std.fmt.bufPrint(buf, "{s}{s}", .{ to, k[dir.len..] }) catch return self.degrade(ctx); + ctx.remove(name); + } + + /// `dir` left the tree: deleted (the OS dropped its watches with it) or moved out (it + /// kept them). Either way they are removed, which also clears the backend's own record, + /// and nothing below it is waited for or lost any longer. + pub fn gone(self: *Watches, dir: []const u8, ctx: anytype) void { + if (self.drop(dir, true, null, ctx) > 0) self.blocked = null; + self.forgetLost(dir); + self.reframe(dir, null, ctx); + } + + /// `src` was renamed to `dst`. The OS keeps a moved folder's watches (the backend has + /// already renamed them); they follow it here, unless it moved somewhere skipped or out + /// of the tree, where they are removed. A folder moved in from a skipped name is walked, + /// its contents announced: the backend reports the rename alone, and nothing was said + /// about them while it was out of view. + pub fn moved(self: *Watches, io: std.Io, src: []const u8, dst: []const u8, ctx: anytype) void { + if (!inside(dst, self.root)) return self.gone(src, ctx); + // Renamed over an empty folder: that folder's key goes. Not its watch: the OS dropped + // it with the folder, and the backend's record now names the moved one's. + _ = self.drop(dst, false, null, ctx); + self.forgetLost(dst); + self.forgetLost(src); + if (underSegment(self.root, dst, skipWatchDir)) { + if (self.drop(src, true, dst, ctx) > 0) self.blocked = null; + return self.reframe(src, null, ctx); + } + // Renaming a common prefix keeps the run in order, so it merges back in one pass. + const start, const end = self.below(src); + const at = self.indexOf(src); + var run: std.ArrayListUnmanaged([]u8) = .empty; + defer run.deinit(self.gpa); + run.ensureTotalCapacity(self.gpa, end - start + 1) catch { + if (self.drop(src, true, dst, ctx) > 0) self.blocked = null; + self.degrade(ctx); + return self.reframe(src, null, ctx); + }; + if (at) |i| run.appendAssumeCapacity(self.paths.items[i]); + run.appendSliceAssumeCapacity(self.paths.items[start..end]); + self.paths.replaceRangeAssumeCapacity(start, end - start, &.{}); + if (at) |i| _ = self.paths.orderedRemove(i); + var kept: usize = 0; + var buf: [std.fs.max_path_bytes]u8 = undefined; + for (run.items) |k| { + const renamed = std.mem.concat(self.gpa, u8, &.{ dst, k[src.len..] }) catch { + // No memory to follow it: the watch goes (and the key, in `dropKey`). + self.dropKey(k, src, true, dst, &buf, ctx); + continue; + }; + self.gpa.free(k); + run.items[kept] = renamed; + kept += 1; + } + if (kept < run.items.len) self.degrade(ctx); + self.merge(run.items[0..kept]) catch { + for (run.items[0..kept]) |p| { + ctx.remove(p); + self.gpa.free(p); + } + self.degrade(ctx); + }; + self.reframe(src, dst, ctx); + self.walkFrom(io, dst, .{ .resume_top = true, .owed = if (underSegment(self.root, src, skipWatchDir)) .all else .none }, ctx); + } +}; + +/// Where `Doorbell` builds: POSIX only. The walker that uses it exists only for a backend that +/// watches folder by folder (inotify, kqueue), and none of those is on Windows. +pub const has_doorbell = builtin.os.tag != .windows and !builtin.cpu.arch.isWasm(); + +/// Wakes a thread from another that may not wait, allocate or touch an `Io`: nightwatch's +/// reader. A pipe with a flag in front of it, so a burst of rings is one byte. +/// +/// The flag's orderings: `ring`'s swap releases what the ringer did before it (an event put +/// on a list), and `answer`'s swap acquires it, so the waiter sees that work once it has +/// lowered the flag. A ring that finds the flag already up writes nothing; the waiter is then +/// either before `answer` (it will look at the work after) or about to wake on the byte +/// already in the pipe. Both are read-modify-writes on one variable, so of a ring and an +/// answer racing, one sees the other. +pub const Doorbell = struct { + fds: [2]std.posix.fd_t, + rung: std.atomic.Value(bool) = .init(false), + + pub fn init() !Doorbell { + return .{ .fds = try std.Io.Threaded.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }) }; + } + + pub fn deinit(self: *Doorbell) void { + std.Io.Threaded.closeFd(self.fds[0]); + std.Io.Threaded.closeFd(self.fds[1]); + } + + /// From any thread. Never blocks. + pub fn ring(self: *Doorbell) void { + if (!self.rung.swap(true, .acq_rel)) _ = std.posix.system.write(self.fds[1], "x", 1); + } + + /// Block until rung since the last `answer`, or `timeout_ms` passes (-1: never). True when + /// rung. + pub fn wait(self: *Doorbell, timeout_ms: i32) error{PollFailed}!bool { + var pfd = [_]std.posix.pollfd{.{ .fd = self.fds[0], .events = std.posix.POLL.IN, .revents = 0 }}; + const n = std.posix.poll(&pfd, timeout_ms) catch return error.PollFailed; + return n > 0; + } + + /// Lower the bell. Call before looking at what it was rung about: a ring after this is + /// a new one. + pub fn answer(self: *Doorbell) void { + var buf: [64]u8 = undefined; + while (true) _ = std.posix.read(self.fds[0], &buf) catch break; + _ = self.rung.swap(false, .acq_rel); + } +}; + /// Fixed-capacity event buffer, filled by the watcher thread and drained by the UI thread. /// /// Nothing here allocates, and that is the whole point: the producer runs on a thread nightwatch @@ -130,25 +776,25 @@ const TestRing = Ring(TestKind, TestObject); test "dot-segment reject keeps build and vcs churn out of the ring" { const root = "/home/u/proj"; - try testing.expect(underDotSegment(root, "/home/u/proj/.git/index")); - try testing.expect(underDotSegment(root, "/home/u/proj/.zig-cache/o/abc/x.o")); - try testing.expect(underDotSegment(root, "/home/u/proj/src/.hidden/f.md")); - try testing.expect(underDotSegment(root, "/home/u/proj/.env")); + try testing.expect(underSegment(root, "/home/u/proj/.git/index", dotName)); + try testing.expect(underSegment(root, "/home/u/proj/.zig-cache/o/abc/x.o", dotName)); + try testing.expect(underSegment(root, "/home/u/proj/src/.hidden/f.md", dotName)); + try testing.expect(underSegment(root, "/home/u/proj/.env", dotName)); - try testing.expect(!underDotSegment(root, "/home/u/proj/README.md")); - try testing.expect(!underDotSegment(root, "/home/u/proj/src/index/Db.zig")); + try testing.expect(!underSegment(root, "/home/u/proj/README.md", dotName)); + try testing.expect(!underSegment(root, "/home/u/proj/src/index/Db.zig", dotName)); // A dot *inside* a segment is a file extension, not a hidden entry. - try testing.expect(!underDotSegment(root, "/home/u/proj/docs/a.b.md")); + try testing.expect(!underSegment(root, "/home/u/proj/docs/a.b.md", dotName)); // The root may itself sit under a dot-directory; only what's below it is our business. - try testing.expect(!underDotSegment("/home/u/.config/vault", "/home/u/.config/vault/note.md")); + try testing.expect(!underSegment("/home/u/.config/vault", "/home/u/.config/vault/note.md", dotName)); // Unrelated paths aren't ours to classify. - try testing.expect(!underDotSegment(root, "/etc/passwd")); + try testing.expect(!underSegment(root, "/etc/passwd", dotName)); } test "dot-segment reject handles windows separators" { const root = "C:\\proj"; - try testing.expect(underDotSegment(root, "C:\\proj\\.git\\HEAD")); - try testing.expect(!underDotSegment(root, "C:\\proj\\src\\main.zig")); + try testing.expect(underSegment(root, "C:\\proj\\.git\\HEAD", dotName)); + try testing.expect(!underSegment(root, "C:\\proj\\src\\main.zig", dotName)); } test "push records paths and rename pairs" { @@ -238,3 +884,675 @@ test "empty distinguishes nothing-happened from everything-was-dropped" { try testing.expectEqual(@as(usize, 0), r.count); try testing.expect(!r.empty()); } + +test "a watch skips hidden folders and the trees tools write" { + try testing.expect(skipWatchDir(".git")); + try testing.expect(skipWatchDir(".zig-cache")); + try testing.expect(skipWatchDir(".bun")); + try testing.expect(skipWatchDir("node_modules")); + try testing.expect(skipWatchDir("zig-pkg")); + + try testing.expect(!skipWatchDir("src")); + // Ordinary names in plenty of projects (`docs/target`): watched, left to `IgnoreRules`. + try testing.expect(!skipWatchDir("target")); + try testing.expect(!skipWatchDir("zig-out")); + try testing.expect(!skipWatchDir("my.module")); +} + +test "a folder is skipped when any folder on its way from the root is" { + try testing.expect(underSegment("/p", "/p/node_modules/lodash", skipWatchDir)); + try testing.expect(underSegment("/p", "/p/src/.git/objects", skipWatchDir)); + try testing.expect(!underSegment("/p", "/p/src/a", skipWatchDir)); + try testing.expect(!underSegment("/home/u/.config/vault", "/home/u/.config/vault/notes", skipWatchDir)); + try testing.expect(!underSegment("/p", "/elsewhere", skipWatchDir)); +} + +/// Watches as a backend would: refusing any past `room` the way inotify does once +/// `max_user_watches` is spent, and any folder named `refuse` for reasons of its own. `why` +/// answers as the errno probe would. +const TestBackend = struct { + room: usize = std.math.maxInt(usize), + refuse: []const u8 = "", + oom: bool = false, + live: usize = 0, + attempts: usize = 0, + removed: usize = 0, + blocked_said: usize = 0, + skipped_said: usize = 0, + last_blocked: ?Blocked = null, + created_dirs: usize = 0, + created_files: usize = 0, + degraded_said: usize = 0, + /// What `why` says for `refuse` before it says `.own`: `.again`s first, or the pool. + agains: usize = 0, + refuse_is_pool: bool = false, + + pub fn add(self: *TestBackend, path: []const u8) error{ OutOfMemory, WatchFailed }!void { + self.attempts += 1; + if (self.oom) return error.OutOfMemory; + if (self.refuse.len > 0 and std.mem.endsWith(u8, path, self.refuse)) return error.WatchFailed; + if (self.live >= self.room) return error.WatchFailed; + self.live += 1; + } + + pub fn why(self: *TestBackend, path: []const u8) Refusal { + if (self.refuse.len > 0 and std.mem.endsWith(u8, path, self.refuse)) { + if (self.agains > 0) { + self.agains -= 1; + return .again; + } + return if (self.refuse_is_pool) .pool else .{ .own = "INVAL" }; + } + return if (self.live >= self.room) .pool else .again; + } + + pub fn remove(self: *TestBackend, path: []const u8) void { + _ = path; + self.live -= 1; + self.removed += 1; + } + + pub fn created(self: *TestBackend, path: []const u8, is_dir: bool) void { + _ = path; + if (is_dir) self.created_dirs += 1 else self.created_files += 1; + } + + pub fn say(self: *TestBackend, said: Said) void { + switch (said) { + .blocked => |b| { + self.blocked_said += 1; + self.last_blocked = b.why; + }, + .skipped => self.skipped_said += 1, + .degraded => self.degraded_said += 1, + } + } +}; + +const TestTree = struct { + tmp: testing.TmpDir, + buf: [std.fs.max_path_bytes]u8 = undefined, + root: []const u8 = "", + cancel: std.atomic.Value(bool) = .init(false), + scratch: [std.fs.max_path_bytes]u8 = undefined, + + /// A small project: six folders worth watching, and the kinds a watch leaves alone. + fn init(self: *TestTree) !void { + self.tmp = testing.tmpDir(.{}); + for ([_][]const u8{ "src/a", "docs", "zig-out/bin", ".git/objects/ab", "node_modules/x/y" }) |p| + try self.tmp.dir.createDirPath(testing.io, p); + self.root = self.buf[0..try self.tmp.dir.realPath(testing.io, &self.buf)]; + } + + /// `rel` under the root, with `/` for the separator. Valid until the next call. + fn path(self: *TestTree, rel: []const u8) []const u8 { + const out = std.fmt.bufPrint(&self.scratch, "{s}{s}{s}", .{ self.root, std.fs.path.sep_str, rel }) catch unreachable; + for (out[self.root.len..]) |*c| if (c.* == '/') { + c.* = std.fs.path.sep; + }; + return out; + } + + /// `rel` under the root, owned by the caller. + fn own(self: *TestTree, rel: []const u8) []u8 { + return testing.allocator.dupe(u8, self.path(rel)) catch unreachable; + } + + fn watches(self: *TestTree) Watches { + return .{ .gpa = testing.allocator, .root = self.root, .cancel = &self.cancel }; + } +}; + +test "a walk watches the folders a person edits, once each" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + + w.walk(testing.io, &b); + // root, src, src/a, docs, zig-out, zig-out/bin + try testing.expectEqual(@as(usize, 6), w.paths.items.len); + try testing.expect(w.contains(t.path("src/a"))); + try testing.expect(w.contains(t.path("zig-out/bin"))); + try testing.expect(!w.contains(t.path("node_modules/x"))); + try testing.expect(w.whole()); + + // A tree moved in announces each folder in it; the ones already watched cost nothing. + w.settled = true; + w.appeared(testing.io, t.path("src"), true, &b); + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 6), b.attempts); + + // A folder under a skipped one is never watched, whatever announced it. + w.appeared(testing.io, t.path("node_modules/x"), false, &b); + try testing.expectEqual(@as(usize, 6), w.paths.items.len); + try testing.expectEqual(@as(usize, 0), b.blocked_said + b.skipped_said); +} + +test "a walk's folders land in order, however many" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + for (0..40) |i| { + var name: [16]u8 = undefined; + try t.tmp.dir.createDirPath(testing.io, try std.fmt.bufPrint(&name, "d{d}/e", .{i})); + } + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 86), w.paths.items.len); + for (w.paths.items[1..], 0..) |p, i| try testing.expect(std.mem.lessThan(u8, w.paths.items[i], p)); + try testing.expectEqual(@as(usize, 0), w.fresh.items.len); +} + +test "a folder refused for its own reasons is left out, said once, and the walk goes on" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .refuse = "docs" }; + + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 5), w.paths.items.len); + try testing.expect(!w.contains(t.path("docs"))); + try testing.expectEqual(@as(usize, 1), b.skipped_said); + try testing.expectEqual(@as(usize, 0), b.blocked_said); + // Not waited for, never blamed on the pool, but the tree isn't whole. + try testing.expectEqual(@as(usize, 0), w.frontier.count()); + try testing.expect(!w.waiting()); + try testing.expect(!w.whole()); +} + +test "a folder that can't be read is left out and said; the rest is watched" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + try t.tmp.dir.createDirPath(testing.io, "locked/inner"); + try t.tmp.dir.setFilePermissions(testing.io, "locked", .fromMode(0o000), .{}); + defer t.tmp.dir.setFilePermissions(testing.io, "locked", .fromMode(0o755), .{}) catch {}; + // Root reads it anyway. + if (t.tmp.dir.openDir(testing.io, "locked", .{ .iterate = true })) |d| { + d.close(testing.io); + return error.SkipZigTest; + } else |_| {} + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + try testing.expect(!w.contains(t.path("locked/inner"))); + try testing.expect(w.contains(t.path("src/a"))); + try testing.expect(!w.whole()); +} + +test "out of watches stops the walk, is said once, and goes on from there when retried" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .room = 2 }; + + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 2), w.paths.items.len); + try testing.expectEqual(@as(usize, 1), b.blocked_said); + try testing.expectEqual(@as(?Blocked, .refused), b.last_blocked); + try testing.expect(w.waiting()); + + // Still out: a retry finds nothing more, and says nothing more. + w.retry(); + w.catchUp(testing.io, &b); + try testing.expectEqual(@as(usize, 2), w.paths.items.len); + try testing.expectEqual(@as(usize, 1), b.blocked_said); + + // Freed by someone else: the next retry goes on from where it stopped, not the root. + b.room = 100; + const attempts = b.attempts; + w.retry(); + w.catchUp(testing.io, &b); + try testing.expect(w.whole()); + try testing.expectEqual(@as(usize, 6), w.paths.items.len); + try testing.expectEqual(attempts + 4, b.attempts); + try testing.expectEqual(@as(usize, 0), b.skipped_said); +} + +test "a root that can't be watched at all is retried" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .room = 0 }; + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 0), w.paths.items.len); + try testing.expectEqual(@as(usize, 1), b.blocked_said); + try testing.expect(w.waiting()); + b.room = 100; + w.retry(); + w.catchUp(testing.io, &b); + try testing.expect(w.whole()); + try testing.expectEqual(@as(usize, 6), w.paths.items.len); +} + +test "the cap counts live watches, so a folder leaving frees room" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + w.limit = 3; + defer w.deinit(); + var b: TestBackend = .{}; + + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 3), w.paths.items.len); + try testing.expectEqual(@as(?Blocked, .cap), b.last_blocked); + // Time doesn't help the cap. + try testing.expect(!w.waiting()); + + const kept = try testing.allocator.dupe(u8, w.paths.items[1]); + defer testing.allocator.free(kept); + w.gone(kept, &b); + try testing.expect(w.paths.items.len < 3); + w.catchUp(testing.io, &b); + try testing.expectEqual(@as(usize, 3), w.paths.items.len); + try testing.expectEqual(w.paths.items.len, b.live); +} + +test "the backend out of memory degrades the watch, said once" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .oom = true }; + w.walk(testing.io, &b); + try testing.expect(w.degraded); + try testing.expectEqual(@as(usize, 1), b.degraded_said); + try testing.expectEqual(@as(usize, 0), b.blocked_said); + try testing.expect(!w.whole()); +} + +test "what a new folder held before its watch landed is announced, once" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + w.settled = true; + try testing.expectEqual(@as(usize, 0), b.created_dirs + b.created_files); + + // An extract, a clone: a folder, and files and folders written into it at once. + try t.tmp.dir.createDirPath(testing.io, "new/sub/node_modules/x"); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "new/f.txt", .data = "" }); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "new/sub/g.txt", .data = "" }); + w.appeared(testing.io, t.path("new"), false, &b); + // f.txt, g.txt; sub, sub/node_modules (seen, though not walked into). + try testing.expectEqual(@as(usize, 2), b.created_files); + try testing.expectEqual(@as(usize, 2), b.created_dirs); + try testing.expect(w.contains(t.path("new/sub"))); + try testing.expect(!w.contains(t.path("new/sub/node_modules"))); + + // The backend's own report of a move-in, and the subfolder's own event: nothing again. + w.appeared(testing.io, t.path("new/sub"), false, &b); + try t.tmp.dir.createDirPath(testing.io, "moved-in/x"); + w.appeared(testing.io, t.path("moved-in"), true, &b); + try testing.expectEqual(@as(usize, 4), b.created_dirs + b.created_files); + try testing.expect(w.contains(t.path("moved-in/x"))); +} + +test "a folder made during the first walk, and reached by it, is announced by its own event" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "docs/made-early.md", .data = "" }); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + // Its event, queued while the walk ran, is handled before the walker settles. + w.appeared(testing.io, t.path("docs"), false, &b); + try testing.expectEqual(@as(usize, 1), b.created_files); + w.settled = true; + w.appeared(testing.io, t.path("docs"), false, &b); + try testing.expectEqual(@as(usize, 1), b.created_files); +} + +test "a folder that appeared while out of watches is announced when it is watched" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + w.settled = true; + b.room = b.live; + try t.tmp.dir.createDirPath(testing.io, "late"); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "late/f.txt", .data = "" }); + w.appeared(testing.io, t.path("late"), false, &b); + try testing.expectEqual(@as(usize, 0), b.created_files); + b.room = 100; + w.retry(); + w.catchUp(testing.io, &b); + try testing.expectEqual(@as(usize, 1), b.created_files); +} + +test "a folder leaving the tree gives back its watches and what was waiting below it" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + w.settled = true; + + // `src-x` sorts between `src` and `src/…`; it must survive `src` going. + try t.tmp.dir.createDirPath(testing.io, "src-x"); + w.appeared(testing.io, t.path("src-x"), false, &b); + const src = t.own("src"); + defer testing.allocator.free(src); + const waiting = t.own("src/a/later"); + defer testing.allocator.free(waiting); + w.leftOut(waiting, .none, &b); + w.gone(src, &b); + try testing.expectEqual(@as(usize, 2), b.removed); + try testing.expect(w.contains(t.path("src-x"))); + try testing.expectEqual(w.paths.items.len, b.live); + try testing.expectEqual(@as(usize, 0), w.frontier.count()); +} + +test "a renamed folder keeps its watches, gains them from a skipped name and loses them to one" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + const src = t.own("src"); + defer testing.allocator.free(src); + const lib = t.own("lib"); + defer testing.allocator.free(lib); + + // Within view: the watches follow, none added; what was waiting below follows too. + try t.tmp.dir.rename("src", t.tmp.dir, "lib", testing.io); + const waiting = t.own("src/a/later"); + defer testing.allocator.free(waiting); + w.leftOut(waiting, .all, &b); + const attempts = b.attempts; + w.moved(testing.io, src, lib, &b); + try testing.expectEqual(attempts, b.attempts); + try testing.expect(w.contains(t.path("lib/a"))); + try testing.expect(!w.contains(src)); + try testing.expectEqual(Owed.all, w.frontier.get(t.path("lib/a/later")).?); + + // Out of a hidden folder into view: watched now, and what it holds announced. + try t.tmp.dir.createDirPath(testing.io, ".tmp-x/sub"); + try t.tmp.dir.rename(".tmp-x", t.tmp.dir, "pkg", testing.io); + const tmpx = t.own(".tmp-x"); + defer testing.allocator.free(tmpx); + const pkg = t.own("pkg"); + defer testing.allocator.free(pkg); + w.moved(testing.io, tmpx, pkg, &b); + try testing.expect(w.contains(t.path("pkg/sub"))); + try testing.expectEqual(@as(usize, 1), b.created_dirs); + try testing.expectEqual(@as(usize, 8), b.live); + + // Into a hidden folder: its watches are given back, and nothing waits below it. + try t.tmp.dir.rename("lib", t.tmp.dir, ".trash", testing.io); + const trash = t.own(".trash"); + defer testing.allocator.free(trash); + w.moved(testing.io, lib, trash, &b); + try testing.expectEqual(@as(usize, 2), b.removed); + try testing.expectEqual(@as(usize, 6), w.paths.items.len); + try testing.expectEqual(w.paths.items.len, b.live); + try testing.expectEqual(@as(usize, 0), w.frontier.count()); +} + +test "a folder renamed over an empty watched one leaves one key, not two" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + try t.tmp.dir.rename("src", t.tmp.dir, "docs", testing.io); + const src = t.own("src"); + defer testing.allocator.free(src); + const docs = t.own("docs"); + defer testing.allocator.free(docs); + w.moved(testing.io, src, docs, &b); + var n: usize = 0; + for (w.paths.items) |p| if (std.mem.eql(u8, p, docs)) { + n += 1; + }; + try testing.expectEqual(@as(usize, 1), n); + try testing.expect(w.contains(t.path("docs/a"))); +} + +test "a cancelled walk adds nothing" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + t.cancel.store(true, .release); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 0), w.paths.items.len); +} + +test "a doorbell rung any number of times wakes once, and again after it is answered" { + if (comptime !has_doorbell) return error.SkipZigTest else { + var bell: Doorbell = try .init(); + defer bell.deinit(); + try testing.expect(!try bell.wait(0)); + bell.ring(); + bell.ring(); + try testing.expect(try bell.wait(0)); + bell.answer(); + try testing.expect(!try bell.wait(0)); + bell.ring(); + try testing.expect(try bell.wait(0)); + } +} + +test "a stop rung while the waiter is between waking and answering is not lost" { + if (comptime !has_doorbell) return error.SkipZigTest else { + // The walker's loop: wait, answer, then look. A stop that rings after the wake but + // before the answer must still be seen, or `stopWatch`'s join waits for ever. + var bell: Doorbell = try .init(); + defer bell.deinit(); + var stop: std.atomic.Value(bool) = .init(false); + bell.ring(); + try testing.expect(try bell.wait(-1)); + stop.store(true, .release); + bell.ring(); // swallowed by the flag: already rung + bell.answer(); + try testing.expect(stop.load(.acquire)); + + // And from another thread, a waiter blocked in `wait` wakes for it. + const Waiter = struct { + fn run(b: *Doorbell, s: *std.atomic.Value(bool)) void { + while (true) { + _ = b.wait(-1) catch return; + b.answer(); + if (s.load(.acquire)) return; + } + } + }; + stop.store(false, .release); + const th = try std.Thread.spawn(.{}, Waiter.run, .{ &bell, &stop }); + stop.store(true, .release); + bell.ring(); + th.join(); + } +} + +test "inside and the segment check need a separator after the folder" { + try testing.expect(inside("/p/a", "/p")); + try testing.expect(inside("/p", "/p")); + try testing.expect(!inside("/pp/a", "/p")); + try testing.expect(inside("/a", "/")); + try testing.expect(!underSegment("/p", "/pp/node_modules", skipWatchDir)); + try testing.expect(underSegment("/p", "/p/node_modules", skipWatchDir)); + try testing.expect(underSegment("/", "/node_modules/x", skipWatchDir)); +} + +test "a walk that stops partway owes the folder only what it hadn't said" { + // P holds files and D and E; the walk says P's entries, walks D whole, then runs out at + // E. Resumed, it must say what is in E — and nothing in P or D again. + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + w.settled = true; + try t.tmp.dir.createDirPath(testing.io, "P/D/d1"); + try t.tmp.dir.createDirPath(testing.io, "P/E/e1"); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "P/f.txt", .data = "" }); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "P/D/g.txt", .data = "" }); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "P/E/h.txt", .data = "" }); + // Room for P and one of its subfolders' trees (two folders), whichever is walked first. + b.room = b.live + 3; + w.appeared(testing.io, t.path("P"), false, &b); + try testing.expect(w.blocked != null); + const said_first = b.created_files + b.created_dirs; + const attempts = b.attempts; + b.room = 100; + w.retry(); + w.catchUp(testing.io, &b); + try testing.expect(w.whole()); + // P: f.txt, D, E; D: d1, g.txt; E: e1, h.txt — seven, each once. + try testing.expectEqual(@as(usize, 7), b.created_files + b.created_dirs); + try testing.expect(said_first < 7); + // Only what wasn't watched was tried: the refused folder and what is below it. + try testing.expectEqual(@as(usize, 2), b.attempts - attempts); +} + +test "a lost folder is said once, not tried again, and forgotten when it goes" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .refuse = "docs" }; + w.walk(testing.io, &b); + try testing.expectEqual(@as(usize, 1), b.skipped_said); + const tried = b.attempts; + w.settled = true; + w.appeared(testing.io, t.path("docs"), false, &b); + w.walk(testing.io, &b); + try testing.expectEqual(tried, b.attempts); + try testing.expectEqual(@as(usize, 1), b.skipped_said); + try testing.expect(!w.whole()); + + const docs = t.own("docs"); + defer testing.allocator.free(docs); + try t.tmp.dir.deleteTree(testing.io, "docs"); + w.gone(docs, &b); + try testing.expect(w.whole()); +} + +test "a lost folder renamed is tried again under its new name" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{ .refuse = "docs" }; + w.walk(testing.io, &b); + try testing.expect(!w.whole()); + try t.tmp.dir.rename("docs", t.tmp.dir, "notes", testing.io); + const docs = t.own("docs"); + defer testing.allocator.free(docs); + const notes = t.own("notes"); + defer testing.allocator.free(notes); + w.moved(testing.io, docs, notes, &b); + try testing.expect(w.contains(notes)); + try testing.expect(w.whole()); +} + +test "a refusal that clears up is tried once more, then judged again" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + { + var w = t.watches(); + defer w.deinit(); + // Says .again, then the retry is refused for the pool: blocked, kept for later. + var b: TestBackend = .{ .refuse = "docs", .agains = 1, .refuse_is_pool = true }; + w.walk(testing.io, &b); + try testing.expectEqual(@as(?Blocked, .refused), w.blocked); + try testing.expect(w.frontier.contains(t.path("docs"))); + try testing.expectEqual(@as(usize, 0), w.lost.count()); + } + { + var w = t.watches(); + defer w.deinit(); + // Says .again twice: lost after the one retry, not looped on. + var b: TestBackend = .{ .refuse = "docs", .agains = 5 }; + w.walk(testing.io, &b); + try testing.expect(w.lost.contains(t.path("docs"))); + try testing.expectEqual(@as(usize, 1), b.skipped_said); + } +} + +test "a folder moved out of the root goes" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + var w = t.watches(); + defer w.deinit(); + var b: TestBackend = .{}; + w.walk(testing.io, &b); + const src = t.own("src"); + defer testing.allocator.free(src); + w.moved(testing.io, src, "/elsewhere/src", &b); + try testing.expect(!w.contains(t.path("src/a"))); + try testing.expectEqual(w.paths.items.len, b.live); +} + +test "running out of memory anywhere leaves no watch untracked" { + var t: TestTree = .{ .tmp = undefined }; + try t.init(); + defer t.tmp.cleanup(); + try t.tmp.dir.createDirPath(testing.io, "new/sub"); + try t.tmp.dir.writeFile(testing.io, .{ .sub_path = "new/f.txt", .data = "" }); + // Fail the first, then the second, … allocation of a whole session: walk, a folder that + // appears, a rename, a folder going. Whatever fails, every live watch is one `paths` + // holds (so it can be removed), and nothing leaks. + var fail_at: usize = 0; + while (fail_at < 200) : (fail_at += 1) { + var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_at }); + var w: Watches = .{ .gpa = failing.allocator(), .root = t.root, .cancel = &t.cancel }; + var b: TestBackend = .{}; + w.walk(testing.io, &b); + w.settled = true; + w.appeared(testing.io, t.path("new"), false, &b); + const src = t.own("src"); + const lib = t.own("lib"); + w.moved(testing.io, src, lib, &b); + w.moved(testing.io, lib, src, &b); + w.gone(src, &b); + testing.allocator.free(src); + testing.allocator.free(lib); + w.catchUp(testing.io, &b); + try testing.expectEqual(w.paths.items.len, b.live); + if (failing.has_induced_failure) { + try testing.expect(w.degraded or w.blocked != null); + try testing.expect(!w.whole()); + } + w.deinit(); + } +} From 6c6a5d41d87cf38aea5fe989ac6d206b85812786 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:43:16 -0500 Subject: [PATCH 09/31] crash: report the caller when the PC is outside every module (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #525 ## What changes A crash report whose faulting PC is not code (a call through a bad function pointer: heap, an unloaded dylib) no longer loses its caller or prints garbage frames. The handler no longer calls the dynamic loader. **The rule:** the report acts only on an address that is **definitely not code**. Acting means recovering a caller, stopping the walk, or marking a frame `(not code)`. Whatever can't be told is `unknown` and is written as it was before this PR: std walks, and the raw address is printed. The answer comes from the kernel, which knows every mapping whoever made it (JIT, a library dlopened by a library, a dylib kept mapped after unload). It never comes from a table that might be incomplete. All of it lives in a new `app/crash/loaded.zig`: - **Linux:** `/proc/self/maps`, read once per report with raw `open`/`read` syscalls into a static 512 KB buffer. It is parsed once into a static array of up to 8192 mappings (range, exec, base, name), and each lookup binary-searches that array. - Code is an `x` mapping. Not code is another mapping, or no mapping when the file was read whole and fit; otherwise unknown. - Names come from the maps line's path, with the kernel's ` (deleted)` suffix stripped. - An image's base is the start of the same file's first mapping, matched by dev and inode. That holds where lld puts code a page past its file offset, so `lo - offset` would be wrong; tested on that layout. - **Windows:** `VirtualQuery`. Code is `MEM_COMMIT` with any `PAGE_EXECUTE*`, not code is anything else, unknown when the query fails. The base is always the allocation base. The name comes from the module table (taken at `init`/`writeTo`) only when that entry starts at the same base, which guards against a stale entry. Otherwise the frame prints as `?+0x…`. - **macOS:** dyld's add/remove-image callbacks keep a table of images (`__TEXT` range and name), plus dyld itself from `TASK_DYLD_INFO` on 64-bit targets. dyld removes only what it unmaps, so `removeModule` no longer drops anything. An address the table doesn't hold asks `mach_vm_region_recurse`, descending the shared cache's submaps. If the table fills (1024 images), only names suffer, and `refresh` logs it once. Replacing an entry writes a free slot and only then lets the old one go, round-robin, so a crash reading the table sees one whole entry or the other, never a torn one. What it does with that: - **The caller is recovered** when the PC is not code: the return address the call left (`*(sp)` on x86_64, `lr` on aarch64), printed as `caller (by call convention):`. That happens only when that word is code; a stack word that isn't code names no caller. - A jump or tail call through the bad pointer leaves the grandparent there; a comment notes the limit. - The stack is then walked from the caller's context with std's own unwinder: PC = return address − 1 (`rsp = sp + 8` on x86_64). std takes a context's PC as exact, so the unwind-info lookup lands in the calling function even when its call is its last instruction. std's "+1" for a context's PC then turns it back into the return address it prints. - If that walk finds nothing, std's walk from the original context runs. - **A return through a corrupt return address names no caller:** `caller: (caller unknown: pc came from a return)`. - aarch64: `lr == pc`. - x86_64: the word just below `sp` equals `pc` **and** `*(sp)` is not a code address. The word below `sp` lies below the stack, where anything may linger, and a real call through a bad pointer leaves a code return address at `*(sp)`. Both shapes are tested through a pure `callerFrom`. - **`*(sp)` is read so it can't fault a second time:** - Linux: through `process_vm_readv` on our own pid, which fails rather than faults. - macOS: only within the thread's `pthread_get_stackaddr_np`/`pthread_get_stacksize_np` bounds. - Windows: only within the TEB's `StackLimit`..`StackBase` bounds. - **The walk stops at the first frame after the PC that is not code:** `(unwind stopped: 0x… is not code)`. Each frame is located once, and that result decides the stop, the header's `crashed in ` (from frames before the stop), and the printed frame. A module only names an address: its ELF span covers its data too, so fizzy's own `.data` is not code. On platforms the kernel query isn't wired for (the BSDs, illumos, haiku, …) everything is unknown, which leaves the report as it was. - **Frames outside every module print as `image+0xoffset`.** - **A second `init` doesn't chain the fault handler to itself.** - **The header records the thread and registers:** thread id and name (`pthread_getname_np` on Linux, Apple OSes, FreeBSD and NetBSD), `pc`/`sp`/`fp`, and for SEGV/BUS/ILL/FPE the signal's `si_code`. Abort and trap are left out, since there `si_code` names the sender. std's fault handler isn't given `siginfo`, so `init` chains a small handler in front of std's that notes the code in one atomic word tagged with the thread id, so another thread's fault can't lend its code. A `threadlocal` can allocate on first touch on macOS. - **No large stack buffers in the handler** (a Windows report can come from a stack overflow): the module copy, the frame locations and the maps text are static. Not changed: the issue's note that the context-PC frame is printed one byte early. std's `StackIterator` already returns a context's PC **plus one** (`ctx_first`), so `ret -| 1` gives the exact PC. ### Demos Scratch programs outside the repo; not committed. macOS, aarch64 natively and x86_64 under Rosetta. `outer` → `callThrough(heap_ptr)`, before (aarch64): ``` demo crashed in demo 0.0.1: Bus error at address 0x10183db20 frames: 0x10183db20 demo+0xfde87 <- outer; callThrough is missing demo+0xfdc7f demo+0xfdbb3 0x180f3bdff dyld ``` After (aarch64): ``` demo crashed in demo 0.0.1: Bus error at address 0x100ea99a0 (code 1) thread 17127651 registers: pc 0x100ea99a0 sp 0x16f83e540 fp 0x16f83e560 caller (by call convention): demo+0xfe2cf (pc is not code) frames: 0x100ea99a0 (not code) demo+0xfe2cf <- callThrough demo+0xfde87 demo+0xfdc7f demo+0xfdbb3 dyld+0x1fdff ``` Before (x86_64): `demo+0x121208 / +0x120fec / +0x120f31 / 0x20445bb27`, with callThrough missing. After (x86_64): ``` caller (by call convention): demo+0x1215c5 (pc is not code) frames: 0x7faa21706080 (not code) demo+0x1215c5 <- callThrough, from *(sp) demo+0x121208 demo+0x120fec demo+0x120f31 dyld+0x12b27 ``` A pivoted stack (x86_64: `rsp = 0x10000`, then a jump into the heap). The first revision of this PR read `*(0x10000)` in the handler, faulted there and wrote no report. Now: ``` registers: pc 0x7fed1bf06040 sp 0x10000 fp 0x301b5a690 frames: 0x7fed1bf06040 (not code) pivot+0x120f31 dyld+0x12b27 ``` A return through a corrupt return address (x86_64 `push heap; ret`; aarch64 `mov x30, heap; ret`, same result): ``` registers: pc 0x7fbb23f06040 sp 0x301f44620 fp 0x301f44680 caller: (caller unknown: pc came from a return) frames: 0x7fbb23f06040 (not code) ret+0x120f31 dyld+0x12b27 ``` ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-tests-reached`, `zig build test-sdk-version`. The crash tests (15) pass natively and as x86_64 under Rosetta. The demos above ran on aarch64 and x86_64 (Rosetta). Unit tests cover: - the report text: caller line, from-a-return, registers, `si_code`, `image+offset`, the stop at not-code, nothing stopped or marked for unknown, a module's data not being code, the header honouring the stop; - `callerFrom` for x86_64 and aarch64, including a lingering bad pointer below `sp` that must not read as a return, and a non-code stack word or `lr` that must not be named; - replacing a table entry leaving the old one whole; - the thread-tagged fault code; - the safe stack read; - `loaded.at` on this code, a dyld-shared-cache function, data, heap and an unmapped address, and the kernel query (`mach_vm_region_recurse`) on its own; - the `/proc/self/maps` parser and lookup on a sample with lld's layout, a deleted file, a gap and the heap. The call-ends-its-function case for the caller context isn't exercised end to end, because Zig emits a trap after a noreturn call, so its code never ends in one. A ReleaseFast demo does show std's own `-1` handling the same shape one frame up: `outer`'s last instruction calls `callLast`, its return address is `callLast`'s first byte, and the report still names `outer`. C code that ends in a call to a noreturn function is what the `-1` on the caller context covers. - [ ] Windows: `zig build -Dtarget=x86_64-windows-gnu` and the crash tests cross-compile; not run. - [ ] Linux: `zig build -Dtarget=x86_64-linux-gnu` and the crash tests (x86_64, aarch64) cross-compile; not run here. CI runs them; `/proc/self/maps` and `process_vm_readv` are only exercised live there. - [x] Web: `zig build check-web`. ## Follow-ups - **The proper fix for the lost caller is upstream,** in std's `StackIterator`: its FP fallback after a context PC with no unwind info should start from the return address the call left, not the frame pointer. This patch works around it in fizzy. - **This patch depends on std's "context PC + 1" convention** (`StackIterator.next`'s `ctx_first`): it writes `pc + 1` for frame 0 and replaces std's `caller + 1` with `caller`. Check it on the Zig 0.17 move (fizzyedit/plans#5). - std's own unwinder, which the handler still calls, asks the loader for unwind info (`SelfInfo/Elf.zig`, `SelfInfo/MachO.zig`). Only fizzy's part of the report is loader-free. - Thread names on Windows (`GetThreadDescription` allocates) and `si_code`'s Windows equivalent are left out. Windows names images loaded after `writeTo` as `?`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/crash/crash.zig | 472 +++++++++++++++++++++++++++++++++++++------ app/crash/image.zig | 10 +- app/crash/loaded.zig | 419 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 837 insertions(+), 64 deletions(-) create mode 100644 app/crash/loaded.zig diff --git a/app/crash/crash.zig b/app/crash/crash.zig index 606f2dcea..a5f4848ee 100644 --- a/app/crash/crash.zig +++ b/app/crash/crash.zig @@ -22,6 +22,7 @@ const std = @import("std"); const builtin = @import("builtin"); pub const image = @import("image.zig"); +pub const loaded = @import("loaded.zig"); /// Where crash reports can be written: native targets std can catch faults on. pub const supported = builtin.target.cpu.arch != .wasm32 and std.debug.have_segfault_handling_support; @@ -54,7 +55,8 @@ pub const Module = struct { /// What crashed, as the first line of a report says it. pub const What = union(enum) { panic: []const u8, - signal: struct { name: []const u8, address: ?usize = null }, + /// `code` is the signal's `si_code` (why it was sent: no mapping, no permission, …). + signal: struct { name: []const u8, address: ?usize = null, code: ?i32 = null }, }; /// The run a report describes. Every string outlives the run. @@ -64,53 +66,132 @@ pub const Run = struct { sdk: []const u8, }; -/// Writes a report: who crashed and how, the run, the modules, and each frame as a module and an -/// offset. `frames` are return addresses, as `std.debug.captureCurrentStackTrace` gives them; -/// each is written less one, so it lands inside the call. `nameOf` names an address no module -/// holds (a system library), when the platform can. +/// The thread that crashed, and for a fault the registers it stopped with. +pub const Thread = struct { + id: u64 = 0, + name: []const u8 = "", + registers: ?Registers = null, +}; + +pub const Registers = struct { + pc: usize, + sp: usize, + fp: usize, + /// When `pc` lies in no image's code, what its caller left; the stack is walked from there. + caller: Caller = .none, +}; + +pub const Caller = union(enum) { + /// `pc` is in an image, or the caller couldn't be read safely. + none, + /// The return address a call through a bad pointer left: `*(sp)` on x86_64, the link + /// register on aarch64. By the call convention only: a jump or tail call through the bad + /// pointer leaves the caller's own caller there, which this then names. + address: usize, + /// That return address is `pc` itself: `pc` came from a return through a corrupt return + /// address, not a call, so it names no caller. + from_return, +}; + +/// Whether an address is code, and whose: `loaded.at`, or a test's stand-in. +pub const CodeAt = *const fn (usize) loaded.At; + +/// Writes a report: who crashed and how, the run, the thread, the modules, and each frame as a +/// module (or image) and an offset. `frames` are return addresses, as +/// `std.debug.captureCurrentStackTrace` gives them; each is written less one, so it lands inside +/// the call. (The first, a context's own PC, std already gives plus one.) `code_at` says which +/// addresses are code. The walk stops at the first frame past the first that is definitely not +/// code, since a frame-pointer walk through a corrupt frame reads garbage from there on; a frame +/// it can't tell about is written as it is. A module only names an address: its span covers its +/// data too. Not reentrant (it locates frames into a static buffer); a run reports once. pub fn writeReport( w: *std.Io.Writer, run: Run, what: What, + thread: Thread, modules: []const Module, - frames: []const usize, - nameOf: ?*const fn (usize, []u8) ?[]const u8, + all_frames: []const usize, + code_at: CodeAt, ) std.Io.Writer.Error!void { + // Each frame is located once, for where the walk stops and for what is written. + const frames = all_frames[0..@min(all_frames.len, located_buf.len)]; + var shown = frames.len; + for (frames, 0..) |ret, i| { + located_buf[i] = locate(modules, ret -| 1, code_at); + if (i > 0 and located_buf[i].exec == .not_code) { + shown = i; + break; + } + } + const located = located_buf[0..shown]; try w.print("{s} crashed", .{run.app}); - for (frames) |ret| if (moduleOf(modules, ret -| 1)) |i| { + for (located) |l| if (l.module) |i| { try w.print(" in {s} {s}", .{ modules[i].name(), modules[i].version() }); break; }; switch (what) { .panic => |msg| try w.print(": panic: {s}\n", .{msg}), - .signal => |s| if (s.address) |a| - try w.print(": {s} at address 0x{x}\n", .{ s.name, a }) - else - try w.print(": {s}\n", .{s.name}), + .signal => |s| { + try w.print(": {s}", .{s.name}); + if (s.address) |a| try w.print(" at address 0x{x}", .{a}); + if (s.code) |c| try w.print(" (code {d})", .{c}); + try w.writeByte('\n'); + }, } - try w.print("\n{s} {s}, sdk {s}, {t} {t} {t}\n\nmodules:\n", .{ + try w.print("\n{s} {s}, sdk {s}, {t} {t} {t}\n", .{ run.app, run.version, run.sdk, builtin.target.os.tag, builtin.target.cpu.arch, builtin.mode, }); + try w.print("thread {d}", .{thread.id}); + if (thread.name.len > 0) try w.print(" \"{s}\"", .{thread.name}); + try w.writeByte('\n'); + if (thread.registers) |r| { + try w.print("registers: pc 0x{x} sp 0x{x} fp 0x{x}\n", .{ r.pc, r.sp, r.fp }); + switch (r.caller) { + .none => {}, + .address => |c| { + try w.writeAll("caller (by call convention): "); + try writeAddress(w, c -| 1, locate(modules, c -| 1, code_at)); + try w.writeAll(" (pc is not code)\n"); + }, + .from_return => try w.writeAll("caller: (caller unknown: pc came from a return)\n"), + } + } + try w.writeAll("\nmodules:\n"); for (modules, 0..) |*m, i| { try w.print(" {d} {s} {s} 0x{x} 0x{x} ", .{ i, m.name(), m.version(), m.image.base, m.image.end - m.image.base }); if (m.image.buildId().len == 0) try w.writeAll("-") else for (m.image.buildId()) |b| try w.print("{x:0>2}", .{b}); try w.writeByte('\n'); } try w.writeAll("frames:\n"); - var name_buf: [256]u8 = undefined; - for (frames) |ret| { - const pc = ret -| 1; - if (moduleOf(modules, pc)) |i| { - try w.print(" {s}+0x{x}\n", .{ modules[i].name(), pc - modules[i].image.base }); - } else { - try w.print(" 0x{x}", .{pc}); - if (nameOf) |f| if (f(pc, &name_buf)) |n| try w.print(" {s}", .{n}); - try w.writeByte('\n'); - } + for (located, frames[0..shown]) |l, ret| { + try w.writeAll(" "); + try writeAddress(w, ret -| 1, l); + try w.writeByte('\n'); } + if (shown < frames.len) try w.print(" (unwind stopped: 0x{x} is not code)\n", .{frames[shown] -| 1}); if (frames.len == 0) try w.writeAll(" (none: the stack could not be walked)\n"); } +var located_buf: [frame_buf.len]Located = undefined; + +/// Where an address lies: whether it is code, and the module or image naming it, if any. +const Located = struct { exec: loaded.Exec, name: []const u8 = "", offset: usize = 0, module: ?usize = null }; + +fn locate(modules: []const Module, address: usize, code_at: CodeAt) Located { + const at = code_at(address); + if (at.exec == .not_code) return .{ .exec = .not_code }; + if (moduleOf(modules, address)) |i| return .{ .exec = at.exec, .name = modules[i].name(), .offset = address - modules[i].image.base, .module = i }; + if (at.name.len == 0) return .{ .exec = at.exec }; + return .{ .exec = at.exec, .name = at.name, .offset = address -% at.base }; +} + +/// `name+0xoffset`, or the bare address, marked when it is definitely not code. +fn writeAddress(w: *std.Io.Writer, address: usize, l: Located) std.Io.Writer.Error!void { + if (l.name.len > 0) return w.print("{s}+0x{x}", .{ l.name, l.offset }); + try w.print("0x{x}", .{address}); + if (l.exec == .not_code) try w.writeAll(" (not code)"); +} + fn moduleOf(modules: []const Module, address: usize) ?usize { for (modules, 0..) |*m, i| if (m.image.contains(address)) return i; return null; @@ -145,14 +226,15 @@ var path_buf: [std.fs.max_path_bytes:0]u8 = undefined; var path_len: usize = 0; var path_w: if (builtin.target.os.tag == .windows) [std.fs.max_path_bytes:0]u16 else void = undefined; -/// Starts reporting for this run: enters the executable as module 0 and catches aborts and -/// traps. Call once, first thing. std's segfault handler is the root's to switch on +/// Starts reporting for this run: enters the executable as module 0, takes the table of +/// loaded images' names, and catches aborts and traps. Call once, first thing. std's segfault handler is the root's to switch on /// (`std_options.enable_segfault_handler`) and to route here (`root.debug.handleSegfault`). /// Until `writeTo`, a report goes to stderr only. pub fn init(run: Run) void { if (!supported) return; run_info = run; addModule(run.app, run.version, @intFromPtr(&init)); + loaded.refresh(); if (builtin.target.os.tag != .windows) { const act: std.posix.Sigaction = .{ .handler = .{ .sigaction = onAbortOrTrap }, @@ -161,13 +243,44 @@ pub fn init(run: Run) void { }; std.posix.sigaction(.ABRT, &act, null); std.posix.sigaction(.TRAP, &act, null); + // std's fault handler is told the address but not the `si_code`; note it on the way in. + for (fault_signals, &prev_fault) |sig, *prev| { + std.posix.sigaction(sig, null, prev); + // Not std's (or already ours, from an earlier `init`): leave it. + if (prev.flags & std.posix.SA.SIGINFO == 0 or prev.handler.sigaction == null or prev.handler.sigaction == &onFault) continue; + var ours = prev.*; + ours.handler = .{ .sigaction = onFault }; + std.posix.sigaction(sig, &ours, null); + } } } +const fault_signals = [_]std.posix.SIG{ .SEGV, .BUS, .ILL, .FPE }; +var prev_fault: [fault_signals.len]std.posix.Sigaction = undefined; +/// The last fault's `si_code`, tagged with its thread: the faulting thread's id in the high half, +/// the code in the low. One word, so a fault on another thread can't pair its code with ours; a +/// thread local would be cleaner, but its first touch on macOS can allocate, in a signal handler. +var fault_code: std.atomic.Value(u64) = .init(0); + +/// This thread's fault code, if `onFault` noted one. +fn faultCode() ?i32 { + const v = fault_code.load(.acquire); + if (v == 0 or @as(u32, @truncate(v >> 32)) != @as(u32, @truncate(@as(u64, @intCast(std.Thread.getCurrentId()))))) return null; + return @bitCast(@as(u32, @truncate(v))); +} + +fn onFault(sig: std.posix.SIG, info: *const std.posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void { + const code: u32 = @bitCast(@as(i32, @intCast(info.code))); + const tag: u32 = @truncate(@as(u64, @intCast(std.Thread.getCurrentId()))); + fault_code.store(@as(u64, tag) << 32 | code, .release); + for (fault_signals, prev_fault) |s, prev| if (s == sig) prev.handler.sigaction.?(sig, info, ctx_ptr); +} + /// Names this run's report file, `/crashes/-.txt`, creating the folder. /// Nothing is written there unless the run crashes. pub fn writeTo(io: std.Io, config_folder: []const u8) void { if (!supported) return; + loaded.refresh(); // the names of libraries loaded since `init` (a window's, a GPU driver's) const pid = if (builtin.target.os.tag == .windows) win.GetCurrentProcessId() else std.c.getpid(); const launched = std.Io.Timestamp.now(io, .real).toMilliseconds(); const sep = std.fs.path.sep; @@ -194,13 +307,14 @@ pub fn panic(msg: []const u8, first_trace_addr: ?usize) noreturn { /// The root's `debug.handleSegfault`: the report, then std's. pub fn handleSegfault(address: ?usize, name: []const u8, ctx: ?std.debug.CpuContextPtr) noreturn { - report(.{ .signal = .{ .name = name, .address = address } }, .{ .context = ctx }); + report(.{ .signal = .{ .name = name, .address = address, .code = if (builtin.target.os.tag == .windows) null else faultCode() } }, .{ .context = ctx }); std.debug.defaultHandleSegfault(address, name, ctx); } fn onAbortOrTrap(sig: std.posix.SIG, _: *const std.posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void { const ctx = std.debug.cpu_context.fromPosixSignalContext(ctx_ptr); const name = if (sig == .ABRT) "Abort" else "Trace/breakpoint trap"; + // No code: for a raised signal `si_code` says who sent it, not why. report(.{ .signal = .{ .name = name } }, .{ .context = if (ctx) |*c| c else null }); // RESETHAND put the default action back; raised again, it runs once this handler returns. _ = std.c.raise(sig); @@ -213,24 +327,48 @@ var report_buf: [32 * 1024]u8 = undefined; var frame_buf: [128]usize = undefined; /// One report per run: a crash while reporting, or the abort std's panic ends in, adds none. +/// Calls no loader (`dladdr` and the like take its lock, which another thread may hold): images +/// come from `loaded`'s table. std's own unwinder still asks the loader for unwind info. fn report(what: What, where: Where) void { if (reported.swap(true, .acq_rel)) return; const run = run_info orelse return; - const trace = std.debug.captureCurrentStackTrace(.{ - .first_address = where.first_address, - .context = where.context, - .allow_unsafe_unwind = true, - }, &frame_buf); - var modules: [max_modules]Module = undefined; + loaded.beginReport(); var n: usize = 0; for (&used, 0..) |*u, i| if (u.load(.acquire)) { - modules[n] = table[i]; + modules_buf[n] = table[i]; n += 1; }; + const modules = modules_buf[0..n]; + + var thread: Thread = .{ .id = @intCast(std.Thread.getCurrentId()), .name = threadName(&thread_name_buf) }; + var frames: []const usize = &.{}; + if (comptime std.debug.cpu_context.Native != noreturn) if (where.context) |ctx| if (registersOf(ctx)) |regs| { + thread.registers = regs; + // A PC in no image's code (a call through a bad pointer) has no unwind info, and std's + // frame-pointer fallback skips the caller, so walk from the caller instead. + if (loaded.at(regs.pc).exec == .not_code) if (callerOf(ctx)) |c| { + thread.registers.?.caller = c.caller; + if (c.caller == .address and loaded.at(c.caller.address -| 1).exec == .code) { + const rest = std.debug.captureCurrentStackTrace(.{ .context = &c.ctx, .allow_unsafe_unwind = true }, frame_buf[1..]); + if (rest.return_addresses.len > 0) { + // std gives a context's PC plus one: the faulting PC's, and the caller's + // context PC (its return address less one) as the return address itself. + frame_buf[0] = regs.pc +| 1; + frames = frame_buf[0 .. 1 + rest.return_addresses.len]; + } + } + }; + }; + // Otherwise, and whenever the walk from the caller found nothing, std's walk from the context. + if (frames.len == 0) frames = std.debug.captureCurrentStackTrace(.{ + .first_address = where.first_address, + .context = where.context, + .allow_unsafe_unwind = true, + }, &frame_buf).return_addresses; var w: std.Io.Writer = .fixed(&report_buf); - writeReport(&w, run, what, modules[0..n], trace.return_addresses, imageName) catch {}; + writeReport(&w, run, what, thread, modules, frames, loaded.at) catch {}; const text = w.buffered(); const path = path_buf[0..path_len]; if (path_len > 0 and writeFile(text)) { @@ -246,6 +384,80 @@ fn report(what: What, where: Where) void { } } +// Static, off the stack: on Windows a report can come from a stack overflow. +var modules_buf: [max_modules]Module = undefined; + +var thread_name_buf: [64:0]u8 = undefined; + +fn threadName(buf: *[64:0]u8) []const u8 { + switch (builtin.target.os.tag) { + .linux, .freebsd, .netbsd, .macos, .ios, .tvos, .visionos, .watchos => {}, + else => return "", + } + buf[0] = 0; + if (std.c.pthread_getname_np(std.c.pthread_self(), buf, buf.len) != 0) return ""; + return std.mem.sliceTo(buf, 0); +} + +fn registersOf(ctx: std.debug.CpuContextPtr) ?Registers { + return switch (builtin.target.cpu.arch) { + .x86_64 => .{ .pc = ctx.gprs.get(.rip), .sp = ctx.gprs.get(.rsp), .fp = ctx.gprs.get(.rbp) }, + .aarch64 => .{ .pc = ctx.pc, .sp = ctx.sp, .fp = ctx.x[29] }, + else => null, + }; +} + +const Arch = enum { x86_64, aarch64 }; + +/// How `pc` was reached, by the call convention. A call through a bad pointer left its return +/// address at `*(sp)` (x86_64's `call` pushed it) or in `lr` (aarch64's `blr`). A return through +/// a corrupt return address left `pc` itself in `lr` (aarch64's `ret` jumped to it) or just +/// below `sp` (x86_64's `ret` popped it). That word is below the stack, where anything may +/// linger, so on x86_64 it counts only when `*(sp)` is no code address a call could have left. +/// A word that is not surely code names no caller. `read` reads a stack word safely; +/// `is_code` says whether an address is surely code. +fn callerFrom(arch: Arch, pc: usize, sp: usize, lr: usize, read: *const fn (usize) ?usize, is_code: *const fn (usize) bool) Caller { + switch (arch) { + .x86_64 => { + if (read(sp)) |r| if (r > 1 and is_code(r - 1)) return .{ .address = r }; + if (read(sp -% @sizeOf(usize))) |popped| if (popped == pc) return .from_return; + return .none; + }, + .aarch64 => { + if (lr == pc) return .from_return; + return if (lr > 1 and is_code(lr - 1)) .{ .address = lr } else .none; + }, + } +} + +fn isCode(address: usize) bool { + return loaded.at(address).exec == .code; +} + +/// The faulting context's caller, and the context as it was at the return to it. +fn callerOf(ctx: std.debug.CpuContextPtr) ?struct { caller: Caller, ctx: std.debug.cpu_context.Native } { + const regs = registersOf(ctx) orelse return null; + var at = ctx.*; + switch (builtin.target.cpu.arch) { + .x86_64 => { + const caller = callerFrom(.x86_64, regs.pc, regs.sp, 0, readStackWord, isCode); + // The PC goes in as the return address less one: std takes a context's PC as exact, + // and a call that ends its function returns to the next one's first byte. + if (caller == .address) { + at.gprs.set(.rip, caller.address - 1); + at.gprs.set(.rsp, regs.sp + @sizeOf(usize)); + } + return .{ .caller = caller, .ctx = at }; + }, + .aarch64 => { + const caller = callerFrom(.aarch64, regs.pc, regs.sp, ctx.x[30], readStackWord, isCode); + if (caller == .address) at.pc = caller.address - 1; + return .{ .caller = caller, .ctx = at }; + }, + else => return null, + } +} + fn writeFile(text: []const u8) bool { if (builtin.target.os.tag == .windows) { const h = win.CreateFileW(&path_w, win.GENERIC_WRITE, 0, null, win.CREATE_ALWAYS, win.FILE_ATTRIBUTE_NORMAL, null); @@ -275,25 +487,38 @@ fn posixWriteAll(fd: std.c.fd_t, text: []const u8) bool { return true; } -/// The file name of the image holding `address`, for a frame in no known module. -fn imageName(address: usize, buf: []u8) ?[]const u8 { - if (builtin.target.os.tag == .windows) return null; - var info: Dl_info = undefined; - if (dladdr(@ptrFromInt(address), &info) == 0) return null; - const path = std.mem.span(info.dli_fname orelse return null); - const base = std.fs.path.basename(path); - const n = @min(base.len, buf.len); - @memcpy(buf[0..n], base[0..n]); - return buf[0..n]; +/// The word at `sp` on the faulting thread's stack, read so that a corrupt or pivoted `sp` +/// can't fault again inside the handler (which would end the run with no report at all): on +/// Linux through `process_vm_readv`, which fails rather than faults; on macOS and Windows only +/// when it lies within this thread's stack. Null where neither can be done. +fn readStackWord(sp: usize) ?usize { + if (sp == 0 or !std.mem.isAligned(sp, @alignOf(usize))) return null; + const lo: usize, const hi: usize = switch (builtin.target.os.tag) { + .linux => { + const linux = std.os.linux; + var word: usize = 0; + const local = [_]std.posix.iovec{.{ .base = @ptrCast(&word), .len = @sizeOf(usize) }}; + const remote = [_]std.posix.iovec_const{.{ .base = @ptrFromInt(sp), .len = @sizeOf(usize) }}; + if (linux.process_vm_readv(linux.getpid(), &local, &remote, 0) != @sizeOf(usize)) return null; + return word; + }, + .macos, .ios, .tvos, .visionos, .watchos => bounds: { + const self = std.c.pthread_self(); + const top = @intFromPtr(pthread_get_stackaddr_np(self)); + break :bounds .{ top -| pthread_get_stacksize_np(self), top }; + }, + .windows => bounds: { + const tib = &std.os.windows.teb().NtTib; + break :bounds .{ @intFromPtr(tib.StackLimit), @intFromPtr(tib.StackBase) }; + }, + else => return null, + }; + if (sp < lo or sp > hi -| @sizeOf(usize)) return null; + return @as(*const usize, @ptrFromInt(sp)).*; } -const Dl_info = extern struct { - dli_fname: ?[*:0]const u8, - dli_fbase: ?*anyopaque, - dli_sname: ?[*:0]const u8, - dli_saddr: ?*anyopaque, -}; -extern "c" fn dladdr(address: *const anyopaque, info: *Dl_info) c_int; +extern "c" fn pthread_get_stackaddr_np(thread: std.c.pthread_t) ?*anyopaque; +extern "c" fn pthread_get_stacksize_np(thread: std.c.pthread_t) usize; const win = struct { const HANDLE = *anyopaque; @@ -325,31 +550,156 @@ test { std.testing.refAllDecls(@This()); } -test "a report names the innermost frame's module, and writes each frame against its module" { +const test_run: Run = .{ .app = "fizzy", .version = "0.4.2", .sdk = "0.2.22" }; + +/// Code: fizzy's at 0x1000..0x5000, pixi's at 0x20000..0x28000, libc's at 0x400000..0x600000. +/// Not code: fizzy's data at 0x5000..0x9000, the heap at 0x7e0000..0x800000. The rest: unknown. +fn testCodeAt(address: usize) loaded.At { + if (address >= 0x1000 and address < 0x5000) return .{ .exec = .code, .name = "fizzy", .base = 0x1000 }; + if (address >= 0x20000 and address < 0x28000) return .{ .exec = .code, .name = "pixi.dylib", .base = 0x20000 }; + if (address >= 0x400000 and address < 0x600000) return .{ .exec = .code, .name = "libc.so.6", .base = 0x400000 }; + if (address >= 0x5000 and address < 0x9000) return .{ .exec = .not_code }; + if (address >= 0x7e0000 and address < 0x800000) return .{ .exec = .not_code }; + return .{ .exec = .unknown }; +} + +fn testUnknown(_: usize) loaded.At { + return .{ .exec = .unknown }; +} + +test "a report names the innermost frame's module, and writes each frame against its module or image" { const app: Module = .init("fizzy", "0.4.2", .{ .base = 0x1000, .end = 0x9000 }); var pixi: Module = .init("pixi", "0.1.40", .{ .base = 0x20000, .end = 0x28000 }); pixi.image.build_id_buf[0..2].* = .{ 0xab, 0x01 }; pixi.image.build_id_len = 2; var buf: [2048]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - const unknown = struct { - fn f(_: usize, out: []u8) ?[]const u8 { - @memcpy(out[0..9], "libc.so.6"); - return out[0..9]; - } - }.f; - try writeReport(&w, .{ .app = "fizzy", .version = "0.4.2", .sdk = "0.2.22" }, .{ .panic = "index out of bounds" }, &.{ app, pixi }, &.{ 0x500001, 0x20101, 0x1201 }, unknown); + try writeReport(&w, test_run, .{ .panic = "index out of bounds" }, .{ .id = 7, .name = "main" }, &.{ app, pixi }, &.{ 0x500001, 0x20101, 0x1201 }, testCodeAt); const text = w.buffered(); try std.testing.expect(std.mem.startsWith(u8, text, "fizzy crashed in pixi 0.1.40: panic: index out of bounds\n")); + try std.testing.expect(std.mem.indexOf(u8, text, "thread 7 \"main\"\n") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "registers:") == null); try std.testing.expect(std.mem.indexOf(u8, text, " 1 pixi 0.1.40 0x20000 0x8000 ab01\n") != null); try std.testing.expect(std.mem.indexOf(u8, text, " 0 fizzy 0.4.2 0x1000 0x8000 -\n") != null); - try std.testing.expect(std.mem.endsWith(u8, text, "frames:\n 0x500000 libc.so.6\n pixi+0x100\n fizzy+0x200\n")); + try std.testing.expect(std.mem.endsWith(u8, text, "frames:\n libc.so.6+0x100000\n pixi+0x100\n fizzy+0x200\n")); } test "a fault with no frames still says what and where" { var buf: [1024]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); - try writeReport(&w, .{ .app = "fizzy", .version = "0.4.2", .sdk = "0.2.22" }, .{ .signal = .{ .name = "Segmentation fault", .address = 0x10 } }, &.{}, &.{}, null); + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault", .address = 0x10 } }, .{}, &.{}, &.{}, testUnknown); try std.testing.expect(std.mem.startsWith(u8, w.buffered(), "fizzy crashed: Segmentation fault at address 0x10\n")); try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n (none: the stack could not be walked)\n")); } + +test "a PC that is not code is reported with its registers, code and caller, and the walk goes on from the caller" { + const app: Module = .init("fizzy", "0.4.2", .{ .base = 0x1000, .end = 0x9000 }); + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const thread: Thread = .{ .id = 42, .registers = .{ .pc = 0x7f0000, .sp = 0x9ff0, .fp = 0x10, .caller = .{ .address = 0x1301 } } }; + // The context's PC as std gives it (plus one), then the caller and its callers. + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault", .address = 0x7f0000, .code = 2 } }, thread, &.{app}, &.{ 0x7f0001, 0x1301, 0x1201 }, testCodeAt); + const text = w.buffered(); + try std.testing.expect(std.mem.startsWith(u8, text, "fizzy crashed in fizzy 0.4.2: Segmentation fault at address 0x7f0000 (code 2)\n")); + try std.testing.expect(std.mem.indexOf(u8, text, "thread 42\nregisters: pc 0x7f0000 sp 0x9ff0 fp 0x10\ncaller (by call convention): fizzy+0x300 (pc is not code)\n") != null); + try std.testing.expect(std.mem.endsWith(u8, text, "frames:\n 0x7f0000 (not code)\n fizzy+0x300\n fizzy+0x200\n")); +} + +test "the walk stops at the first frame past the PC that is not code, and at nothing unknown" { + const app: Module = .init("fizzy", "0.4.2", .{ .base = 0x1000, .end = 0x9000 }); + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + // A corrupt frame pointer: after fizzy's frame comes a heap address, then whatever it held. + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault" } }, .{}, &.{app}, &.{ 0x7f0001, 0x1201, 0x7e0001, 0x450001 }, testCodeAt); + try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n 0x7f0000 (not code)\n fizzy+0x200\n (unwind stopped: 0x7e0000 is not code)\n")); + // What can't be told about stops nothing, and is written as it is. + w = .fixed(&buf); + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault" } }, .{}, &.{app}, &.{ 0x7f0001, 0x1201, 0x7e0001 }, testUnknown); + try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n 0x7f0000\n fizzy+0x200\n 0x7e0000\n")); +} + +test "the header names a module only from frames before the walk stopped" { + const pixi: Module = .init("pixi", "0.1.40", .{ .base = 0x20000, .end = 0x28000 }); + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault" } }, .{}, &.{pixi}, &.{ 0x7f0001, 0x7e0001, 0x20101 }, testCodeAt); + try std.testing.expect(std.mem.startsWith(u8, w.buffered(), "fizzy crashed: Segmentation fault\n")); + try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n 0x7f0000 (not code)\n (unwind stopped: 0x7e0000 is not code)\n")); +} + +test "a PC that came from a return names no caller" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const thread: Thread = .{ .id = 1, .registers = .{ .pc = 0x7f0000, .sp = 0x9ff0, .fp = 0x10, .caller = .from_return } }; + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault" } }, thread, &.{}, &.{0x7f0001}, testCodeAt); + try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "registers: pc 0x7f0000 sp 0x9ff0 fp 0x10\ncaller: (caller unknown: pc came from a return)\n") != null); +} + +test "a fault code is this thread's only" { + const saved = fault_code.load(.acquire); + defer fault_code.store(saved, .release); + fault_code.store(0, .release); + try std.testing.expectEqual(null, faultCode()); + const tag: u32 = @truncate(@as(u64, @intCast(std.Thread.getCurrentId()))); + fault_code.store(@as(u64, tag) << 32 | @as(u32, @bitCast(@as(i32, -2))), .release); + try std.testing.expectEqual(-2, faultCode()); + fault_code.store(@as(u64, tag +% 1) << 32 | 2, .release); + try std.testing.expectEqual(null, faultCode()); +} + +test "a stack word is read where it is this thread's, and an unmapped one is refused" { + if (!supported) return error.SkipZigTest; + var word: usize = 0x5eed; + std.mem.doNotOptimizeAway(&word); + switch (builtin.target.os.tag) { + .linux, .macos, .windows => try std.testing.expectEqual(0x5eed, readStackWord(@intFromPtr(&word)).?), + else => {}, + } + try std.testing.expectEqual(null, readStackWord(0x1000)); + try std.testing.expectEqual(null, readStackWord(@intFromPtr(&word) + 1)); +} + +test "a module's data is not code: the walk stops there" { + const app: Module = .init("fizzy", "0.4.2", .{ .base = 0x1000, .end = 0x9000 }); + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try writeReport(&w, test_run, .{ .signal = .{ .name = "Segmentation fault" } }, .{}, &.{app}, &.{ 0x1201, 0x6001, 0x1301 }, testCodeAt); + try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n fizzy+0x200\n (unwind stopped: 0x6000 is not code)\n")); +} + +/// The stack around `test_sp`: the word just below it, and the word at it. +var test_words: [2]?usize = .{ null, null }; +const test_sp = 0x9000; + +fn testRead(address: usize) ?usize { + if (address == test_sp - @sizeOf(usize)) return test_words[0]; + if (address == test_sp) return test_words[1]; + return null; +} + +fn testIsCode(address: usize) bool { + return testCodeAt(address).exec == .code; +} + +test "a call through a bad pointer names its caller; a return through a bad address names none" { + // x86_64: `call` pushed the return address at sp; below sp lingers whatever was there. + test_words = .{ 0x55, 0x1301 }; + try std.testing.expectEqual(Caller{ .address = 0x1301 }, callerFrom(.x86_64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + // Even when that lingering word is the bad pointer itself: `*(sp)` is a return address in code. + test_words = .{ 0x7f0000, 0x1301 }; + try std.testing.expectEqual(Caller{ .address = 0x1301 }, callerFrom(.x86_64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + // x86_64: `ret` popped pc from just below sp, and `*(sp)` is no return address. + test_words = .{ 0x7f0000, 0x7e1234 }; + try std.testing.expectEqual(Caller.from_return, callerFrom(.x86_64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + // x86_64: a stack word that is not code is no caller. + test_words = .{ 0x55, 0x7e1234 }; + try std.testing.expectEqual(Caller.none, callerFrom(.x86_64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + // x86_64: a stack that can't be read names none. + test_words = .{ null, null }; + try std.testing.expectEqual(Caller.none, callerFrom(.x86_64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + // aarch64: `blr` left the return address in lr; `ret` jumped to lr. + try std.testing.expectEqual(Caller{ .address = 0x1301 }, callerFrom(.aarch64, 0x7f0000, test_sp, 0x1301, testRead, testIsCode)); + try std.testing.expectEqual(Caller.from_return, callerFrom(.aarch64, 0x7f0000, test_sp, 0x7f0000, testRead, testIsCode)); + try std.testing.expectEqual(Caller.none, callerFrom(.aarch64, 0x7f0000, test_sp, 0, testRead, testIsCode)); + try std.testing.expectEqual(Caller.none, callerFrom(.aarch64, 0x7f0000, test_sp, 0x7e1234, testRead, testIsCode)); +} diff --git a/app/crash/image.zig b/app/crash/image.zig index b99cfbed1..c4d85820d 100644 --- a/app/crash/image.zig +++ b/app/crash/image.zig @@ -42,10 +42,14 @@ pub fn containing(address: usize) ?Image { } fn macho(address: usize) ?Image { - const macho_ = std.macho; - const header: *const macho_.mach_header_64 = @ptrCast(@alignCast( + return machoAt(@ptrCast(@alignCast( std.c._dyld_get_image_header_containing_address(@ptrFromInt(address)) orelse return null, - )); + ))); +} + +/// The Mach-O image whose header is mapped at `header`: its `__TEXT` and its UUID. +pub fn machoAt(header: *const std.macho.mach_header_64) ?Image { + const macho_ = std.macho; var image: Image = .{ .base = @intFromPtr(header), .end = 0 }; var at: usize = @intFromPtr(header) + @sizeOf(macho_.mach_header_64); for (0..header.ncmds) |_| { diff --git a/app/crash/loaded.zig b/app/crash/loaded.zig new file mode 100644 index 000000000..bcb398201 --- /dev/null +++ b/app/crash/loaded.zig @@ -0,0 +1,419 @@ +//! Whether an address is code, and whose, asked from a crash handler without the loader. +//! +//! `dladdr`, `dl_iterate_phdr` and `_dyld_get_image_header_containing_address` take the loader's +//! lock; called from a crash on one thread while another holds it (a plugin's `dlopen`), they +//! deadlock and the report never comes. So `at` asks the kernel, which answers for every mapping +//! whoever made it: `/proc/self/maps` on Linux (read with raw syscalls), `VirtualQuery` on +//! Windows, `mach_vm_region_recurse` on macOS. Names come from the same place on Linux, and on +//! macOS and Windows from a table of loaded images taken outside any handler (dyld's add and +//! remove callbacks; a refresh at `crash.init` and `crash.writeTo`). +//! +//! The answer has three values, and only `not_code` is acted on: a report stops its walk there +//! and recovers a caller only from there. Whatever can't be told is `unknown`, and is written as +//! it was before any of this. +const std = @import("std"); +const builtin = @import("builtin"); +const image = @import("image.zig"); + +const os = builtin.target.os.tag; +const darwin = switch (os) { + .macos, .ios, .tvos, .visionos, .watchos => @sizeOf(usize) == 8, + else => false, +}; + +/// Whether this platform can tell code from not. Elsewhere `at` always says `unknown`. +pub const supported = builtin.target.cpu.arch != .wasm32 and (darwin or os == .linux or os == .windows); + +pub const Exec = enum { code, not_code, unknown }; + +/// What an address is. `name` and `base` name the image (or mapping) holding code, when known. +pub const At = struct { exec: Exec, name: []const u8 = "", base: usize = 0 }; + +/// Whether `address` is code, and whose. Calls no loader: safe in a crash handler, once +/// `beginReport` has run on Linux. +pub fn at(address: usize) At { + if (!supported) return .{ .exec = .unknown }; + switch (os) { + .linux => return mapsAt(mappings[0..mappings_len], maps_buf[0..maps_len], maps_complete, address), + .windows => { + var mbi: win.MEMORY_BASIC_INFORMATION = undefined; + if (win.VirtualQuery(@ptrFromInt(address), &mbi, @sizeOf(win.MEMORY_BASIC_INFORMATION)) == 0) return .{ .exec = .unknown }; + if (mbi.State != win.MEM_COMMIT or mbi.Protect & win.PAGE_EXECUTE_ANY == 0) return .{ .exec = .not_code }; + // The image's base is the allocation's; the table, taken earlier, may name an image + // since unloaded, so it names only one that starts there. + const base = @intFromPtr(mbi.AllocationBase); + if (entryHolding(address)) |e| if (e.base == base) return .{ .exec = .code, .name = e.name(), .base = base }; + return .{ .exec = .code, .name = "?", .base = base }; + }, + else => { + // dyld's table is kept by its own callbacks, so what it holds is mapped code. + if (entryHolding(address)) |e| return .{ .exec = .code, .name = e.name(), .base = e.base }; + return .{ .exec = machExec(address) }; + }, + } +} + +/// Before a report's first `at`: on Linux, reads `/proc/self/maps` (open and read are +/// async-signal-safe) and parses it once. Nothing elsewhere. +pub fn beginReport() void { + if (os != .linux) return; + const linux = std.os.linux; + maps_len = 0; + mappings_len = 0; + maps_complete = false; + const rc = linux.open("/proc/self/maps", .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0); + if (linux.errno(rc) != .SUCCESS) return; + const fd: i32 = @intCast(rc); + defer _ = linux.close(fd); + var whole = false; + while (maps_len < maps_buf.len) { + const n = linux.read(fd, maps_buf[maps_len..].ptr, maps_buf.len - maps_len); + if (linux.errno(n) != .SUCCESS) return; + if (n == 0) { + whole = true; + break; + } + maps_len += n; + } + const parsed = parseMaps(maps_buf[0..maps_len], &mappings); + mappings_len = parsed.len; + maps_complete = whole and parsed.all; +} + +var maps_buf: [if (os == .linux) 1 << 19 else 0]u8 = undefined; +var maps_len: usize = 0; +var mappings: [if (os == .linux) 8192 else 0]Mapping = undefined; +var mappings_len: usize = 0; +var maps_complete = false; + +/// A line of `/proc/self/maps`: its range, whether it is code, the base of the file it maps, +/// and that file's name as a range of the text. +const Mapping = struct { lo: usize, hi: usize, base: usize, exec: bool, name_at: u32, name_len: u32 }; + +/// Parses `/proc/self/maps` text into `out`, in the kernel's order (by address). The base of a +/// file's mapping is the start of that file's first mapping (offset 0): the load bias of an +/// object whose first segment is at address 0, though its code may sit at another offset from +/// its file offset than 0 (lld puts it a page on). `all` is false when `out` was too short. +fn parseMaps(text: []const u8, out: []Mapping) struct { len: usize, all: bool } { + var n: usize = 0; + var file_key: []const u8 = ""; + var file_base: usize = 0; + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + // start-end perms offset dev inode [path] + var fields = std.mem.tokenizeScalar(u8, line, ' '); + const range = fields.next() orelse continue; + const perms = fields.next() orelse continue; + const offset = std.fmt.parseInt(usize, fields.next() orelse continue, 16) catch continue; + const dev = fields.next() orelse continue; + const inode = fields.next() orelse continue; + const dash = std.mem.indexOfScalar(u8, range, '-') orelse continue; + const lo = std.fmt.parseInt(usize, range[0..dash], 16) catch continue; + const hi = std.fmt.parseInt(usize, range[dash + 1 ..], 16) catch continue; + var path = std.mem.trim(u8, fields.rest(), " "); + if (std.mem.endsWith(u8, path, " (deleted)")) path = path[0 .. path.len - " (deleted)".len]; + const name = std.fs.path.basename(path); + // dev and inode together name the file; inode 0 is no file. + const key = line[@intFromPtr(dev.ptr) - @intFromPtr(line.ptr) .. @intFromPtr(inode.ptr) - @intFromPtr(line.ptr) + inode.len]; + if (std.mem.eql(u8, inode, "0")) { + file_key = ""; + file_base = lo; + } else if (!std.mem.eql(u8, key, file_key)) { + file_key = key; + file_base = if (offset == 0) lo else lo -| offset; + } + if (n == out.len) return .{ .len = n, .all = false }; + out[n] = .{ + .lo = lo, + .hi = hi, + .base = file_base, + .exec = perms.len >= 3 and perms[2] == 'x', + .name_at = if (name.len == 0) 0 else @intCast(@intFromPtr(name.ptr) - @intFromPtr(text.ptr)), + .name_len = @intCast(name.len), + }; + n += 1; + } + return .{ .len = n, .all = true }; +} + +/// `address` among parsed mappings: code if an executable mapping holds it, not code if another +/// does or (the list being whole) none does. +fn mapsAt(maps: []const Mapping, text: []const u8, complete: bool, address: usize) At { + // The last mapping starting at or below `address`. + var lo: usize = 0; + var hi: usize = maps.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (maps[mid].lo <= address) lo = mid + 1 else hi = mid; + } + if (lo > 0 and address < maps[lo - 1].hi) { + const m = maps[lo - 1]; + if (!m.exec) return .{ .exec = .not_code }; + return .{ .exec = .code, .name = text[m.name_at..][0..m.name_len], .base = m.base }; + } + return .{ .exec = if (complete) .not_code else .unknown }; +} + +/// The protection of the region holding `address`, descending into the shared cache's submaps. +fn machExec(address: usize) Exec { + const KERN_INVALID_ADDRESS = 1; + var depth: u32 = 0; + for (0..8) |_| { + var addr: u64 = address; + var size: u64 = 0; + // vm_region_submap_info_64 is #pragma pack(4) in C: protection is word 0, is_submap word 12. + var info: [32]u32 = undefined; + var count: u32 = 19; // VM_REGION_SUBMAP_INFO_COUNT_64 + const kr = mach_vm_region_recurse(std.c.mach_task_self(), &addr, &size, &depth, &info, &count); + if (kr == KERN_INVALID_ADDRESS) return .not_code; // no region at or above it + if (kr != 0) return .unknown; + if (addr > address) return .not_code; // it lies in a hole below the next region + if (info[12] == 0) return if (info[0] & 4 != 0) .code else .not_code; // VM_PROT_EXECUTE + depth += 1; + } + return .unknown; +} + +extern "c" fn mach_vm_region_recurse(task: std.c.mach_port_t, address: *u64, size: *u64, depth: *u32, info: *[32]u32, count: *u32) c_int; + +// The table of loaded images, on macOS and Windows: names, and on macOS code ranges. + +pub const Entry = struct { + base: usize = 0, + end: usize = 0, + name_buf: [64]u8 = undefined, + name_len: u8 = 0, + + pub fn name(e: *const Entry) []const u8 { + return e.name_buf[0..e.name_len]; + } + + fn setName(e: *Entry, path: []const u8) void { + const base = std.fs.path.basename(path); + e.name_len = @intCast(@min(base.len, e.name_buf.len)); + @memcpy(e.name_buf[0..e.name_len], base[0..e.name_len]); + } +}; + +const max_images = 1024; +var table: [max_images]Entry = undefined; +var used: [max_images]std.atomic.Value(bool) = @splat(.init(false)); +/// Held by whoever writes the table (dyld's callbacks run on any thread). Readers never take it: +/// a slot is written whole before it is marked used, and marked unused before it is rewritten. +var writing: std.atomic.Value(bool) = .init(false); +/// Set when an image found the table full; `refresh` says so once. +var overflowed: std.atomic.Value(bool) = .init(false); + +fn lock() void { + while (writing.cmpxchgWeak(false, true, .acquire, .monotonic) != null) std.atomic.spinLoopHint(); +} + +fn entryHolding(address: usize) ?*const Entry { + for (&used, 0..) |*u, i| if (u.load(.acquire) and address >= table[i].base and address < table[i].end) return &table[i]; + return null; +} + +/// Under the lock: enters `e`, replacing what was at its base. A slot in use is never written: +/// `e` goes into a free one, and only then is the old one let go, so a crash reading the table +/// meanwhile sees one whole entry or the other. Free slots are taken round-robin, so one just +/// let go is the last to be reused. +fn put(e: *const Entry) ?usize { + var old: ?usize = null; + for (&used, 0..) |*u, i| if (u.load(.acquire) and table[i].base == e.base) { + if (std.meta.eql(table[i], e.*)) return i; + old = i; + break; + }; + for (0..max_images) |k| { + const i = (next_slot + k) % max_images; + if (used[i].load(.acquire)) continue; + table[i] = e.*; + used[i].store(true, .release); + next_slot = (i + 1) % max_images; + if (old) |o| used[o].store(false, .release); + return i; + } + overflowed.store(true, .release); + return old; +} + +var next_slot: usize = 0; + +/// Takes the table from the loader. Calls the loader, so never from a crash handler. On macOS the +/// first call registers dyld's callbacks, which keep it from then on. +pub fn refresh() void { + if (!supported or os == .linux) return; + if (darwin) { + if (!registered.swap(true, .acq_rel)) { + _dyld_register_func_for_add_image(onAdd); + _dyld_register_func_for_remove_image(onRemove); + // dyld itself is in no image list; it is where every thread's stack starts. + if (dyldHeader()) |h| addMacho(h, "dyld"); + } + } else { + lock(); + defer writing.store(false, .release); + seen = @splat(false); + enumerateWindows(); + for (&used, seen) |*u, s| if (!s) u.store(false, .release); + } + if (overflowed.swap(false, .acq_rel)) + std.log.warn("crash reports: more than {d} images loaded; the rest are named by address only", .{max_images}); +} + +var registered: std.atomic.Value(bool) = .init(false); +extern "c" fn _dyld_register_func_for_add_image(f: *const fn (*const anyopaque, isize) callconv(.c) void) void; +extern "c" fn _dyld_register_func_for_remove_image(f: *const fn (*const anyopaque, isize) callconv(.c) void) void; + +fn onAdd(header: *const anyopaque, _: isize) callconv(.c) void { + addMacho(header, "?"); +} + +/// Enters the Mach-O image at `header`, named by its file, or `unnamed` when dyld has no path. +fn addMacho(header: *const anyopaque, unnamed: []const u8) void { + const img = image.machoAt(@ptrCast(@alignCast(header))) orelse return; + var e: Entry = .{ .base = img.base, .end = img.end }; + var info: Dl_info = undefined; + if (dladdr(header, &info) != 0) if (info.dli_fname) |f| e.setName(std.mem.span(f)); + if (e.name_len == 0) e.setName(unnamed); + lock(); + defer writing.store(false, .release); + _ = put(&e); +} + +fn onRemove(header: *const anyopaque, _: isize) callconv(.c) void { + lock(); + defer writing.store(false, .release); + for (&used, 0..) |*u, i| if (u.load(.acquire) and table[i].base == @intFromPtr(header)) u.store(false, .release); +} + +/// dyld's own header, from the task's `dyld_all_image_infos` (`dyldImageLoadAddress`), on +/// 64-bit targets. +fn dyldHeader() ?*const anyopaque { + const TASK_DYLD_INFO = 17; + var info: extern struct { addr: u64, size: u64, format: i32 } = undefined; + var count: std.c.mach_msg_type_number_t = @sizeOf(@TypeOf(info)) / @sizeOf(std.c.natural_t); + if (std.c.task_info(std.c.mach_task_self(), TASK_DYLD_INFO, @ptrCast(&info), &count) != 0 or info.addr == 0) return null; + // version, infoArrayCount, infoArray, notification, two bools, then dyldImageLoadAddress. + return @as(*const ?*const anyopaque, @ptrFromInt(info.addr + 32)).*; +} + +const Dl_info = extern struct { + dli_fname: ?[*:0]const u8, + dli_fbase: ?*anyopaque, + dli_sname: ?[*:0]const u8, + dli_saddr: ?*anyopaque, +}; +extern "c" fn dladdr(address: *const anyopaque, info: *Dl_info) c_int; + +const win = struct { + const HANDLE = *anyopaque; + const MEM_COMMIT = 0x1000; + /// PAGE_EXECUTE, _READ, _READWRITE, _WRITECOPY. + const PAGE_EXECUTE_ANY = 0x10 | 0x20 | 0x40 | 0x80; + const MEMORY_BASIC_INFORMATION = extern struct { + BaseAddress: ?*anyopaque, + AllocationBase: ?*anyopaque, + AllocationProtect: u32, + PartitionId: u16, + RegionSize: usize, + State: u32, + Protect: u32, + Type: u32, + }; + extern "kernel32" fn VirtualQuery(address: ?*const anyopaque, info: *MEMORY_BASIC_INFORMATION, len: usize) callconv(.winapi) usize; + extern "kernel32" fn GetCurrentProcess() callconv(.winapi) HANDLE; + extern "kernel32" fn K32EnumProcessModules(process: HANDLE, modules: [*]?HANDLE, bytes: u32, needed: *u32) callconv(.winapi) i32; + extern "kernel32" fn GetModuleFileNameW(module: ?HANDLE, name: [*]u16, len: u32) callconv(.winapi) u32; +}; + +// Under the lock, during `refresh`: kept static, off the stack. +var seen: [max_images]bool = @splat(false); +var modules_w: [max_images]?win.HANDLE = undefined; +var path_w: [512]u16 = undefined; +var path_8: [512 * 3]u8 = undefined; + +fn enumerateWindows() void { + if (os != .windows) return; + var needed: u32 = 0; + if (win.K32EnumProcessModules(win.GetCurrentProcess(), &modules_w, @sizeOf(@TypeOf(modules_w)), &needed) == 0) return; + const n = @min(needed / @sizeOf(?win.HANDLE), modules_w.len); + for (modules_w[0..n]) |m| { + const img = image.containing(@intFromPtr(m orelse continue)) orelse continue; + var e: Entry = .{ .base = img.base, .end = img.end }; + const len = win.GetModuleFileNameW(m, &path_w, path_w.len); + e.setName(if (len == 0 or len >= path_w.len) "?" else path_8[0..std.unicode.wtf16LeToWtf8(&path_8, path_w[0..len])]); + if (put(&e)) |i| seen[i] = true; + } +} + +test "this code is code, by its image's name; the heap and data are not" { + if (!supported) return error.SkipZigTest; + refresh(); + beginReport(); + const here = @intFromPtr(&refresh); + const code = at(here); + try std.testing.expectEqual(Exec.code, code.exec); + try std.testing.expect(code.base <= here and code.name.len > 0); + // A system library, reached through the dyld shared cache on macOS. + try std.testing.expectEqual(Exec.code, at(@intFromPtr(&std.c.write)).exec); + try std.testing.expectEqual(Exec.not_code, at(@intFromPtr(&table)).exec); + const heap = try std.testing.allocator.create(usize); + defer std.testing.allocator.destroy(heap); + try std.testing.expectEqual(Exec.not_code, at(@intFromPtr(heap)).exec); + try std.testing.expectEqual(Exec.not_code, at(0x1000).exec); + if (darwin) { // the kernel's answer alone, as for an image the table doesn't hold + try std.testing.expectEqual(Exec.code, machExec(here)); + try std.testing.expectEqual(Exec.code, machExec(@intFromPtr(&std.c.write))); + try std.testing.expectEqual(Exec.not_code, machExec(@intFromPtr(heap))); + } +} + +test "maps say what is code, and name each file from its first mapping" { + // lld's layout: the code a page past its file offset; a deleted file; the heap. + const text = + \\55d000000000-55d000001000 r--p 00000000 fd:01 42 /usr/bin/fizzy + \\55d000002000-55d000009000 r-xp 00001000 fd:01 42 /usr/bin/fizzy + \\55d00000a000-55d00000b000 rw-p 00008000 fd:01 42 /usr/bin/fizzy + \\7f0000000000-7f0000021000 rw-p 00000000 00:00 0 + \\7f1000000000-7f1000028000 r--p 00000000 fd:01 7 /usr/lib/libc.so.6 + \\7f1000028000-7f1000180000 r-xp 00028000 fd:01 7 /usr/lib/libc.so.6 + \\7f2000000000-7f2000001000 r-xp 00000000 fd:01 9 /tmp/plugin.so (deleted) + \\ + ; + var maps: [16]Mapping = undefined; + const parsed = parseMaps(text, &maps); + try std.testing.expect(parsed.all); + const m = maps[0..parsed.len]; + const fizzy = mapsAt(m, text, true, 0x55d000002200); + try std.testing.expectEqual(Exec.code, fizzy.exec); + try std.testing.expectEqualStrings("fizzy", fizzy.name); + try std.testing.expectEqual(0x55d000000000, fizzy.base); + const libc = mapsAt(m, text, true, 0x7f1000028100); + try std.testing.expectEqualStrings("libc.so.6", libc.name); + try std.testing.expectEqual(0x7f1000000000, libc.base); + try std.testing.expectEqualStrings("plugin.so", mapsAt(m, text, true, 0x7f2000000010).name); + try std.testing.expectEqual(Exec.not_code, mapsAt(m, text, true, 0x7f0000000010).exec); // the heap + try std.testing.expectEqual(Exec.not_code, mapsAt(m, text, true, 0x55d00000a010).exec); // fizzy's data + try std.testing.expectEqual(Exec.not_code, mapsAt(m, text, true, 0x55d000001010).exec); // a gap + try std.testing.expectEqual(Exec.not_code, mapsAt(m, text, true, 0x1000).exec); // below them all + try std.testing.expectEqual(Exec.unknown, mapsAt(m, text, false, 0x1000).exec); // or not read whole + // Too many lines for the list: the rest is not known. + try std.testing.expect(!parseMaps(text, maps[0..3]).all); +} + +test "replacing an entry leaves the old one whole until the new one is in" { + lock(); + defer writing.store(false, .release); + var a: Entry = .{ .base = 0x9000_0000, .end = 0x9000_1000 }; + a.setName("old"); + const i = put(&a).?; + var b = a; + b.setName("new"); + const j = put(&b).?; + try std.testing.expect(i != j); + try std.testing.expect(!used[i].load(.acquire) and used[j].load(.acquire)); + try std.testing.expectEqualStrings("old", table[i].name()); + try std.testing.expectEqualStrings("new", entryHolding(0x9000_0010).?.name()); + used[j].store(false, .release); +} From ef86a081c9e8c87508946639125a9e9700ea65e6 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:51:37 -0500 Subject: [PATCH 10/31] linux: opening a large folder no longer hangs watching every directory (#534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #516. **Stacked on #543**, which holds the std-only folder walker (`folder_events.Watches`, `Doorbell`) and its tests. This PR wires it in. Its base is `app/folder-walker`; it retargets to `main` once #543 merges. ## What changes Opening a large folder on Linux no longer hangs the editor or fills stderr with `NOSPC`. That covers a home folder, and a tree with `node_modules` or a package cache. **Why it hung.** `FolderWatcher` called nightwatch's `watch(folder)`. On inotify that adds one watch per directory, recursing into every subdirectory with no skip list and no cap. It ran synchronously on the UI thread, both from `setProjectFolder` and at startup. After the kernel runs out of watches, it logs twice per directory and keeps opening every directory left. nightwatch's handler wrapper does the same on the reader thread for every directory created later. **What it does now.** nightwatch is upstream (`neurocyte/nightwatch`), not a fork of ours, so the fix is on fizzy's side. `FolderWatcher` drives nightwatch's `Backend` directly (a public declaration on `nightwatch.Default`). Which folders to watch is left to `Watches` from #543. ### The walker thread - One walker thread per open folder does the first walk of the root. After that it works through folder events: appeared, gone, moved. - **Reader thread stays fast.** nightwatch's reader thread allocates each event outside the lock, only links it in under the lock, and rings the `Doorbell`. It never waits on the walker. - **Allocator.** The app's allocator is `std.heap.DebugAllocator(.{})`, which is thread-safe on native. - **Stopping can't hang.** The walker answers the doorbell before it checks for cancel, so a stop rung at any point is seen. `stopWatch` rings, joins, tears down the backend, then frees what is still queued. - **Retrying on a timer.** While something is left out that time can help with, the walker also wakes on a timer and retries: 5 s, doubling to 60 s, and reset once caught up. That covers a root that couldn't be watched at all, and watches freed by another program. The cap is not retried on a timer; only a folder in this tree going frees room under it. - **Out of memory** anywhere in the walker is logged once (`degraded`) and turns `active` off for the folder's session. A watch whose key can't be kept is removed. - **Poll failure.** If the doorbell can't poll, the walker logs once and stops cleanly, and `active` turns false. It doesn't spin. ### Why a watch was refused nightwatch's `add_watch` reports only `WatchFailed`. The walker therefore keeps a scratch inotify instance (`inotify_init1(IN_CLOEXEC|IN_NONBLOCK)`). When a folder is refused, it calls `inotify_add_watch(path, IN_ONLYDIR)` on that instance, then `inotify_rm_watch`, to get the real errno: | errno | treated as | what happens | |---|---|---| | `ENOSPC`, `EMFILE`, `ENOMEM` | the pool is out | The walk stops. One line says the system is out of inotify watches and names `fs.inotify.max_user_watches`. Said once per stretch of being behind, including when the root itself is refused. | | `ENOENT`, `ENOTDIR` | a race | Nothing said. | | success | room again | The watch is tried once more. | | anything else (`EACCES`, `EINVAL` from a FUSE mount, …) | that folder's own problem | It is left out, with one line naming it and the errno. | No more guessing from refusal patterns. ### What plugins see - **New folders' contents.** What a new folder held before its watch landed (an extract, a clone) is reported as `.created` through the event ring. A folder moved in from outside the tree is not reported a second time: inotify reports it with everything in it, and the reader spots that from the depth-first `created` events following the folder's own. A folder created while the root's first walk was running, and reached by it, gets its contents reported once its own event is handled. - **`Host.folderWatchActive`** is `whole && !dropped`. `whole` comes from the walker: true once the first walk finished and nothing is left out, waiting or given up. `dropped` is set once a folder event couldn't be queued for want of memory. The only consumer I found is atlas's index sweeper, which re-reads it each sweep to pick a rescan interval, so true→false just shortens the interval. `FolderWatcher`'s doc comment says which folders are never watched. - Backends that watch a subtree with one watch (FSEvents on macOS, `ReadDirectoryChangesW` on Windows) add the root only, as before. ### Also from the issue - `setProjectFolder` resolves a relative path against the cwd, on native builds, and only for a path that is neither a mount nor absolute. A path that can't be found gets "… can't be found." A path that isn't a folder gets "… is not a folder." and `error.NotADirectory`. - Opening a recent folder (the menu, and macOS's native Recent Folders) goes through `Editor.openRecentFolder`. - A recent that exists but is no longer a folder (`NotADirectory`, `NotDir`) is dropped. - One that can't be found stays, since an unplugged drive can come back. - Errors are not passed on, so they no longer cut short the menu draw. - The Open Folder dialog's start directory is unchanged. ## SDK impact - [x] None ## Verified All gates were run on this branch, and separately on #543 alone. - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test` (99/99 steps; 609/610 tests, 1 skipped), `zig build check-tests-reached`, `zig build check-web`. - The walker's logic is covered by #543's tests, including the refusal classification through an injected `why`. - macOS itself watches through FSEvents, where only how the backend is created changed. That path builds but was not exercised at runtime. - [x] Windows: `zig build check -Dtarget=x86_64-windows-gnu` and `zig build -Dtarget=x86_64-windows-gnu` compile. Neither was run. - [x] Linux: - `zig build -Dtarget=x86_64-linux-gnu` cross-compiles the inotify path, the errno probe and the timer. - Not run on Linux. The probe and the reader-side move-in detection are covered by CI and the logic tests only, and were not tried against a real `max_user_watches` limit. - [x] Web: `zig build check-web`. About 360 lines of this diff are outside tests. #543 is about 470. ## Follow-ups - **nightwatch's `remove_watch` is linear.** It scans its whole watch map for each removal, under the mutex the reader thread also takes. So `rm -rf` of a large watched tree costs O(k·n) inside nightwatch, while fizzy's own bookkeeping is O(log n + k). The fix belongs upstream: a path→wd index. - **A folder renamed over an empty watched one.** inotify follows the move with `IN_DELETE_SELF` for the replaced folder. nightwatch reports that by path, which can drop the moved folder's watch. `Watches` keeps one key, but the backend's record is nightwatch's to fix. - **Move-in detection can miss.** The check looks only at the newest queued event. If the walker takes the batch between a moved-in folder's event and its first child, that folder's contents are reported twice. Duplicates are harmless to consumers. - **`.gitignore` doesn't decide what is watched.** - **`$HOME` and `/` are not refused.** They are bounded by the skip list and the cap. - **A folder left out for its own reasons** keeps `folderWatchActive` false for the session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/watch/FolderWatcher.zig | 311 ++++++++++++++++++++++++++++++++++-- src/editor/Editor.zig | 75 +++++++-- src/editor/Menu.zig | 3 +- 3 files changed, 357 insertions(+), 32 deletions(-) diff --git a/app/watch/FolderWatcher.zig b/app/watch/FolderWatcher.zig index 00e7c9e7f..2e692d5b3 100644 --- a/app/watch/FolderWatcher.zig +++ b/app/watch/FolderWatcher.zig @@ -22,6 +22,13 @@ //! `fizzy.files.changed`, so it can be swapped, forked, or replaced with per-platform code //! without any plugin noticing. //! +//! Not every folder is watched. Where the backend watches folder by folder (inotify), hidden +//! folders (`.git`, `.zig-cache`, …), `node_modules` and `zig-pkg` never are +//! (`folder_events.skipWatchDir`), and a tree past `folder_events.max_dir_watches`, or past +//! what the OS allows, is watched only in part. Elsewhere events from hidden folders are dropped +//! anyway (`record`). `active` — `Host.folderWatchActive` — is true only while the tree is +//! covered, so a plugin knows when to keep its own rescan. +//! //! `have_impl` is false on wasm and any unsupported OS — the watcher is simply not started //! there, and `Host.folderWatchActive` reports false so a plugin knows to keep its own slow //! rescan (same degrade-gracefully spirit as `SettingsWatcher` / `DocumentWatcher`). @@ -101,19 +108,123 @@ const Impl = if (have_impl) struct { /// option), which watches the subtree from a single stream; the kqueue fallback would want /// a file descriptor per directory *and* per file, and a project folder is exactly the /// shape that exhausts the fd limit. + /// + /// Its `Backend` is driven directly rather than through `Default.watch`. Where a backend + /// watches one directory at a time (inotify), `watch` and its handler wrapper add every + /// directory under the folder and every one that appears later, with nothing skipped and + /// nothing capped, on whichever thread asked, and go on after the OS has said no to each + /// remaining one. Opening a home folder hung the app that way (#516). Here the folders + /// watched are `folder_events.Watches`: it skips what nobody edits, counts the watches + /// live against a cap, stops a walk when the OS runs out and goes on from there once + /// watches are freed, and is kept by a walker thread of its own, which the backend's + /// thread hands folder events to without waiting. const Watcher = nightwatch.Default; + const Backend = Watcher.Backend; const Handler = Watcher.Handler; handler: Handler = .{ .vtable = &vtable }, - nw: ?Watcher = null, + /// Heap-allocated: a backend's thread (and FSEvents' callback context) keep pointers into + /// it, so it must not move with `FolderWatcher`. + backend: ?*Backend = null, /// Set in `startWatch` once `FolderWatcher` is at its final address. owner: ?*FolderWatcher = null, + /// The walker: keeps the folders watched, where the backend watches folder by folder. + walker: ?std.Thread = null, + /// Folder events for the walker from the backend's thread, newest first. Each is + /// allocated before the lock, which only links it in. + ops: ?*Op = null, + ops_lock: Spin = .{}, + /// Rung for a new op and for the stop; safe from the backend's thread. + bell: ?folder_events.Doorbell = null, + /// The folder is being closed: the walker stops at its next folder. + cancel: std.atomic.Value(bool) = .init(false), + /// Every folder under the root is watched (`Watches.whole`), as of the walker's last batch. + /// False during the first walk and while anything is left out. + whole: std.atomic.Value(bool) = .init(false), + /// A folder event was dropped for want of memory, so what is watched may have drifted + /// from the tree. Sticky for the folder. + dropped: std.atomic.Value(bool) = .init(false), + + const Op = struct { + next: ?*Op = null, + kind: enum { appeared, gone, moved }, + path: []u8, + /// Where a moved folder was. + old: []u8 = &.{}, + /// An appeared folder whose contents the backend has already reported: inotify reports + /// a folder moved in from outside the tree with everything in it. Set by the reader, + /// under `ops_lock`. + announced: bool = false, + }; + const vtable = Handler.VTable{ .change = onChange, .rename = onRename, }; + /// The backend, and the event ring, as `Watches` sees them. + const Ctx = struct { + backend: *Backend, + gpa: Allocator, + folder: []const u8, + owner: *FolderWatcher, + /// A scratch inotify instance for `why`, or -1. + probe: i32, + + /// The backend says only that a watch failed. Asking the kernel again, on a scratch + /// instance, gets the errno: ENOSPC is the pool, anything else that folder. + pub fn why(c: Ctx, path: []const u8) folder_events.Refusal { + // No other backend here watches folder by folder (macOS uses FSEvents): if one + // does, the pool is the common cause and a retry later the safe answer. + if (comptime builtin.target.os.tag != .linux) return .pool; + const linux = std.os.linux; + if (c.probe < 0) return .pool; + var buf: [std.fs.max_path_bytes + 1]u8 = undefined; + if (path.len >= buf.len) return .{ .own = "NAMETOOLONG" }; + @memcpy(buf[0..path.len], path); + buf[path.len] = 0; + const rc = linux.inotify_add_watch(c.probe, buf[0..path.len :0], linux.IN.ONLYDIR); + return switch (linux.errno(rc)) { + .SUCCESS => blk: { + _ = linux.inotify_rm_watch(c.probe, @intCast(rc)); + break :blk .again; + }, + .NOSPC, .MFILE, .NOMEM => .pool, + .NOENT, .NOTDIR => .gone, + else => |e| .{ .own = @tagName(e) }, + }; + } + + /// Found in a folder as its watch landed, so it had no event of its own. + pub fn created(c: Ctx, path: []const u8, is_dir: bool) void { + push(c.owner, path, "", .created, if (is_dir) .dir else .file); + } + + pub fn add(c: Ctx, path: []const u8) !void { + try c.backend.add_watch(c.gpa, path); + } + + pub fn remove(c: Ctx, path: []const u8) void { + const r = c.backend.remove_watch(c.gpa, path); + if (@typeInfo(@TypeOf(r)) == .error_union) r catch {}; + } + + pub fn say(c: Ctx, said: folder_events.Said) void { + switch (said) { + .skipped => |k| dvui.log.warn("folder watcher: can't watch {s} ({s}); changes in it won't be reported", .{ k.path, k.reason }), + .degraded => dvui.log.warn("folder watcher: out of memory; changes under {s} may not all be reported until it is opened again", .{c.folder}), + .blocked => |b| switch (b.why) { + .cap => dvui.log.warn("folder watcher: {d} folders under {s} are watched, the most one folder may be; changes in the rest won't be reported", .{ b.watched, c.folder }), + .refused => if (comptime builtin.target.os.tag == .linux) + dvui.log.warn("folder watcher: the system is out of inotify watches after {d} folders under {s} (raise fs.inotify.max_user_watches); the rest are watched as watches are freed", .{ b.watched, c.folder }) + else + dvui.log.warn("folder watcher: the system refused more watches after {d} folders under {s}; the rest are watched as watches are freed", .{ b.watched, c.folder }), + }, + } + } + }; + fn kindOf(ev: nightwatch.EventType) PathEvent.Kind { return switch (ev) { .created => .created, @@ -130,6 +241,123 @@ const Impl = if (have_impl) struct { }; } + /// The walker thread: watches the folder, then follows the folder events the backend + /// hands over, until the folder is closed. While something is left out that time may + /// help (watches freed by another program), it also tries again on a timer, backing off. + fn walk(self: *FolderWatcher) void { + const impl = &self.impl; + const folder = self.folder orelse return; + if (impl.bell == null) return; + const bell = &impl.bell.?; + var watches: folder_events.Watches = .{ .gpa = self.gpa, .root = folder, .cancel = &impl.cancel }; + defer watches.deinit(); + const probe: i32 = if (comptime builtin.target.os.tag == .linux) probe: { + const linux = std.os.linux; + const rc = linux.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK); + break :probe if (linux.errno(rc) == .SUCCESS) @intCast(rc) else -1; + } else -1; + defer if (probe >= 0) std.Io.Threaded.closeFd(probe); + const ctx: Ctx = .{ .backend = impl.backend orelse return, .gpa = self.gpa, .folder = folder, .owner = self, .probe = probe }; + watches.walk(dvui.io, ctx); + impl.whole.store(watches.whole(), .release); + + const first_retry_ms: i32 = 5 * std.time.ms_per_s; + var retry_ms = first_retry_ms; + while (true) { + const rung = bell.wait(if (watches.waiting()) retry_ms else -1) catch { + // Nothing to wait on: stop following the tree rather than spin. `active` + // turns false, so plugins rescan. + dvui.log.warn("folder watcher: can't wait for folder events; changes under {s} won't be reported", .{folder}); + impl.whole.store(false, .release); + return; + }; + // Answer, then look: a stop rung after this is a new ring, one rung before it is + // seen below. Looking first could lose it and block here for ever. + bell.answer(); + if (impl.cancel.load(.acquire)) return; + if (!rung) { + watches.retry(); + retry_ms = @min(retry_ms * 2, 60 * std.time.ms_per_s); + } + const batch = takeOps(impl); + defer freeOps(self.gpa, batch); + var it = batch; + while (it) |op| : (it = op.next) { + if (impl.cancel.load(.acquire)) return; + switch (op.kind) { + .appeared => watches.appeared(dvui.io, op.path, op.announced, ctx), + .gone => watches.gone(op.path, ctx), + .moved => watches.moved(dvui.io, op.old, op.path, ctx), + } + } + // What arrived during the first walk has now been handled. + watches.settled = true; + watches.catchUp(dvui.io, ctx); + if (!watches.waiting()) retry_ms = first_retry_ms; + impl.whole.store(watches.whole(), .release); + } + } + + /// Everything queued, oldest first. + fn takeOps(impl: *Impl) ?*Op { + impl.ops_lock.lock(); + var newest = impl.ops; + impl.ops = null; + impl.ops_lock.unlock(); + var oldest: ?*Op = null; + while (newest) |op| { + newest = op.next; + op.next = oldest; + oldest = op; + } + return oldest; + } + + fn freeOps(gpa: Allocator, first: ?*Op) void { + var it = first; + while (it) |op| { + it = op.next; + gpa.free(op.path); + gpa.free(op.old); + gpa.destroy(op); + } + } + + /// Hand a folder event to the walker. Never waits on it: this is the backend's reader + /// thread, and a reader that falls behind loses events (inotify's queue overflows). + fn queueFolder(self: *FolderWatcher, kind: @FieldType(Op, "kind"), path: []const u8, old: []const u8) void { + const impl = &self.impl; + const op = self.gpa.create(Op) catch return lose(impl, path); + op.* = .{ .kind = kind, .path = self.gpa.dupe(u8, path) catch &.{}, .old = self.gpa.dupe(u8, old) catch &.{} }; + if (op.path.len != path.len or op.old.len != old.len) { + freeOps(self.gpa, op); + return lose(impl, path); + } + impl.ops_lock.lock(); + op.next = impl.ops; + impl.ops = op; + impl.ops_lock.unlock(); + if (impl.bell) |*b| b.ring(); + } + + /// Something created right inside the newest folder that appeared, before the walker took + /// it: the backend is reporting what that folder held (a move-in, reported depth first), + /// so the walker needn't. A folder made in place has no such events until it is watched. + fn markAnnounced(impl: *Impl, path: []const u8) void { + const parent = std.fs.path.dirname(path) orelse return; + impl.ops_lock.lock(); + defer impl.ops_lock.unlock(); + const op = impl.ops orelse return; + if (op.kind == .appeared and std.mem.eql(u8, op.path, parent)) op.announced = true; + } + + /// A folder event that couldn't be queued: the watch no longer covers the tree for sure, + /// so `active` says so, and the log says it once. + fn lose(impl: *Impl, path: []const u8) void { + if (!impl.dropped.swap(true, .acq_rel)) + dvui.log.warn("folder watcher: out of memory; {s} and later folder changes may not be watched", .{path}); + } + fn record( h: *Handler, path: []const u8, @@ -139,6 +367,25 @@ const Impl = if (have_impl) struct { ) void { const impl: *Impl = @fieldParentPtr("handler", h); const self = impl.owner orelse return; + if (self.folder == null) return; + + // Where the backend watches folder by folder, the walker follows folders coming and + // going — before the dot reject, which would hide a folder moved out of view. + if (!Backend.watches_recursively and kind == .created) markAnnounced(impl, path); + if (!Backend.watches_recursively and object == .dir) switch (kind) { + .created => queueFolder(self, .appeared, path, ""), + // Deleted, or moved out of the tree (an unpaired IN_MOVED_FROM), which keeps its + // watches: both are given back. + .deleted => queueFolder(self, .gone, path, ""), + .renamed => queueFolder(self, .moved, path, old_path), + .modified => {}, + }; + + push(self, path, old_path, kind, object); + } + + /// Into the ring, for `tick`. From the backend's thread, or the walker's. + fn push(self: *FolderWatcher, path: []const u8, old_path: []const u8, kind: PathEvent.Kind, object: PathEvent.ObjectType) void { const folder = self.folder orelse return; // Cheap dot-directory reject before taking the lock. The authoritative `IgnoreRules` // pass happens on the main thread in `tick`; this one exists only so a `git checkout` @@ -197,7 +444,11 @@ pub fn deinit(self: *FolderWatcher) void { /// `Host.folderWatchActive`. pub fn active(self: *const FolderWatcher) bool { if (comptime !have_impl) return false; - return self.impl.nw != null; + if (self.impl.backend == null) return false; + // Folder by folder, the tree is covered only once the walker says every folder is watched; + // until then, and while any is left out, a plugin keeps its own rescan. + return Impl.Backend.watches_recursively or + (self.impl.whole.load(.acquire) and !self.impl.dropped.load(.acquire)); } /// Point the watcher at `path` (or nowhere, when null). Must be called only once `self` is at @@ -223,23 +474,55 @@ pub fn setFolder(self: *FolderWatcher, path: ?[]const u8) void { fn startWatch(self: *FolderWatcher) !void { if (comptime !have_impl) return error.Unsupported; const folder = self.folder orelse return error.NoFolder; - self.impl.owner = self; - var nw = try Impl.Watcher.init(dvui.io, self.gpa, &self.impl.handler); - errdefer nw.deinit(); - try nw.watch(folder); - self.impl.nw = nw; + const impl = &self.impl; + impl.owner = self; + impl.cancel.store(false, .release); + impl.whole.store(false, .release); + impl.dropped.store(false, .release); + const backend = try self.gpa.create(Impl.Backend); + errdefer self.gpa.destroy(backend); + backend.* = try Impl.Backend.init(dvui.io, &impl.handler); + errdefer backend.deinit(self.gpa); + try backend.arm(self.gpa); + impl.backend = backend; + errdefer impl.backend = null; + if (Impl.Backend.watches_recursively) { + // One watch covers the tree: FSEvents' stream, ReadDirectoryChangesW's subtree. + try backend.add_watch(self.gpa, folder); + } else { + // A watch per folder, and a large tree has a great many: never on the UI thread. + impl.bell = try folder_events.Doorbell.init(); + errdefer { + impl.bell.?.deinit(); + impl.bell = null; + } + impl.walker = try std.Thread.spawn(.{}, Impl.walk, .{self}); + } } -/// Tears the watcher down entirely rather than calling `unwatch`: nightwatch's `unwatch` drops -/// only the path it was given, not the subdirectories its recursive walk added, so a folder -/// switch would otherwise leak watches on the old tree. +/// Tears the watcher down entirely rather than removing one watch: a folder switch would +/// otherwise leak the watches on the old tree's subdirectories. fn stopWatch(self: *FolderWatcher) void { if (comptime have_impl) { - if (self.impl.nw) |*nw| { - nw.deinit(); - self.impl.nw = null; + const impl = &self.impl; + // The walker returns at its next folder, or wakes to find it should. + impl.cancel.store(true, .release); + if (comptime !Impl.Backend.watches_recursively) if (impl.bell) |*b| b.ring(); + if (impl.walker) |t| { + t.join(); + impl.walker = null; + } + if (impl.backend) |b| { + b.deinit(self.gpa); + self.gpa.destroy(b); + impl.backend = null; } - self.impl.owner = null; + // Only now: the backend's thread may have queued more until it was joined. + Impl.freeOps(self.gpa, impl.ops); + impl.ops = null; + if (comptime !Impl.Backend.watches_recursively) if (impl.bell) |*b| b.deinit(); + impl.bell = null; + impl.owner = null; } if (self.folder) |f| { self.gpa.free(f); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 58dde9117..d19744f58 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4195,14 +4195,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { dvui.log.err("about command failed: {s}", .{@errorName(err)}); }; } - if (fizzy.backend.pollPendingRecentFolder()) |i| { - if (i < editor.app.recents.folders.items.len) { - const folder = editor.app.recents.folders.items[i]; - editor.setProjectFolder(folder) catch |err| { - dvui.log.err("open recent folder failed: {s}", .{@errorName(err)}); - }; - } - } + if (fizzy.backend.pollPendingRecentFolder()) |i| editor.openRecentFolder(i); if (fizzy.backend.pollPendingNativeMenuAction()) |action| { editor.queueNativeMenuAction(action); } @@ -5210,7 +5203,16 @@ pub fn close(app: *Entry, editor: *Editor) void { /// anything deriving a key from `editor.app.folder` (a language server's `rootUri`, notably) then /// can't disagree about how the same directory is spelled. See `fizzy.core.paths.normalize`. pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { - const path = try fizzy.core.paths.normalize(editor.app.gpa, path_in); + // A relative path (a plugin's `host.setProjectFolder("proj")`) is the cwd's: everything + // downstream — the watcher, recents, a language server's `rootUri` — wants it absolute. + const relative = builtin.target.cpu.arch != .wasm32 and + !fizzy.core.paths.isMountPath(path_in) and !std.fs.path.isAbsolute(path_in); + const path = if (relative) cwd: { + // No path limit on the web, so the buffer's size can't be named there. + if (comptime builtin.target.cpu.arch == .wasm32) unreachable; + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + break :cwd try fizzy.core.paths.normalizeJoin(editor.app.gpa, cwd_buf[0..try std.Io.Dir.cwd().realPath(dvui.io, &cwd_buf)], path_in); + } else try fizzy.core.paths.normalize(editor.app.gpa, path_in); defer editor.app.gpa.free(path); // A root on a mount — a drive, a zip — is only openable while that mount exists. From @@ -5225,6 +5227,23 @@ pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { return error.NotMounted; } + // Only a folder on disk is a project folder: a file, or one removed since it was recent, + // would be watched, listed and remembered as if it were one. + if (comptime builtin.target.cpu.arch != .wasm32) if (!on_mount) { + const arena = editor.app.arena.allocator(); + const st = std.Io.Dir.cwd().statFile(dvui.io, path, .{}) catch |err| { + dvui.toast(@src(), .{ .message = if (err == error.FileNotFound) + std.fmt.allocPrint(arena, "{s} can't be found.", .{path}) catch "That folder can't be found." + else + std.fmt.allocPrint(arena, "{s} can't be opened ({t}).", .{ path, err }) catch "That folder can't be opened." }); + return err; + }; + if (st.kind != .directory) { + dvui.toast(@src(), .{ .message = std.fmt.allocPrint(arena, "{s} is not a folder.", .{path}) catch "That is not a folder." }); + return error.NotADirectory; + } + }; + // Opening a folder makes a close queued during this frame's draw moot. editor.app.pending_folder_close = false; @@ -5240,14 +5259,7 @@ pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { // A demo's folder is not one the user opened, and is gone when the demo is. if (!editor.demo.active()) { try editor.app.recents.appendFolder(try editor.app.gpa.dupe(u8, path)); - // Written now, not only at quit: a browser tab is closed, never quit, so the web keeps - // recents only if they are stored as they change. Cheap enough to do everywhere. - if (std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, "recents.zon" })) |recents_path| { - defer editor.app.gpa.free(recents_path); - editor.app.recents.save(editor.app.gpa, recents_path) catch |err| dvui.log.warn("recents: not saved: {s}", .{@errorName(err)}); - } else |_| {} - // The dvui menu re-reads recents every frame; the macOS submenu is retained state. - fizzy.backend.rebuildNativeRecentFolders(); + editor.recentFoldersChanged(); } if (editor.app.host.selectedSurface(sdk.keywords.ide.sidebar)) |s| { editor.app.host.setSelectionFor(sdk.keywords.ide.sidebar, s.id); @@ -5262,6 +5274,35 @@ pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { editor.app.host.emitZon("fizzy.folder.opened", .{ .path = path }); } +/// Store recents and rebuild the native menu after the recent folders change. +fn recentFoldersChanged(editor: *Editor) void { + // Written now, not only at quit: a browser tab is closed, never quit, so the web keeps + // recents only if they are stored as they change. Cheap enough to do everywhere. + if (std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, "recents.zon" })) |recents_path| { + defer editor.app.gpa.free(recents_path); + editor.app.recents.save(editor.app.gpa, recents_path) catch |err| dvui.log.warn("recents: not saved: {s}", .{@errorName(err)}); + } else |_| {} + // The dvui menu re-reads recents every frame; the macOS submenu is retained state. + fizzy.backend.rebuildNativeRecentFolders(); +} + +/// Open the recent folder at `index`. One that is there but no longer a folder leaves Recents +/// instead of staying to fail again; one that can't be found stays, since a drive or a share +/// can come back. `setProjectFolder` has said which in a toast. Never fails: it runs from menu +/// draws, which an error would cut short. +pub fn openRecentFolder(editor: *Editor, index: usize) void { + const recents = &editor.app.recents; + if (index >= recents.folders.items.len) return; + editor.setProjectFolder(recents.folders.items[index]) catch |err| { + // As `anyerror`: on the web there is no disk to check, so neither is in the set. + const e: anyerror = err; + if (e == error.NotADirectory or e == error.NotDir) { + recents.folders.allocator.free(recents.folders.orderedRemove(index)); + editor.recentFoldersChanged(); + } else if (e != error.FileNotFound) dvui.log.err("open recent folder failed: {t}", .{err}); + }; +} + /// Perform a close queued by `closeProjectFolder` during an earlier frame. fn applyPendingFolderClose(editor: *Editor) void { if (!editor.app.pending_folder_close) return; diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 1a7722f3d..0cef163a6 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -230,7 +230,8 @@ fn drawRecentFolders(editor: *Editor, id_extra: usize) !void { .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), })) |_| { - try editor.setProjectFolder(folder); + // `folder` may be freed here (a gone folder leaves Recents); not used after. + editor.openRecentFolder(i - 1); } } } From 0c4d03a0a0c6faee016cd73e733fdec5dc9be250 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:53:57 -0500 Subject: [PATCH 11/31] dvui: front-to-back blocks close by append; one replay order (#544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #519 ## What changes This follows up #540, which merged with the first cut of the front list (`fizzy-sdk-0.2.30-2`). It repins dvui to `foxnne/dvui-dev` `44010a3` (tag `fizzy-sdk-0.2.30-4`), which is `cc60612` plus a commit addressing the two later reviews. What is drawn on screen does not change: blocks still draw above every draw-last stroke in their subwindow. - **Closing a block is an append.** A subwindow's `render_cmds_front` is a `RenderFrontToBack.Blocks`: closed blocks appended in closing order, with each one's start offset. `Blocks.drawOrder` hands them out the other way round, so the first block closed is still drawn last. The cost is amortized constant time per command, but a nested block's commands are copied again at each enclosing level. - **A block with nothing in it adds nothing.** This covers an `initReset` block or an empty one; neither appends an empty entry any more. - **Deferred queues are one unit.** A `Picture` or a block records into a `dvui.RenderQueues` (commands, draw-last commands, blocks), installed through one pointer, `Window.defer_render`. It replaces `defer_render_cmds`, `defer_render_cmds_after` and the paired front field. The three can't be mismatched, so the pairing check and the legacy fallback are gone; nothing in dvui set them separately. - **One replay order.** `RenderQueues.render` is the only place that defines the order queued drawing draws in. `Picture.stop` and `Window.renderSubwindow` both use it. - **`renderSubwindow(sw, .{ .take })` takes first, then draws.** It empties the subwindow's queues before replaying them, as fizzy's old code did, so anything the replay queues (say, a draw-last stroke from a custom draw command) is kept for later. - **Fizzy calls `dvui.currentWindow().renderSubwindow(sw, .{ .take = … })` at its six sites** (`src/editor/Popout.zig`, `backend/src/os_windows.zig`). The one-line `viewports.Picture.subwindow` wrapper is gone. Known minor: dvui's `Debug` widget outline is a draw-last stroke, so it draws under a front-to-back block. `docs/DEPENDENCIES.md` updates item 10 and the list of tags. `-3` was never pinned. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0x2897dc6ec36f713b` → `0xd624ada9fe0e74d7`), `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag For plugin authors: - `Window.defer_render: ?*dvui.RenderQueues` replaces `defer_render_cmds`, `defer_render_cmds_after` and `defer_render_cmds_front`. - `Subwindow.render_cmds_front` is a `RenderFrontToBack.Blocks`. - To draw a subwindow elsewhere, call `Window.renderSubwindow`. ## Verified - [x] macOS: - fizzy, on current `main` with the URL pin: `scripts/check-fmt.sh`, `zig build`, `zig build check-web` and `zig build test-sdk-version` passed. `zig build test`: 101/101 steps. `zig build test-integration`: 53/53 steps, 562/562 tests. `scripts/check-examples.sh`: all 8 builds ok. - dvui: `zig build test -Dbackend=testing` passed 266/266. The test "closed RenderFrontToBack blocks are drawn after draw-last strokes, before or after they closed" covers: - sibling and nested blocks, and the block order both as stored and as drawn; - deferred `RenderQueues` installed as `Picture.start` does; - empty and `initReset` blocks adding nothing (fails without the early return); - `renderSubwindow` with and without `take`, including a custom draw command whose `.after` stroke survives the replay (fails if the take happens after drawing); - clearing with the frame. - Not exercised: a real `Picture`, since the testing backend makes no texture targets. - Not seen on screen. - [ ] Windows: - [ ] Linux: - [ ] Web: `check-web` only ## Follow-ups - What `core/widgets/BlurBackdrop.zig` captures (`sw.render_cmds[cmd_start..]` only): fizzyedit/plans#27. - Look at a focused explorer filter under the sidebar's corner button on a desktop. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- backend/src/os_windows.zig | 2 +- backend/src/viewports.zig | 21 ++------------------- docs/DEPENDENCIES.md | 32 ++++++++++++++++++++------------ sdk/build.zig.zon | 6 +++--- sdk/src/version.zig | 2 +- src/editor/Popout.zig | 10 +++++----- 6 files changed, 32 insertions(+), 41 deletions(-) diff --git a/backend/src/os_windows.zig b/backend/src/os_windows.zig index b491d3010..7a2549d55 100644 --- a/backend/src/os_windows.zig +++ b/backend/src/os_windows.zig @@ -167,7 +167,7 @@ pub fn afterFrame() void { const area: dvui.Rect.Physical = .{ .x = shown.x, .y = shown.y, .w = shown.w, .h = shown.h }; const picture: viewports.Picture = .begin(target, shown); for (cw.subwindows.stack.items) |*sw| { - if (area.contains(sw.rect_pixels.center())) picture.subwindow(sw, true); + if (area.contains(sw.rect_pixels.center())) dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); } picture.end(); viewports.present(o.viewport, target); diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index a80cd3cbc..63822e158 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -290,7 +290,8 @@ pub fn present(vp: *Viewport, target: ?dvui.TextureTarget) void { } /// A viewport's picture this frame, drawn as dvui draws the frame: into a target, from the top left -/// of the part of the frame the viewport shows, until `end`. In it go the subwindows the viewport shows (`subwindow`) — a float and +/// of the part of the frame the viewport shows, until `end`. In it go the subwindows the viewport +/// shows (`dvui.Window.renderSubwindow`, `.take` when they are out of the frame too) — a float and /// what opened in it, a menu, a carried view — and whatever the app draws under them. Begun after /// `Window.drawRetained`, so the dialogs and toasts are subwindows by then; then `present`. pub const Picture = struct { @@ -323,24 +324,6 @@ pub const Picture = struct { _ = dvui.renderTarget(self.rt); } - /// Subwindow `sw`'s drawing this frame, in the picture. `take`: out of the frame too, so dvui's - /// replay into the main window — and any picture after this one — draws none of it. - pub fn subwindow(_: Picture, sw: *dvui.Subwindows.Subwindow, take: bool) void { - const cw = dvui.currentWindow(); - const cmds = sw.render_cmds; - const after = sw.render_cmds_after; - const front = sw.render_cmds_front; - if (take) { - sw.render_cmds = .empty; - sw.render_cmds_after = .empty; - sw.render_cmds_front = .empty; - } - // In dvui's order (`Window.endRendering`): front-to-back blocks over the draw-last strokes. - cw.renderCommands(cmds.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); - cw.renderCommands(after.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); - cw.renderCommands(front.items) catch |err| dvui.logError(@src(), err, "drawing a subwindow into a viewport", .{}); - } - /// Done: drawing goes to the frame's own target again. pub fn end(self: Picture) void { _ = dvui.renderTarget(self.prev); diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 491768dee..66dcdc99b 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -93,16 +93,22 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov test calling it failed to compile (#526). It takes `SnapshotOptions` now, and logs through `std.log`. Upstream: already there; comes out empty at the next rebase. 10. **RenderFrontToBack: closed blocks are drawn after draw-last commands** (`f07c334`, reworked - in `fdc03d4`). A closed block went in at the start of `render_cmds_after` and draw-last strokes - (`focusBorder`, a text entry's error ring) were appended after it, so a focus ring drawn later - lay on top of a block meant to cover it: the pane corner button's glass under the explorer - filter's ring (#519). Closed blocks now go in a list of their own, - `Subwindow.render_cmds_front`, drawn after `render_cmds_after` (and, while drawing is deferred, - `Window.defer_render_cmds_front`, which `RenderFrontToBack` and `Picture` set). Code that takes - a subwindow's lists to draw elsewhere takes this one too, last, as `backend/src/viewports.zig` - does. `Debug`'s widget outline, a draw-last stroke, now draws under such a block. Upstream: - worth proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack - blocks are drawn after draw-last strokes, before or after they closed". + in `fdc03d4`, `cc60612` and `44010a3`). A closed block went in at the start of + `render_cmds_after` and draw-last strokes (`focusBorder`, a text entry's error ring) were + appended after it, so a focus ring drawn later lay on top of a block meant to cover it: the + pane corner button's glass under the explorer filter's ring (#519). Closed blocks now go in a + list of their own, `Subwindow.render_cmds_front` (a `RenderFrontToBack.Blocks`), drawn after + `render_cmds_after`. Closing a block appends its commands (amortized constant time a command; + a nested block's commands are copied again at each enclosing level), and they are drawn the + other way round, so the first closed is drawn last. A `Picture` or a block records into a + `RenderQueues` (commands, draw-last commands, blocks) installed as one unit, + `Window.defer_render`, which replaces `defer_render_cmds` and `defer_render_cmds_after`. + `RenderQueues.render` is the one place that says the order queued drawing is drawn in; + `Picture.stop` and `Window.renderSubwindow` use it, and fizzy draws a subwindow into a viewport + with `renderSubwindow` (`.take` empties the subwindow's queues before drawing them). + `Debug`'s widget outline, a draw-last stroke, now draws under such a block. Upstream: worth + proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack blocks + are drawn after draw-last strokes, before or after they closed". Each of 5 and 6 is also a branch of its own on upstream `main` (`window-draw-retained`, `screens-set`), from which the upstream PR is opened. @@ -112,8 +118,10 @@ builds); `fizzy-sdk-0.2.16` → `4428108` (SDK 0.2.16 to 0.2.18); `fizzy-sdk-0.2 `f8b32db`; `fizzy-sdk-0.2.19-2` → `a90bc70` (0.2.19 was not released yet, and a tag never moves, so the second pin of one version takes a suffix, as the SDL fork's tags do; SDK 0.2.19 to 0.2.22); `fizzy-sdk-0.2.23` → `f646ec0` (SDK 0.2.23 to 0.2.29); `fizzy-sdk-0.2.30` → `f07c334` -(never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-2` → -`fdc03d4`, what `sdk/build.zig.zon` pins now. +(never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-1` → +`e2c9a85` (item 9 alone, never pinned); `fizzy-sdk-0.2.30-2` → `fdc03d4` (pinned by #540); +`fizzy-sdk-0.2.30-3` → `cc60612` (never +pinned); `fizzy-sdk-0.2.30-4` → `44010a3`, what `sdk/build.zig.zon` pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends (`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 0b02ea6fb..20b9c4980 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -39,9 +39,9 @@ // `fizzy-sdk-` tag in the fork before the pin lands. `fizzy-dev` itself stays a // working branch and can be rebased freely — the tag, never. .dvui = .{ - // fizzy-sdk-0.2.30-2 - .url = "https://github.com/foxnne/dvui-dev/archive/fdc03d4fc77f8a79f3fc8bbb14534769028e0069.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmdBcBAHqXrD_1KInoFLi4qYmeOwR9QOxzbL4kIMR", + // fizzy-sdk-0.2.30-4 + .url = "https://github.com/foxnne/dvui-dev/archive/44010a31274fa49ce56c9cf96dc2706dffd09a30.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmdtlBAF4FZgHirDe3ghMmGMS46WlRhlT0Sx_TmEc", //.path = "../../dvui-dev", }, .zf = .{ diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 9f4b39242..4dfd5e5c9 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x2897dc6ec36f713b; +pub const recorded_sdk_shape_fingerprint: u64 = 0xd624ada9fe0e74d7; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index be816ab8c..5d7722d94 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -478,7 +478,7 @@ fn carryFrame(state: *State) void { // The carried view's own layer (`core.screens.markCarried`), taken from it: dvui's replay into the main // window, and the float windows' (`windowFrame`), draw nothing of it. for (cw.subwindows.stack.items) |*sw| { - if (fizzy.core.screens.isCarried(sw.id)) drawing.picture.subwindow(sw, true); + if (fizzy.core.screens.isCarried(sw.id)) dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); } } @@ -610,7 +610,7 @@ fn overlayFrame(state: *State) void { if (!fizzy.core.screens.isCarried(sw.id)) continue; for (offsets[0..n_off], 0..) |off, i| { picture.moveTo(off); - picture.subwindow(sw, i == n_off - 1); + dvui.currentWindow().renderSubwindow(sw, .{ .take = i == n_off - 1 }); } } picture.end(); @@ -1166,7 +1166,7 @@ fn windowFrame(state: *State, o: *Out) void { // What takes no input goes over everything, last (`overlaysFrame`). if (!sw.mouse_events and !fizzy.core.screens.isEverywhere(sw.id)) continue; const mine = area.contains(sw.rect_pixels.center()); - if (mine or fizzy.core.screens.isEverywhere(sw.id)) picture.subwindow(sw, mine); + if (mine or fizzy.core.screens.isEverywhere(sw.id)) dvui.currentWindow().renderSubwindow(sw, .{ .take = mine }); } viewports.present(o.viewport, target); } @@ -1348,7 +1348,7 @@ fn menuFrame() void { defer dvui.alphaSet(prev_alpha); frame.fill(dvui.CornerRect.Physical.all(corner * s), .{ .color = .{ .color = Editor.windowBase(op) } }); } - picture.subwindow(sw, true); + dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); picture.end(); viewports.present(m.viewport, target); } @@ -1410,7 +1410,7 @@ fn overlaysFrame() void { }; const w = into orelse continue; const picture: viewports.Picture = .again(w.target, w.part); - picture.subwindow(sw, true); + dvui.currentWindow().renderSubwindow(sw, .{ .take = true }); picture.end(); } } From ad7b5184877417fe4b37ab49858910e284e9503c Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:56:03 -0500 Subject: [PATCH 12/31] build: an app built on fizzy packages under its own name and icons (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #520 ## What changes An app built on fizzy now packages, updates and identifies itself as itself, not as fizzy. **One "is fizzy" signal.** - Fizzy's root `build.zig` passes `Options.own` only when fizzy is the root package (`b.pkg_hash.len == 0`). - `Options.own` carries fizzy's bundle id, update feed, `VERSION` file and its whole branding set: `icon.png`, `fizzy.ico`, `fizzy.rc`, `fizzy.icns`, `info.plist`. - #533's `app_is_fizzy` now derives from `opts.own != null`. This branch is rebased onto #533. Its `own_repo_url` and `app_repo_url_set` semantics are unchanged. **Branding.** - Fizzy's build takes its set whole unless it is renamed (`-Dapp-name`) or given an icon (`-Dapp-icon`, `-Dapp-icon-ico`, `-Dapp-icon-icns`). There is no string comparison: fizzy's argv is unchanged, and a renamed or re-iconed fizzy never ships a plist naming the wrong executable or icon. - Any other build gets the app set: - its icons, with fizzy's art where it gives none; - a generated resource script; - a plist that vpk writes from `--bundleId`. - `-Dapp-windows-rc` and `-Dapp-macos-plist` override either set. **App-neutral defaults.** - **Bundle id:** `dev.fizzy.`, built by `build/bundle_id.zig`, which is unit-tested. - Anything outside `[A-Za-z0-9.-]`, including each byte of a multi-byte character, becomes `-`, and runs of `-` collapse to one. - A leading `-` or digit and a trailing `-` are removed; if nothing is left, the component is `app`. - **Update feed:** none, unless `-Drepo-url` is given. - **Version:** 0.0.0 unless the app passes `app_version`. The app's own `VERSION` file can't be read here, because a dependency's build doesn't know the root package's directory. **Generated resource script.** - It holds the app's icon and a VERSIONINFO built from its name, display name and version. - It starts with `#pragma code_page(65001)`. - Control characters are dropped from the strings, quotes are doubled and backslashes escaped. - The version parse drops a leading `v` and cuts at `-` or `+`. **Names.** - `--packId` is ``. - `--mainExe` is the executable's own file name. - `--packTitle` comes from `app-package-title`, defaulting to `app-name`, on every OS. **Icons.** `app-icon` (a PNG) is the window icon, the infobar logo and the settings-tree logo, through an `app_icon` module. That module also exports `pixel_art`: fizzy's icon is drawn with nearest-neighbour sampling, an app's is smoothed. The PNG is also the new Linux `--icon`. **`packageall`.** - Each run gets this build's build file (`--build-file /build.zig`) and inherits the cwd this build was run in. A relative `-D` path means what it meant here: Zig resolves a `LazyPath` option against the build runner's cwd, which is the cwd `zig build` was run in, not the build root (checked with a probe build). - It passes the command-line `-D` options except the target's own (`target`, `cpu`, `ofmt`, `dynamic-linker`). `-Doptimize` is passed only if it was given; `--release` is passed through as given. - `windows-msvc-libc` and `fetch-msvc` go to the `*-windows-msvc` runs only. **`fizzy.addPackageSteps(b, fizzy_dep)`** (new, additive; `buildApp` is unchanged) gives an app's own build `package`, `desktop` and `packageall`. - It panics if `buildApp` hasn't run. It skips only the configure pass that fetches Velopack. - If the app already has one of the three steps, it logs which and returns `error.PackageStepTaken`. - The installers go into the app's `zig-out/`, once, through `fizzy_dep.namedLazyPath("package")`. That directory is vpk's output; on a target that can't be packaged, it is a directory whose step fails with fizzy's message. - Fizzy's own `package` step is not run for an app: as a dependency, it would install into `.zig-cache/i/`. - The doc says the MSVC options reach fizzy only if the app declares and forwards them. ## SDK impact - [x] None ## Verified These pass on macOS, run one at a time under the machine's load: `scripts/check-fmt.sh`, `zig build`, `zig build test` (591/592, 1 skipped, now including `fizzy-bundle-id-tests`), `zig build test-integration` (54/54 steps), `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached`, and `EXAMPLES_JOBS=1 scripts/check-examples.sh` (all eight builds). The `vpk` argv was checked by running `zig build package` with a stub `dotnet` that prints the `vpk` arguments and writes a fake installer: | Build | vpk got | |---|---| | fizzy | `--packId fizzy --packVersion 0.2.0 --mainExe fizzy --packTitle fizzy --plist assets/macos/info.plist --icon assets/macos/fizzy.icns` (unchanged values) | | fizzy `-Dapp-name=myapp` | `--packId myapp --mainExe myapp --packTitle myapp --bundleId com.foxnne.fizzy` | | fizzy, run from `src/` with `-Dapp-icon-icns=../assets/macos/fizzy.icns` | `--bundleId com.foxnne.fizzy --icon ./../assets/macos/fizzy.icns` (relative path resolved correctly) | | example-app as `minimal_app`, display `Minimal "App" \ Ü`, `app_version` `v1.2.3+build`, macOS | `--packId minimal_app --packVersion v1.2.3+build --mainExe minimal_app --bundleId dev.fizzy.minimal-app`; fake installer in `zig-out/arm64-macos/` only | | the same app, `desktop -Dtarget=x86_64-linux-gnu` | `--packId studio_app --mainExe studio_app --packTitle "Studio App" --icon /icon.png` | | the same app, `package -Dtarget=x86_64-windows-gnu` | fails through the app's step with fizzy's "requires the MSVC ABI" message | More checks: - **`packageall` from `docs/`:** with `-Dapp-icon=../assets/icon.png`, the first run was `zig build package -Dtarget=x86_64-windows-msvc -Dapp-icon=../assets/icon.png --build-file /build.zig`, with the cwd inherited. - **Windows resources:** the same app built for `x86_64-windows-gnu`. `minimal_app.exe` holds `Minimal "App" \ Ü` (the tab dropped), `v1.2.3+build` and `minimal_app.exe`, with FILEVERSION 1,2,3,0, and no `fizzy` or `foxnne`. Platforms: - [x] macOS: the gates and dry runs above. No real `vpk pack` was run. - [ ] Windows: the gnu cross build and resource check above. No MSVC package was built. - [ ] Linux: argv only, from the cross dry run above. - [x] Web: `check-web`. ## Follow-ups - The web favicon is served by the site (`web/index.html` points at `/img/icon.png`), so it stays fizzy's. - `assets/macos/Fizzy.entitlements` has no option for an app's own. - `.velopack-msvc/` resolves against fizzy's build root, so for an app it lands in fizzy's package directory under `zig-pkg`. This was already so before this change. - `fizzyedit/example-app` could adopt `addPackageSteps`, `app_version` and its own icons, in a companion PR there. - `zig build packageall --release=fast` panics in a jj workspace with no `.git`, because nightwatch's build runs `git describe`. This was already so before this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 6 +- RELEASING.md | 5 +- build.zig | 65 ++++++ build/app.zig | 206 +++++++++++++++-- build/bundle_id.zig | 36 +++ build/exe.zig | 18 +- build/package.zig | 439 ++++++++++++++++++++---------------- build/web.zig | 3 + src/Entry.zig | 5 +- src/editor/Infobar.zig | 5 +- src/editor/SettingsTree.zig | 5 +- 11 files changed, 552 insertions(+), 241 deletions(-) create mode 100644 build/bundle_id.zig diff --git a/AGENTS.md b/AGENTS.md index fb191062e..6ea356df8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,11 @@ fizzy's own are entries with no module, built for each executable (`build/plugin `link`), and only those listed register or get a dylib. `.@"dynamic-plugins" = false` (or `-Ddynamic-plugins=false`) makes an app link-only: no plugin dylib is built, and nothing loads at runtime (fizzy's dylibs, the user plugins directory, the store, a web page's `?plugin=`), -all decided at comptime (`App.loads_plugins`). See +all decided at comptime (`App.loads_plugins`). What is fizzy's own (bundle id, update feed, +`VERSION`, its plist and resource script) only fizzy's root `build.zig` passes (`Options.own`); +an app sets its identity through fizzy's options (`app-name`, `app-bundle-id`, `app_version`, +`repo-url`, `app-package-title`, `app-icon`, `app-icon-ico`, `app-icon-icns`, …), and +`try fizzy.addPackageSteps(b, fizzy_dep)` gives its own build `package` and `packageall`. See [`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles `fizzyedit/example-plugin`. diff --git a/RELEASING.md b/RELEASING.md index 1e60c8064..7ff7560b1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -103,8 +103,9 @@ will be unsigned — Gatekeeper will warn users. The release still goes out fine; you'd just want to sign before announcing it widely. For a **single-machine** build (e.g. on a Mac), `zig build packageall` is -unchanged and still builds all six targets sequentially; CI parallelism is -only in the workflow. +unchanged and still builds all six targets sequentially, passing each run the +`-D` options it was given but the target's own (the Windows MSVC ones to the +Windows runs only); CI parallelism is only in the workflow. ### Option B: Run locally diff --git a/build.zig b/build.zig index 3b1ffe42d..b83c77d78 100644 --- a/build.zig +++ b/build.zig @@ -49,6 +49,18 @@ pub fn build(b: *std.Build) !void { // `.{ .dir = "../fizzyedit/atlas", .modules = &.{ .{ .name = "batch2d", .root = // "src/batch2d/root.zig" }, … } }`. .web_plugin_dirs = &.{}, + // Fizzy's own identity, for fizzy alone: the root package. Reached as a dependency + // (`buildApp`), it is an app's build, which gets none of it. + .own = if (b.pkg_hash.len == 0) .{ + .bundle_id = "com.foxnne.fizzy", + .repo_url = "https://github.com/fizzyedit/fizzy", + .version_file = "VERSION", + .png = "assets/icon.png", + .ico = "assets/windows/fizzy.ico", + .rc = "assets/windows/fizzy.rc", + .icns = "assets/macos/fizzy.icns", + .plist = "assets/macos/info.plist", + } else null, }; // A consumer that bundles plugins of its own cannot say so through `b.dependency` @@ -61,6 +73,8 @@ pub fn build(b: *std.Build) !void { return; } try app.construct(b, target, optimize, base, cfg); + // On the configure pass that fetches Velopack there is no `package` step to run. + if (cfg.vz != null) @import("build/package.zig").addPackageAllStep(b, cfg.no_emit); } /// What `build` parsed for a `defer-app` consumer, so `buildApp` need not re-declare the @@ -70,6 +84,8 @@ var deferred: ?struct { optimize: std.builtin.OptimizeMode, base: @import("build/app.zig").Options, cfg: @import("build/app.zig").Config, + /// `buildApp` has run (`addPackageSteps` needs its steps). + built: bool = false, } = null; pub const BundledPlugin = @import("build/sdk.zig").BundledPlugin; @@ -114,6 +130,55 @@ pub fn buildApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) } else false; opts.app_plugins = if (names_fizzys) linked else try std.mem.concat(fizzy_dep.builder.allocator, BundledPlugin, &.{ &plugins.all, linked }); try @import("build/app.zig").construct(fizzy_dep.builder, d.target, d.optimize, opts, d.cfg); + deferred.?.built = true; +} + +/// `zig build package` (and its alias `desktop`) and `zig build packageall` for an app built with +/// `buildApp`, as steps of the app's own build `b`: a dependency's steps are not the command +/// line's. `package` packages the app for the target the app gave fizzy, into the app's +/// `zig-out/`; `packageall` runs `zig build package` for each of the six release +/// targets from the app's build root, with the options the app's build was given. An app that +/// already has a step by one of these names gets `error.PackageStepTaken`: `packageall`'s runs +/// call `package` by name. +/// +/// ```zig +/// try fizzy.buildApp(fizzy_dep, &.{ … }); +/// try fizzy.addPackageSteps(b, fizzy_dep); +/// ``` +/// +/// What the installers call the app and what they carry come from fizzy's options, passed in +/// `b.dependency`: `app-name` (`--packId`, the executable), `app-bundle-id`, `app_version`, +/// `repo-url` (the update feed; none by default), `app-package-title`, `app-icon` (PNG: the +/// window, the infobar, Linux), `app-icon-ico`, `app-windows-rc`, `app-icon-icns`, +/// `app-macos-plist`. The icons default to fizzy's art; the resource script is generated and +/// vpk writes the plist. +/// +/// `packageall` passes each run the options the app's own command line was given, so the +/// Windows MSVC ones reach fizzy only if the app declares `windows-msvc-libc` / `fetch-msvc` and +/// passes them on in `b.dependency`. Without them, a `*-windows-msvc` run fetches MSVC itself. +pub fn addPackageSteps(b: *std.Build, fizzy_dep: *std.Build.Dependency) !void { + const d = deferred orelse @panic("fizzy.addPackageSteps: pass `.@\"defer-app\" = true` to b.dependency(\"fizzy\", …) and call fizzy.buildApp first"); + const names = [_][]const u8{ "package", "desktop", "packageall" }; + for (names) |name| if (b.top_level_steps.contains(name)) { + std.log.err("fizzy.addPackageSteps: this build already has a `{s}` step; `package`, `desktop` and `packageall` are the names it adds", .{name}); + return error.PackageStepTaken; + }; + if (!d.built) @panic("fizzy.addPackageSteps: call fizzy.buildApp first"); + // The configure pass that fetches Velopack builds no package steps. + if (d.cfg.vz == null) return; + const package = fizzy_dep.builder.top_level_steps.get("package").?; + // Into the app's prefix (`zig-out/`), once. Fizzy's own `package` step would + // install them into its prefix as a dependency, a directory in the cache + // (`std.Build.createChild` gives it `.zig-cache/i/`), so it is not run: the installers' + // directory carries the vpk run, or the failure saying why this target can't be packaged. + const step = b.step("package", package.description); + step.dependOn(&b.addInstallDirectory(.{ + .source_dir = fizzy_dep.namedLazyPath("package"), + .install_dir = .{ .custom = d.cfg.zig_out_subdir }, + .install_subdir = "", + }).step); + b.step("desktop", "Alias for `zig build package`").dependOn(step); + @import("build/package.zig").addPackageAllStep(b, d.cfg.no_emit); } /// What a dvui app of its own builds on (`addDvui`): fizzy's backend, with OS windows besides the diff --git a/build/app.zig b/build/app.zig index 7dc592116..edd439345 100644 --- a/build/app.zig +++ b/build/app.zig @@ -41,6 +41,8 @@ pub const Options = struct { /// links in, taken by path outside the package graph. Local development's answer to /// `web_plugin_deps`; a directory that is missing is skipped with a note. web_plugin_dirs: []const WebPluginDir = &.{}, + /// Fizzy's own build passes `Own`; an app built on fizzy leaves it null. + own: ?Own = null, windows_msvc_libc_opt: ?[]const u8 = null, fetch_msvc_opt: ?bool = null, macos_sign_app_identity: ?[]const u8 = null, @@ -75,6 +77,7 @@ pub const Config = struct { app_name: []const u8, app_display_name: []const u8, app_bundle_id: []const u8, + branding: Branding, app_config_dir: []const u8, app_registry_url: []const u8, app_repo_url: []const u8, @@ -97,11 +100,148 @@ pub const Config = struct { macos_sign_app_identity: ?[]const u8, macos_sign_install_identity: ?[]const u8, macos_notary_profile: ?[]const u8, - windows_msvc_libc_opt: ?[]const u8, - fetch_msvc: bool, win_libc: velopack.ResolvedWindowsMsvcLibc, }; +/// What says which application this is outside its own code: the icons its window, executable +/// and installers carry, macOS's Info.plist, and the title the installers give it. Fizzy's own +/// by default; an app built on fizzy passes its own through `b.dependency` (`-Dapp-icon=` …). +pub const Branding = struct { + /// The window's icon (`src/Entry.zig`, as the `app_icon` module) and the Linux AppImage's. + png: std.Build.LazyPath, + /// `png` is fizzy's pixel art, drawn with nearest-neighbour sampling; an app's is smoothed. + pixel_art: bool, + /// The Windows installer's and its Start Menu shortcut's icon. + ico: std.Build.LazyPath, + /// The resource script embedded into the Windows executable: its icon and version details. + rc: std.Build.LazyPath, + /// The macOS bundle's icon. + icns: std.Build.LazyPath, + /// The macOS bundle's Info.plist. Null for an app other than fizzy without one: vpk then + /// writes it from `--bundleId` (vpk takes one or the other, never both). + plist: ?std.Build.LazyPath, + /// `vpk pack --packTitle`: the name of the macOS bundle, the Windows shortcut and the Linux + /// desktop entry. + package_title: []const u8, +}; + +/// The application's identity, as `readBranding` needs it. +const Identity = struct { + name: []const u8, + display_name: []const u8, + version: []const u8, +}; + +/// What only fizzy's own build passes (`build.zig`, when fizzy is the root package): its update +/// feed, its version file, its bundle id, and the plist and resource script that name it. An app +/// built on fizzy gets none of these by default. +pub const Own = struct { + bundle_id: []const u8, + /// The update feed (`repo-url`). + repo_url: []const u8, + /// Read for the version when `-Dapp_version` is not given. This and the files below are + /// relative to fizzy's build root. + version_file: []const u8, + /// Fizzy's branding, a set: the plist names the executable and `fizzy.icns`, the resource + /// script embeds `fizzy.ico`. The build takes all of it or none (`readBranding`). + png: []const u8, + ico: []const u8, + rc: []const u8, + icns: []const u8, + plist: []const u8, +}; + +/// Fizzy's own branding as a whole (`Own`) while fizzy's build keeps fizzy's name and icons; +/// otherwise the app's: its icons (fizzy's art where it gives none), a generated resource script +/// and a plist vpk writes. `-Dapp-windows-rc` / `-Dapp-macos-plist` override either. +fn readBranding(b: *std.Build, id: Identity, own: ?Own, renamed: bool) Branding { + const LazyPath = std.Build.LazyPath; + const png_opt = b.option(LazyPath, "app-icon", "PNG icon: the window's, the infobar's and the Linux package's (default: fizzy's)"); + const ico_opt = b.option(LazyPath, "app-icon-ico", "Windows .ico: the installer's, the Start Menu shortcut's, and the generated resource script's (default: fizzy's)"); + const icns_opt = b.option(LazyPath, "app-icon-icns", "macOS .icns: the bundle's icon (default: fizzy's)"); + const rc_opt = b.option(LazyPath, "app-windows-rc", "Windows resource script embedded in the exe (default: fizzy's for fizzy, else one generated from the icon, names and version)"); + const plist_opt = b.option(LazyPath, "app-macos-plist", "macOS Info.plist (default: fizzy's for fizzy, else one vpk writes from app-bundle-id)"); + const package_title = b.option([]const u8, "app-package-title", "The installers' name for the app: the macOS bundle, the Windows shortcut (default: app-name)") orelse id.name; + const own_set = if (renamed or png_opt != null or ico_opt != null or icns_opt != null) null else own; + if (own_set) |o| return .{ + .png = b.path(o.png), + .pixel_art = true, + .ico = b.path(o.ico), + .rc = rc_opt orelse b.path(o.rc), + .icns = b.path(o.icns), + .plist = plist_opt orelse b.path(o.plist), + .package_title = package_title, + }; + const ico = ico_opt orelse b.path("assets/windows/fizzy.ico"); + return .{ + .png = png_opt orelse b.path("assets/icon.png"), + .pixel_art = png_opt == null, + .ico = ico, + .rc = rc_opt orelse windowsRc(b, id, ico), + .icns = icns_opt orelse b.path("assets/macos/fizzy.icns"), + .plist = plist_opt, + .package_title = package_title, + }; +} + +/// A resource script with the build's own icon, and the version details Explorer shows from its +/// own names and version rather than fizzy.rc's. +fn windowsRc(b: *std.Build, id: Identity, ico: std.Build.LazyPath) std.Build.LazyPath { + // `v1.2.3-dev+5` → 1,2,3,0: up to four numeric parts, anything else 0. + var parts = [4]u16{ 0, 0, 0, 0 }; + const core = std.mem.trimStart(u8, id.version[0 .. std.mem.indexOfAny(u8, id.version, "-+") orelse id.version.len], "vV"); + var it = std.mem.splitScalar(u8, core, '.'); + for (&parts) |*part| part.* = std.fmt.parseInt(u16, it.next() orelse break, 10) catch 0; + // An rc string literal holds no control characters (they are dropped), doubles its quotes + // and escapes its backslashes. + var strings: [3][]const u8 = .{ id.display_name, id.version, id.name }; + for (&strings) |*str| { + var printable: std.ArrayList(u8) = .empty; + for (str.*) |c| if (!std.ascii.isControl(c)) printable.append(b.allocator, c) catch @panic("OOM"); + str.* = printable.items; + str.* = std.mem.replaceOwned(u8, b.allocator, str.*, "\\", "\\\\") catch @panic("OOM"); + str.* = std.mem.replaceOwned(u8, b.allocator, str.*, "\"", "\"\"") catch @panic("OOM"); + } + const files = b.addWriteFiles(); + // The script names its icon relative to itself, so the two go in one directory. + _ = files.addCopyFile(ico, "app.ico"); + return files.add("app.rc", b.fmt( + \\#pragma code_page(65001) + \\ + \\1 ICON "app.ico" + \\ + \\1 VERSIONINFO + \\FILEVERSION {[numeric]s} + \\PRODUCTVERSION {[numeric]s} + \\FILEOS 0x4 + \\FILETYPE 0x1 + \\{{ + \\ BLOCK "StringFileInfo" + \\ {{ + \\ BLOCK "040904B0" + \\ {{ + \\ VALUE "FileDescription", "{[display]s}" + \\ VALUE "FileVersion", "{[version]s}" + \\ VALUE "InternalName", "{[name]s}" + \\ VALUE "OriginalFilename", "{[name]s}.exe" + \\ VALUE "ProductName", "{[display]s}" + \\ VALUE "ProductVersion", "{[version]s}" + \\ }} + \\ }} + \\ BLOCK "VarFileInfo" + \\ {{ + \\ VALUE "Translation", 0x0409, 0x04B0 + \\ }} + \\}} + \\ + , .{ + .numeric = b.fmt("{d},{d},{d},{d}", .{ parts[0], parts[1], parts[2], parts[3] }), + .display = strings[0], + .version = strings[1], + .name = strings[2], + })); +} + /// Phase one of `build`: read every option and set up the option steps. pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options) !Config { const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; @@ -190,14 +330,15 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false; - const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file"); + const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log (default: fizzy's VERSION file for fizzy, else 0.0.0)"); // GitHub repo URL baked into the binary so Velopack's auto-update can find // the latest release via the GitHub Releases API. Override at build time // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. - const repo_url_opt = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)"); - const app_repo_url = repo_url_opt orelse "https://github.com/fizzyedit/fizzy"; + // An app built on fizzy has no feed unless it names one: fizzy's releases are not its updates. + const repo_url_opt = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (default: fizzy's for fizzy, else none)"); + const app_repo_url = repo_url_opt orelse if (opts.own) |own| own.repo_url else ""; // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` // yields no update. Lets a build survive a repo move/rename: ship a binary @@ -208,22 +349,29 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options var version_owned: ?[]u8 = null; // lives as long as the build process - const app_version: []const u8 = if (app_version_opt) |v| v else blk: { - const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); + // An app's version is its own: it passes `.app_version` (its build root is not this + // package's, so its VERSION file is out of reach here), else 0.0.0. + const app_version: []const u8 = if (app_version_opt) |v| v else if (opts.own) |own| blk: { + const raw = b.build_root.handle.readFileAlloc(b.graph.io, own.version_file, b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); version_owned = raw; break :blk std.mem.trimEnd(u8, raw, "\r\n"); - }; + } else "0.0.0"; const build_opts = b.addOptions(); build_opts.addOption([]const u8, "app_version", app_version); // Application identity (see app/AppInfo.zig). Options rather than literals so an app built // on fizzy as a library can set them; fizzy passes its own values. - const app_name = b.option([]const u8, "app-name", "Short lowercase app identifier (exe name, packId, config dir)") orelse "fizzy"; + const app_name_opt = b.option([]const u8, "app-name", "Short lowercase app identifier (exe name, packId, config dir)"); + const app_name = app_name_opt orelse "fizzy"; const app_display_name = b.option([]const u8, "app-display-name", "Human-facing application name") orelse "Fizzy"; - const app_bundle_id = b.option([]const u8, "app-bundle-id", "Reverse-DNS application identifier") orelse "com.foxnne.fizzy"; + // An app that names no bundle id gets one of its own, not fizzy's: `dev.fizzy.`, + // with anything a bundle id cannot hold made `-`. + const app_bundle_id = b.option([]const u8, "app-bundle-id", "Reverse-DNS application identifier (default: fizzy's for fizzy, else dev.fizzy.)") orelse + if (opts.own) |own| own.bundle_id else b.fmt("dev.fizzy.{s}", .{@import("bundle_id.zig").component(b.allocator, app_name)}); const app_config_dir = b.option([]const u8, "app-config-dir", "Config directory name (defaults to app-name)") orelse app_name; const app_registry_url = b.option([]const u8, "app-registry-url", "Plugin registry catalog URL; empty disables the store") orelse "https://plugins.fizzyed.it/catalog"; + const branding = readBranding(b, .{ .name = app_name, .display_name = app_display_name, .version = app_version }, opts.own, app_name_opt != null); build_opts.addOption([]const u8, "app_name", app_name); build_opts.addOption([]const u8, "app_display_name", app_display_name); build_opts.addOption([]const u8, "app_bundle_id", app_bundle_id); @@ -231,10 +379,10 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption([]const u8, "app_registry_url", app_registry_url); build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); - // Fizzy itself rather than an app built on it: fizzy is the root package, an app reaches it - // as a dependency (`buildApp`). Structural, so a fork or a fizzy build that renames itself - // is still told apart from an app. - const app_is_fizzy = b.pkg_hash.len == 0; + // Fizzy itself rather than an app built on it: fizzy's own build passes `own` (as the root + // package, `build.zig`), an app reaches fizzy as a dependency (`buildApp`). Structural, so a + // fork or a fizzy build that renames itself is still told apart from an app. + const app_is_fizzy = opts.own != null; // Where Report a Bug goes. Not `repo-url` as such, which is the update feed and defaults to // fizzy's: an app reports to its own repo only when it named one (`issues_url.zig`). const app_issues_url = b.option([]const u8, "app-issues-url", "Where Report a Bug goes; empty disables it (default: an explicit repo-url's issues, fizzy's for fizzy)") orelse @@ -317,6 +465,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .app_name = app_name, .app_display_name = app_display_name, .app_bundle_id = app_bundle_id, + .branding = branding, .app_config_dir = app_config_dir, .app_registry_url = app_registry_url, .app_repo_url = app_repo_url, @@ -337,8 +486,6 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .macos_sign_app_identity = macos_sign_app_identity, .macos_sign_install_identity = macos_sign_install_identity, .macos_notary_profile = macos_notary_profile, - .windows_msvc_libc_opt = windows_msvc_libc_opt, - .fetch_msvc = fetch_msvc, .win_libc = win_libc, }; } @@ -389,12 +536,21 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. const macos_sign_app_identity = cfg.macos_sign_app_identity; const macos_sign_install_identity = cfg.macos_sign_install_identity; const macos_notary_profile = cfg.macos_notary_profile; - const windows_msvc_libc_opt = cfg.windows_msvc_libc_opt; - const fetch_msvc = cfg.fetch_msvc; const win_libc = cfg.win_libc; const assetpack = @import("assetpack"); const assets_module = assetpack.pack(b, b.path("assets"), .{}); + // The window's icon, apart from `assets` so an app built on fizzy can give its own. + const app_icon_files = b.addWriteFiles(); + _ = app_icon_files.addCopyFile(cfg.branding.png, "icon.png"); + const app_icon_module = b.createModule(.{ + .root_source_file = app_icon_files.add("app_icon.zig", b.fmt( + \\pub const png = @embedFile("icon.png"); + \\/// Fizzy's icon is pixel art, kept sharp; an app's is smoothed. + \\pub const pixel_art = {}; + \\ + , .{cfg.branding.pixel_art})), + }); // --------------------------------------------------------------- // Web (wasm) build — entirely separate from the native exe so it can't disturb @@ -402,9 +558,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. // --------------------------------------------------------------- - web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); + web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, app_icon_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); - const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, app_icon_module, cfg.branding.rc, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; const package_fizzy: FizzyExecutable = package_blk: { @@ -431,7 +587,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. pack_opts.addOption(bool, "static_image", static_image); pack_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); pack_opts.addOption(bool, "has_app_layout", app_layout_path != null); - break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, app_icon_module, cfg.branding.rc, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); }; const exe_for_package = package_fizzy.exe; @@ -539,6 +695,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .target = target, .optimize = optimize, .app_version = app_version, + .app_name = app_name, + .app_bundle_id = app_bundle_id, + .branding = cfg.branding, .zig_out_subdir = zig_out_subdir, .zig_out_install_dir = zig_out_install_dir, .no_emit = no_emit, @@ -548,8 +707,6 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .macos_sign_app_identity = macos_sign_app_identity, .macos_sign_install_identity = macos_sign_install_identity, .macos_notary_profile = macos_notary_profile, - .windows_msvc_libc_opt = windows_msvc_libc_opt, - .fetch_msvc = fetch_msvc, }); // --------------------------------------------------------------- @@ -694,6 +851,8 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // Where Report a Bug goes when the build isn't told. std-only (see issues_url.zig); the // build is its only user. .{ "fizzy-issues-url-tests", "build/issues_url.zig" }, + // The default bundle id an app built on fizzy gets. std-only, like issues_url.zig. + .{ "fizzy-bundle-id-tests", "build/bundle_id.zig" }, }) |entry| { try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = entry[0], @@ -890,6 +1049,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // The testing backend has no viewports (`backend_web.viewports`). fizzy_test_module.addImport("viewports_none", @import("fizzy_backend").viewportsNoneModule(fizzy_exe.backendDependency(b), dvui_testing_dep.module("dvui_testing"))); fizzy_test_module.addImport("assets", assets_module); + fizzy_test_module.addImport("app_icon", app_icon_module); fizzy_test_module.addImport("build_opts", sdk.buildOptsModule(build_opts)); // Shared `core` module for the test build (dvui testing backend variant). diff --git a/build/bundle_id.zig b/build/bundle_id.zig new file mode 100644 index 000000000..b696443d7 --- /dev/null +++ b/build/bundle_id.zig @@ -0,0 +1,36 @@ +//! The default bundle id of an app built on fizzy, `dev.fizzy.` (`build/app.zig`). +//! std-only, so `zig build test` covers it (`fizzy-bundle-id-tests`) although the build is its +//! only user. + +const std = @import("std"); + +/// `name` as a bundle id component: anything but ASCII letters, digits, `.` and `-` becomes `-` +/// (a run of them, a multi-byte character among them, one `-`); no leading `-` or digit, no +/// trailing `-`; `app` if nothing is left. +pub fn component(gpa: std.mem.Allocator, name: []const u8) []const u8 { + var out: std.ArrayList(u8) = .empty; + for (name) |c| { + const kept = if (std.ascii.isAlphanumeric(c) or c == '.') c else '-'; + if (kept == '-' and (out.items.len == 0 or out.items[out.items.len - 1] == '-')) continue; + if (out.items.len == 0 and std.ascii.isDigit(kept)) continue; + out.append(gpa, kept) catch @panic("OOM"); + } + const trimmed = std.mem.trimEnd(u8, out.items, "-"); + return if (trimmed.len == 0) "app" else trimmed; +} + +test component { + const gpa = std.testing.allocator; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const cases = [_][2][]const u8{ + .{ "myapp", "myapp" }, + .{ "minimal_app", "minimal-app" }, + .{ "my app!!", "my-app" }, + .{ "--9lives", "lives" }, + .{ "Über App", "ber-App" }, + .{ "日本", "app" }, + .{ "", "app" }, + }; + for (cases) |c| try std.testing.expectEqualStrings(c[1], component(arena.allocator(), c[0])); +} diff --git a/build/exe.zig b/build/exe.zig index 22305d6ea..86cd52b6b 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -189,6 +189,10 @@ pub fn addFizzyExecutableForTarget( build_opts: *std.Build.Step.Options, workbench_opts: *std.Build.Step.Options, assets_module: *std.Build.Module, + /// `pub const png`: the window's icon (`app.Branding.png`). + app_icon_module: *std.Build.Module, + /// The resource script embedded on Windows (`app.Branding.rc`). + windows_rc: std.Build.LazyPath, macos_sdl_paths: ?MacosSdlPaths, velopack_enabled: bool, /// The application's short name (see app/AppInfo.zig) — the executable's name. Passed in @@ -239,6 +243,7 @@ pub fn addFizzyExecutableForTarget( if (resolved_target.result.ofmt == .elf) exe.build_id = .fast; exe.root_module.addImport("assets", assets_module); + exe.root_module.addImport("app_icon", app_icon_module); exe.root_module.addImport("build_opts", sdk.buildOptsModule(build_opts)); if (optimize != .Debug) { @@ -406,14 +411,11 @@ pub fn addFizzyExecutableForTarget( } exe.root_module.linkSystemLibrary("comctl32", .{}); - // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer, - // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all - // show the right icon without any runtime work. fizzy.ico must be a - // multi-resolution ICO with 16/32/48/256 px frames (see the README in - // that directory). - exe.root_module.addWin32ResourceFile(.{ - .file = b.path("assets/windows/fizzy.rc"), - }); + // Embed the resource script (fizzy's: assets/windows/fizzy.rc -> fizzy.ico) into the + // exe so Explorer, Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all + // show the right icon without any runtime work. The .ico must be a multi-resolution + // ICO with 16/32/48/256 px frames (see the README in assets/windows). + exe.root_module.addWin32ResourceFile(.{ .file = windows_rc }); } // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers diff --git a/build/package.zig b/build/package.zig index 6a0cf6c86..c96858943 100644 --- a/build/package.zig +++ b/build/package.zig @@ -9,6 +9,11 @@ pub const Options = struct { target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_version: []const u8, + /// `--packId`, and the executable's name (`--mainExe`). + app_name: []const u8, + /// `--bundleId` on macOS when `branding.plist` is null. + app_bundle_id: []const u8, + branding: @import("app.zig").Branding, zig_out_subdir: []const u8, zig_out_install_dir: std.Build.InstallDir, no_emit: bool, @@ -18,8 +23,6 @@ pub const Options = struct { macos_sign_app_identity: ?[]const u8, macos_sign_install_identity: ?[]const u8, macos_notary_profile: ?[]const u8, - windows_msvc_libc_opt: ?[]const u8, - fetch_msvc: bool, }; pub fn addSteps(opts: Options) *std.Build.Step { @@ -37,8 +40,8 @@ pub fn addSteps(opts: Options) *std.Build.Step { const macos_sign_app_identity = opts.macos_sign_app_identity; const macos_sign_install_identity = opts.macos_sign_install_identity; const macos_notary_profile = opts.macos_notary_profile; - const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; - const fetch_msvc = opts.fetch_msvc; + const app_name = opts.app_name; + const branding = opts.branding; const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run"); // The default native target on a Windows host resolves to x86_64-windows-gnu, @@ -47,218 +50,258 @@ pub fn addSteps(opts: Options) *std.Build.Step { // installer, but the install hook never runs: Setup.exe hangs with "the // application install hook failed". Fail loudly instead of shipping that trap. const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc; - if (velopack_required_fail) |fail_step| { - package_step.dependOn(fail_step); - } else if (windows_non_msvc) { - package_step.dependOn(&b.addFail( - \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. - \\The default native target resolves to x86_64-windows-gnu, which builds a binary - \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but - \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). - \\ - \\Build with the MSVC target instead: - \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc - \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) - ).step); - } else if (no_emit) { - package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step); - } else switch (target.result.os.tag) { - .linux, .macos, .windows => { - // Host strip can't process foreign object files when cross-compiling. - const cross_os = target.result.os.tag != b.graph.host.result.os.tag; - // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also - // breaks host strip — it errors with "Unable to recognise the format". - const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; - // Windows hosts don't ship `strip` or `touch`. Skip the external strip - // step entirely there — Zig's linker already drops debug info in - // release builds. Use `cmd /c exit 0` as the no-op and keep the - // dependency on exe_for_package via the step graph. - const host_is_windows = b.graph.host.result.os.tag == .windows; - const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; - const strip_release_sh = if (host_is_windows) blk: { - const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); - sh.step.dependOn(&exe_for_package.step); - break :blk sh; - } else blk: { - const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); - sh.addFileArg(exe_for_package.getEmittedBin()); - break :blk sh; - }; + const failure: ?*std.Build.Step = if (velopack_required_fail) |fail_step| fail_step else if (windows_non_msvc) &b.addFail( + \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. + \\The default native target resolves to x86_64-windows-gnu, which builds a binary + \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but + \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). + \\ + \\Build with the MSVC target instead: + \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc + \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) + ).step else if (no_emit) &b.addFail("cannot run `package` with -Dno-emit").step else switch (target.result.os.tag) { + .linux, .macos, .windows => null, + else => &b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step, + }; + // The installers' directory: vpk's output, or, when this target can't be packaged, one whose + // step fails saying why. Both `package` and an app's (`fizzy.addPackageSteps`) install it. + const installers: std.Build.LazyPath = if (failure) |fail_step| failed: { + const nothing = b.addWriteFiles(); + nothing.step.dependOn(fail_step); + break :failed nothing.getDirectory(); + } else vpk: { + // Host strip can't process foreign object files when cross-compiling. + const cross_os = target.result.os.tag != b.graph.host.result.os.tag; + // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also + // breaks host strip — it errors with "Unable to recognise the format". + const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; + // Windows hosts don't ship `strip` or `touch`. Skip the external strip + // step entirely there — Zig's linker already drops debug info in + // release builds. Use `cmd /c exit 0` as the no-op and keep the + // dependency on exe_for_package via the step graph. + const host_is_windows = b.graph.host.result.os.tag == .windows; + const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; + const strip_release_sh = if (host_is_windows) blk: { + const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); + sh.step.dependOn(&exe_for_package.step); + break :blk sh; + } else blk: { + const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); + sh.addFileArg(exe_for_package.getEmittedBin()); + break :blk sh; + }; - //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); - //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); - //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); + //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); + //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); + //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); - const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); - vpk_pkg_sh.addArg("vpk"); - // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) - // because by default it auto-detects from the host OS. - if (cross_os) { - vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "[win]", - .linux => "[linux]", - .macos => "[osx]", - else => unreachable, - }); - } - vpk_pkg_sh.addArg("pack"); - vpk_pkg_sh.addArg("--packId"); - vpk_pkg_sh.addArg("fizzy"); - vpk_pkg_sh.addArg("--packVersion"); - vpk_pkg_sh.addArg(app_version); - // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into - // the binary by vpk; the updater matches this to release assets. Distinct per triple - // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. - vpk_pkg_sh.addArg("--channel"); - vpk_pkg_sh.addArg(zig_out_subdir); - vpk_pkg_sh.addArg("--mainExe"); + const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); + vpk_pkg_sh.addArg("vpk"); + // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) + // because by default it auto-detects from the host OS. + if (cross_os) { vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "fizzy.exe", - else => "fizzy", + .windows => "[win]", + .linux => "[linux]", + .macos => "[osx]", + else => unreachable, }); + } + vpk_pkg_sh.addArg("pack"); + vpk_pkg_sh.addArg("--packId"); + vpk_pkg_sh.addArg(app_name); + vpk_pkg_sh.addArg("--packVersion"); + vpk_pkg_sh.addArg(app_version); + // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into + // the binary by vpk; the updater matches this to release assets. Distinct per triple + // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. + vpk_pkg_sh.addArg("--channel"); + vpk_pkg_sh.addArg(zig_out_subdir); + vpk_pkg_sh.addArg("--mainExe"); + vpk_pkg_sh.addArg(exe_for_package.out_filename); + vpk_pkg_sh.addArg("--packTitle"); + vpk_pkg_sh.addArg(branding.package_title); - vpk_pkg_sh.addArg("--delta"); - vpk_pkg_sh.addArg("None"); - vpk_pkg_sh.addArg("--yes"); + vpk_pkg_sh.addArg("--delta"); + vpk_pkg_sh.addArg("None"); + vpk_pkg_sh.addArg("--yes"); - vpk_pkg_sh.addArg("--outputDir"); - // `addOutputDirectoryArg` takes a basename — Zig manages the actual - // path under the run step's cache dir. The `addInstallDirectory` - // below copies that into zig-out//. Previously this passed - // the full install path, which produced `.zig-cache\o\\C:\...` - // on Windows (BadPathName). - const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop"); - // Stage exe + built-in plugin dylibs under zig-out//.pack-input/ - // so vpk ships plugins/ next to the main binary. - const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir}); - const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir}); - const pack_stage_tail = exe.addVelopackPackDirInstall( - b, - exe_for_package, - package_fizzy, - pack_input_subdir, - pack_plugins_subdir, - &strip_release_sh.step, - ); - vpk_pkg_sh.addArg("--packDir"); - vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, "")); - switch (target.result.os.tag) { - .windows => { - // Sets the installer's icon and the Start Menu shortcut icon. The - // exe's own icon is already embedded via assets/windows/fizzy.rc. - vpk_pkg_sh.addArg("--icon"); - const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e}); - vpk_pkg_sh.addArg(ico_path); - // Velopack's installer is silent (no shortcut-choice UI). Default is - // Desktop,StartMenu; restrict to StartMenu so we don't drop an - // unrequested icon on the user's desktop. - vpk_pkg_sh.addArg("--shortcuts"); - vpk_pkg_sh.addArg("StartMenu"); - }, - .macos => { - vpk_pkg_sh.addArg("--packTitle"); - vpk_pkg_sh.addArg("fizzy"); - // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist). + vpk_pkg_sh.addArg("--outputDir"); + // `addOutputDirectoryArg` takes a basename — Zig manages the actual + // path under the run step's cache dir. The `addInstallDirectory` + // below copies that into zig-out//. Previously this passed + // the full install path, which produced `.zig-cache\o\\C:\...` + // on Windows (BadPathName). + const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop"); + // Stage exe + built-in plugin dylibs under zig-out//.pack-input/ + // so vpk ships plugins/ next to the main binary. + const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir}); + const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir}); + const pack_stage_tail = exe.addVelopackPackDirInstall( + b, + exe_for_package, + package_fizzy, + pack_input_subdir, + pack_plugins_subdir, + &strip_release_sh.step, + ); + vpk_pkg_sh.addArg("--packDir"); + vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, "")); + switch (target.result.os.tag) { + .windows => { + // Sets the installer's icon and the Start Menu shortcut icon. The + // exe's own icon is already embedded via `branding.rc`. + vpk_pkg_sh.addArg("--icon"); + vpk_pkg_sh.addFileArg(branding.ico); + // Velopack's installer is silent (no shortcut-choice UI). Default is + // Desktop,StartMenu; restrict to StartMenu so we don't drop an + // unrequested icon on the user's desktop. + vpk_pkg_sh.addArg("--shortcuts"); + vpk_pkg_sh.addArg("StartMenu"); + }, + .macos => { + // Bundle id / document types / versions: fizzy's are in assets/macos/info.plist. + // vpk rejects --bundleId with --plist, so an app with no plist of its own + // gets one vpk writes from its bundle id. + if (branding.plist) |plist| { vpk_pkg_sh.addArg("--plist"); - const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e}); - vpk_pkg_sh.addArg(plist_path); - vpk_pkg_sh.addArg("--icon"); - const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e}); - vpk_pkg_sh.addArg(icns_path); - - if (macos_sign_app_identity) |id| { - vpk_pkg_sh.addArg("--signAppIdentity"); - vpk_pkg_sh.addArg(id); - // Required for notarization: enables hardened runtime + secure timestamp on - // every nested binary (vpk forwards the file to `codesign --entitlements`). - // Without this, Apple's notary service rejects with "signature does not - // include a secure timestamp" / "hardened runtime not enabled". - vpk_pkg_sh.addArg("--signEntitlements"); - const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); - vpk_pkg_sh.addArg(entitlements_path); - } - if (macos_sign_install_identity) |id| { - vpk_pkg_sh.addArg("--signInstallIdentity"); - vpk_pkg_sh.addArg(id); - } - if (macos_notary_profile) |profile| { - vpk_pkg_sh.addArg("--notaryProfile"); - vpk_pkg_sh.addArg(profile); - } - }, - else => {}, - } - vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); - // Stream vpk's stdout/stderr live so failures surface their actual - // diagnostic instead of just an exit-code-N message from the build - // runner. With `addOutputDirectoryArg` in play, `infer_from_args` - // can otherwise capture+drop stdio on certain runner configs. - vpk_pkg_sh.stdio = .inherit; - try velopack.attachMksquashfsToVpkRun(b, vz, vpk_pkg_sh, target); + vpk_pkg_sh.addFileArg(plist); + } else { + vpk_pkg_sh.addArg("--bundleId"); + vpk_pkg_sh.addArg(opts.app_bundle_id); + } + vpk_pkg_sh.addArg("--icon"); + vpk_pkg_sh.addFileArg(branding.icns); - //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); - vpk_pkg_sh.step.dependOn(pack_stage_tail); + if (macos_sign_app_identity) |id| { + vpk_pkg_sh.addArg("--signAppIdentity"); + vpk_pkg_sh.addArg(id); + // Required for notarization: enables hardened runtime + secure timestamp on + // every nested binary (vpk forwards the file to `codesign --entitlements`). + // Without this, Apple's notary service rejects with "signature does not + // include a secure timestamp" / "hardened runtime not enabled". + vpk_pkg_sh.addArg("--signEntitlements"); + const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); + vpk_pkg_sh.addArg(entitlements_path); + } + if (macos_sign_install_identity) |id| { + vpk_pkg_sh.addArg("--signInstallIdentity"); + vpk_pkg_sh.addArg(id); + } + if (macos_notary_profile) |profile| { + vpk_pkg_sh.addArg("--notaryProfile"); + vpk_pkg_sh.addArg(profile); + } + }, + .linux => { + // The AppImage's icon (its `.DirIcon` and desktop entry): a PNG. + vpk_pkg_sh.addArg("--icon"); + vpk_pkg_sh.addFileArg(branding.png); + }, + else => unreachable, + } + vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); + // Stream vpk's stdout/stderr live so failures surface their actual + // diagnostic instead of just an exit-code-N message from the build + // runner. With `addOutputDirectoryArg` in play, `infer_from_args` + // can otherwise capture+drop stdio on certain runner configs. + vpk_pkg_sh.stdio = .inherit; + try velopack.attachMksquashfsToVpkRun(b, vz, vpk_pkg_sh, target); - const build_package_install = b.addInstallDirectory(.{ - .source_dir = vpk_pkg_out_dir, - .install_dir = zig_out_install_dir, - .install_subdir = "", - }); + //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); + vpk_pkg_sh.step.dependOn(pack_stage_tail); - package_step.dependOn(&build_package_install.step); - }, - else => { - package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step); - }, - } + break :vpk vpk_pkg_out_dir; + }; + package_step.dependOn(&b.addInstallDirectory(.{ + .source_dir = installers, + .install_dir = zig_out_install_dir, + .install_subdir = "", + }).step); + // For an app built with `buildApp`, which installs them into its own prefix: a dependency's + // prefix is a directory in the cache (`fizzy.addPackageSteps`). + b.addNamedLazyPath("package", installers); const desktop_step = b.step("desktop", "Alias for `zig build package`"); desktop_step.dependOn(package_step); - const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux"); + return package_step; +} + +/// `zig build packageall`: `zig build package` once for each of the six release targets, run from +/// `b`'s build root with the options `b` was given on its command line. `b` is the root builder: +/// fizzy's own (`build.zig`), or an app's built on fizzy (`fizzy.addPackageSteps`), whose +/// `build.zig` passes fizzy its options again in each run. +pub fn addPackageAllStep(b: *std.Build, no_emit: bool) void { + const packageall_step = b.step("packageall", "zig build package for each of the six release targets, with this build's -D options but the target's; -Dwindows-msvc-libc= and -Dfetch-msvc reach the *-windows-msvc runs only"); if (no_emit) { packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step); - } else { - const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)}); - - // Build order is deliberately fail-fast: Windows first (most likely to - // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, - // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage - // packaging quirks), then macOS last (native, lowest risk). When a - // release run is going to break, this ordering surfaces the failure - // 5-10 minutes sooner than the alphabetical order did. - const packageall_triples = [_][]const u8{ - "x86_64-windows-msvc", - "aarch64-windows-msvc", - "x86_64-linux-gnu", - "aarch64-linux-gnu", - "x86_64-macos", - "aarch64-macos", - }; + return; + } - var prev_step: ?*std.Build.Step = null; - for (packageall_triples) |triple| { - const zig_pkg_run = b.addSystemCommand(&.{ - b.graph.zig_exe, - "build", - "package", - packageall_optimize_arg, - b.fmt("-Dtarget={s}", .{triple}), - }); - if (std.mem.endsWith(u8, triple, "-windows-msvc")) { - if (windows_msvc_libc_opt) |libc_path| { - zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path})); - } - if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc"); - } - zig_pkg_run.setCwd(b.path(".")); - if (prev_step) |p| { - zig_pkg_run.step.dependOn(p); - } - prev_step = &zig_pkg_run.step; + // This build's command-line options, sorted so the command lines are the same each run. + // Not the target's own (each run names its own target), and the Windows MSVC ones only for + // the runs that build for it. `-Doptimize` only as given: an app's build may not declare it. + const target_options = [_][]const u8{ "target", "cpu", "ofmt", "dynamic-linker" }; + const msvc_options = [_][]const u8{ "windows-msvc-libc", "fetch-msvc" }; + var common: std.ArrayList([]const u8) = .empty; + var msvc_only: std.ArrayList([]const u8) = .empty; + switch (b.release_mode) { + .off => {}, + .any => common.append(b.allocator, "--release") catch @panic("OOM"), + else => |mode| common.append(b.allocator, b.fmt("--release={s}", .{@tagName(mode)})) catch @panic("OOM"), + } + var it = b.user_input_options.iterator(); + while (it.next()) |entry| { + const name = entry.key_ptr.*; + if (isOneOf(name, &target_options)) continue; + const list = if (isOneOf(name, &msvc_options)) &msvc_only else &common; + switch (entry.value_ptr.value) { + .flag => list.append(b.allocator, b.fmt("-D{s}", .{name})) catch @panic("OOM"), + .scalar => |v| list.append(b.allocator, b.fmt("-D{s}={s}", .{ name, v })) catch @panic("OOM"), + .list => |l| for (l.items) |v| list.append(b.allocator, b.fmt("-D{s}={s}", .{ name, v })) catch @panic("OOM"), + // Only a `b.dependency` call passes these, never a command line. + .map, .lazy_path, .lazy_path_list => {}, } - packageall_step.dependOn(prev_step.?); } + for ([_][][]const u8{ common.items, msvc_only.items }) |items| std.mem.sort([]const u8, items, {}, struct { + fn lessThan(_: void, l: []const u8, r: []const u8) bool { + return std.mem.lessThan(u8, l, r); + } + }.lessThan); - return package_step; + // Build order is deliberately fail-fast: Windows first (most likely to + // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, + // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage + // packaging quirks), then macOS last (native, lowest risk). When a + // release run is going to break, this ordering surfaces the failure + // 5-10 minutes sooner than the alphabetical order did. + const packageall_triples = [_][]const u8{ + "x86_64-windows-msvc", + "aarch64-windows-msvc", + "x86_64-linux-gnu", + "aarch64-linux-gnu", + "x86_64-macos", + "aarch64-macos", + }; + + var prev_step: ?*std.Build.Step = null; + for (packageall_triples) |triple| { + const zig_pkg_run = b.addSystemCommand(&.{ b.graph.zig_exe, "build", "package", b.fmt("-Dtarget={s}", .{triple}) }); + zig_pkg_run.addArgs(common.items); + if (std.mem.endsWith(u8, triple, "-windows-msvc")) zig_pkg_run.addArgs(msvc_only.items); + // This build's own build file, from the cwd this build was run in (inherited): a + // relative path option (`-Dapp-icon=…`) is relative to that cwd, as it was here. + zig_pkg_run.addArgs(&.{ "--build-file", b.pathFromRoot("build.zig") }); + if (prev_step) |p| zig_pkg_run.step.dependOn(p); + prev_step = &zig_pkg_run.step; + } + packageall_step.dependOn(prev_step.?); +} + +fn isOneOf(name: []const u8, names: []const []const u8) bool { + for (names) |n| { + if (std.mem.eql(u8, name, n)) return true; + } + return false; } diff --git a/build/web.zig b/build/web.zig index d0075f6f6..6688a1a1f 100644 --- a/build/web.zig +++ b/build/web.zig @@ -10,6 +10,8 @@ pub fn addSteps( build_opts: *std.Build.Step.Options, workbench_opts: *std.Build.Step.Options, assets_module: *std.Build.Module, + /// The window's icon (`app.Branding.png`); `src/Entry.zig` imports it. + app_icon_module: *std.Build.Module, app_plugins: []const sdk.BundledPlugin, web_plugin_deps: []const []const u8, web_plugin_dirs: []const @import("app.zig").WebPluginDir, @@ -141,6 +143,7 @@ pub fn addSteps( // `assets` is generated at build time by assetpack (pure `@embedFile`s, // target-independent). Same instance as native — no extra build cost. web_exe.root_module.addImport("assets", assets_module); + web_exe.root_module.addImport("app_icon", app_icon_module); // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared // with native. velopack_enabled is whatever was passed via `-Dvelopack`; diff --git a/src/Entry.zig b/src/Entry.zig index 792624f1e..004d7c630 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -5,9 +5,8 @@ const build_opts = @import("build_opts"); /// The app's version for SDL's app metadata, which takes a C string. const app_version_z = std.fmt.comptimePrint("{s}", .{build_opts.app_version}); -const assets = @import("assets"); - -const icon = assets.files.@"icon.png"; +/// The app's own (`-Dapp-icon`), fizzy's by default. +const icon = @import("app_icon").png; const fizzy = @import("fizzy.zig"); const workbench = @import("workbench"); diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 727fe82a9..ff883612f 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -3,7 +3,6 @@ const fizzy = @import("../fizzy.zig"); const AppInfo = @import("app").AppInfo; const dvui = @import("dvui"); const icons = @import("icons"); -const assets = @import("assets"); const update_notify = @import("app").update.update_notify; const update_install = @import("app").update.update_install; const restart = @import("app").restart; @@ -72,9 +71,9 @@ pub fn draw(_: Infobar, editor: *fizzy.Editor) !void { // it and never grows the bar. const logo_side = infobar.iconSide(); const logo: dvui.ImageSource = .{ .imageFile = .{ - .bytes = assets.files.@"icon.png", + .bytes = @import("app_icon").png, .name = "icon.png", - .interpolation = .nearest, + .interpolation = if (@import("app_icon").pixel_art) .nearest else .linear, } }; { // Fixed slot (min == max) so the artwork fits the bar instead of dictating its diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index 33d302eda..e13b7d872 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -22,7 +22,6 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); -const assets = @import("assets"); const core = @import("core"); const fizzy = @import("../fizzy.zig"); const PluginSettingsPane = @import("app").settings.PluginPane; @@ -582,9 +581,9 @@ fn drawIdentityIcon(branch: *const Branch, style: RowStyle, color: dvui.Color) v // `icon.png` is the pixel-art F (not `fox.png`). Use `.imageFile` so dvui caches the // texture — `fromImageFileBytes` re-decodes and leaks a 1024² buffer every frame. const logo: dvui.ImageSource = .{ .imageFile = .{ - .bytes = assets.files.@"icon.png", + .bytes = @import("app_icon").png, .name = "icon.png", - .interpolation = .nearest, + .interpolation = if (@import("app_icon").pixel_art) .nearest else .linear, } }; _ = dvui.image(@src(), .{ .source = core.image.cachedTexture(logo), .shrink = .ratio }, core.widgets.treeRowIconOptions(.{})); return; From f60c1c7a13018c4ee0327a2a3c9889ea75485f03 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:02:01 -0500 Subject: [PATCH 13/31] automation: key pills are the transport bar's glass (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#27 (one glass material). This only moves the pills onto core's shared glass call; the material itself is that issue's. ## What changes The key and command pills that a demo or a working agent shows (and the "Catching up…" seek pill) are now the same glass as the player's transport bar. Before, they were a frosted text pane (`frostPane`, which `LiquidField` draws with the text look: more of the window's colour, a hairline edge). Their keycaps were also solid control-fill boxes, so the glass showed only as a thin rim around a dark slab, which read as opaque, especially over a dialog. - `app/automation/overlay.zig`: `pillGlass` draws a pill through `core.dialogs.glassBubbles`, the call the transport bar uses. It grows out of its origin as before, and the shadow fades with it. `glass` and `pillGlass` share `grown`. - `core/keycaps.zig`: `DrawOptions.cap_fill`, the colour of a `.caps` keycap; the control fill when unset, so the command palette and keybind settings are unchanged. The pills use the text colour at 10%, so the glass reads through the caps. Caption cards keep `frostPane`: `glassBubbles` draws capsules only, and a card several lines tall can't be one. Under plans#27's single material they join through the shared card call. Tried and dropped: drawing the pills as the OS's Liquid Glass (`native_glass.add`, as `carriedCard` does). The in-app glass is what was wanted. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag `test-sdk-version` passes: the fingerprint did not move. ## Verified - [x] macOS: `FIZZY_DEMO=tour` in a sandbox profile, window screenshots through the run. The ⏎ pill is a capsule with the bar bubbles' edge and a faint cap. Not seen over a dialog. `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds (`check-web`); not looked at. ## Follow-ups - Caption cards onto the same material, through plans#27's shared glass-card call. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/automation/overlay.zig | 42 ++++++++++++++++++++++++++------------ core/keycaps.zig | 12 +++++++---- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/app/automation/overlay.zig b/app/automation/overlay.zig index 3f0029d88..f3eb017fe 100644 --- a/app/automation/overlay.zig +++ b/app/automation/overlay.zig @@ -149,17 +149,8 @@ const Reveal = struct { /// Before the alpha for its contents is set: glass forms rather than fades (`r.form`) — a frost /// replaces what it covers, and at partial alpha would punch a hole. fn glass(wd: *dvui.WidgetData, origin: dvui.Point, r: Reveal) void { - const brs = wd.borderRectScale(); - const full = brs.r; - const s = brs.s; - const k = @max(0, r.grow); - const o: dvui.Point.Physical = .{ .x = full.x + full.w * origin.x, .y = full.y + full.h * origin.y }; - const rect: dvui.Rect.Physical = .{ - .x = o.x + (full.x - o.x) * k, - .y = o.y + (full.y - o.y) * k, - .w = full.w * k, - .h = full.h * k, - }; + const s = wd.borderRectScale().s; + const rect = grown(wd, origin, r); const c = corners(); const bs = core.dialogs.surfaceShadow(); if (core.dialogs.dialogFrost()) |f| { @@ -185,6 +176,29 @@ fn glass(wd: *dvui.WidgetData, origin: dvui.Point, r: Reveal) void { dvui.clipSet(dvui.clipGet().intersect(rect)); } +/// The glass under a pill — a key just pressed, the seek's progress — `r` of the way open, grown +/// out of `origin` as `glass` grows a card: a capsule of the same glass as the transport bar's +/// bubbles (`core.dialogs.glassBubbles`), so the player's controls and what it shows are one +/// material. What the pill draws after this is cut to the glass as it grows. +fn pillGlass(wd: *dvui.WidgetData, origin: dvui.Point, r: Reveal) void { + const rect = grown(wd, origin, r); + core.dialogs.glassBubbles(wd.id, &.{.{ .rect = rect }}, wd.borderRectScale().s, .{ .shadow = r.alpha }); + dvui.clipSet(dvui.clipGet().intersect(rect)); +} + +/// `wd`'s border rect `r.grow` of the way grown out of `origin` (a point in it, as fractions). +fn grown(wd: *dvui.WidgetData, origin: dvui.Point, r: Reveal) dvui.Rect.Physical { + const full = wd.borderRectScale().r; + const k = @max(0, r.grow); + const o: dvui.Point.Physical = .{ .x = full.x + full.w * origin.x, .y = full.y + full.h * origin.y }; + return .{ + .x = o.x + (full.x - o.x) * k, + .y = o.y + (full.y - o.y) * k, + .w = full.w * k, + .h = full.h * k, + }; +} + // ---- captions and keys --------------------------------------------------------------------- /// How long a key or command stays among the popups at home. @@ -538,12 +552,14 @@ fn keysCard(player: *Player, op: Tape.Op, shown: Reveal) void { const label = keysLabel(player, op) orelse return; var pill = dvui.box(@src(), .{ .dir = .horizontal }, cardOptions(.{ .x = 14, .y = 7, .w = 14, .h = 7 })); defer pill.deinit(); - glass(pill.data(), .{ .x = 0.5, .y = 1 }, shown); + pillGlass(pill.data(), .{ .x = 0.5, .y = 1 }, shown); const prev_alpha = dvui.alpha(shown.alpha); defer dvui.alphaSet(prev_alpha); if (label.stroke) |s| { core.keycaps.draw(@src(), keycapsStroke(s), .{ .style = .caps, + // Faint caps, so the glass reads through them as it does through the bar's icons. + .cap_fill = theme().color(.content, .text).opacity(0.1), .color = theme().color(.content, .text), .mac = core.platform.isMacOS(), .gravity_x = 0, @@ -831,7 +847,7 @@ fn drawSeeking(player: *Player, win: dvui.Rect) void { var pill = dvui.box(@src(), .{ .dir = .vertical }, cardOptions(.{ .x = 14, .y = 7, .w = 14, .h = 9 })); defer pill.deinit(); // A seek runs with motion off, so there is no opening to play. - glass(pill.data(), .{ .x = 0.5, .y = 0 }, .{}); + pillGlass(pill.data(), .{ .x = 0.5, .y = 0 }, .{}); dvui.labelNoFmt(@src(), "Catching up\u{2026}", .{}, .{ .color_text = .{ .color = theme().color(.content, .text) } }); const wd = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .w = 120, .h = 3 } }); const r = wd.rectScale().r; diff --git a/core/keycaps.zig b/core/keycaps.zig index 652249975..523db6cb9 100644 --- a/core/keycaps.zig +++ b/core/keycaps.zig @@ -181,6 +181,9 @@ pub const DrawOptions = struct { gravity_y: f32 = 0.5, /// Force a platform, for tests and for a keymap editor showing another platform's bindings. mac: ?bool = null, + /// What a `.caps` keycap is filled with; the control fill when null. Something translucent + /// for caps drawn on glass. + cap_fill: ?dvui.Color = null, }; /// Draw `stroke`. A two-part chord draws both halves with a gap between them. @@ -189,6 +192,7 @@ pub fn draw(src: std.builtin.SourceLocation, stroke: Stroke, opts: DrawOptions) const base = opts.color orelse theme.color(.control, .text).opacity(0.55); const color = if (opts.enabled) base else base.opacity(0.5); const mac = opts.mac orelse platform.isMacOS(); + const cap_fill = opts.cap_fill orelse theme.color(.control, .fill); var row = dvui.box(src, .{ .dir = .horizontal }, .{ .id_extra = opts.id_extra, @@ -197,14 +201,14 @@ pub fn draw(src: std.builtin.SourceLocation, stroke: Stroke, opts: DrawOptions) }); defer row.deinit(); - drawChord(stroke.first, 0, color, mac, opts.style); + drawChord(stroke.first, 0, color, mac, opts.style, cap_fill); if (stroke.second) |second| { _ = dvui.spacer(@src(), .{ .min_size_content = .width(6) }); - drawChord(second, 1, color, mac, opts.style); + drawChord(second, 1, color, mac, opts.style, cap_fill); } } -fn drawChord(chord: Chord, which: usize, color: dvui.Color, mac: bool, style: Style) void { +fn drawChord(chord: Chord, which: usize, color: dvui.Color, mac: bool, style: Style, cap_fill: dvui.Color) void { var mod_buf: [4]Face = undefined; const mods = modifierFaces(chord.mods, mac, &mod_buf); var faces: [5]Face = undefined; @@ -226,7 +230,7 @@ fn drawChord(chord: Chord, which: usize, color: dvui.Color, mac: bool, style: St .padding = .{ .x = 5, .y = 1, .w = 5, .h = 1 }, .corners = @import("corners.zig").all(4), .background = true, - .color_fill = .{ .color = dvui.themeGet().color(.control, .fill) }, + .color_fill = .{ .color = cap_fill }, .min_size_content = .{ .w = 12 }, }); defer cap.deinit(); From 42215d200a887b4f74a8f13ab8a4fb66810c4279 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:19:26 -0500 Subject: [PATCH 14/31] core: tabs and rail icons show a hover look (#542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #524 (the hover look; the hand cursor and touch hover move to #547) ## What changes Panel tabs, rail icons and document tabs now show a hover look. All colours come from the theme, and nothing allocates per frame. - **Panel tab labels:** `Chooser.label` takes `hovered` (wired in `Layout.tabsIn` from `Item.hovered()`). An unselected label brightens from `control.text` to `window.text`, as rail icons already did. - **Rail icons:** `Chooser.icon` takes the item's `WidgetData` (from `Item.data`). An unselected hovered icon sits on the row hover wash (`core.dialogs.rowHover()`) over the cell's border rect, rounded with `corners.row`. - **Document tabs:** an inactive tab under the pointer is filled with `window.fill_hover` at the content opacity. The colour comes from this frame's hover and is passed to the tab's own `borderAndBackground(.{ .fill_color })`, so nothing is stacked and the tab's options aren't touched. The close button and dirty dot behave as before. **`core.widgets.hovered`** (also used by `Menu.zig`) changes in two ways: - It now decides from where the pointer ends the frame: dvui's single `.position` event, which is always the frame's last. Before, it used the first mouse event that matched. - It is false while the widget holds the pointer itself (pressed, or dragged along its strip), so a tab dragged across its strip doesn't stay lit. It still checks the widget's border rect. Hover is computed lazily, where it's read (`Item.hovered()`, the document tab). ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag The fingerprint didn't move (`zig build test-sdk-version` passes). ## Verified - [x] macOS: - **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-integration` (53/53 steps, 561/561), `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached`. - **Sandbox fizzy** (`--profile`), driven by a demo tape and captured with the agent plugin's `fizzy_capture` (display asleep): - a hovered inactive document tab measures (36,37,45), against (23,24,30) at rest; - the hovered Plugins rail cell shows the (62,64,78) wash and is clear at rest. - **Not seen:** the panel-tab label hover, because that layout had no strip with several views. - [ ] Windows: - [ ] Linux: - [ ] Web: builds (`check-web`), not seen. ## Follow-ups Tracked in #547: - **Hand cursor:** it needs a cursor-priority seam. `dvui.cursorSet` is first-wins, and a split is drawn after the region before it, so a tab asking for `.hand` would steal a neighbouring split's resize cursor near the sash. - **Touch hover:** it needs a pointer-kind seam. dvui leaves its pointer where a touch lifted, so a tapped item can stay lit, and on native SDL reports a trackpad's touches as fingers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/Chooser.zig | 22 +++++++++++++++++----- app/layout/Layout.zig | 2 +- core/widgets.zig | 23 +++++++++++------------ plugins/workbench/src/Workspace.zig | 6 +++++- src/editor/Sidebar.zig | 2 +- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/app/layout/Chooser.zig b/app/layout/Chooser.zig index 6240f55bc..26f4b9fca 100644 --- a/app/layout/Chooser.zig +++ b/app/layout/Chooser.zig @@ -376,13 +376,19 @@ fn placeName(f: *Layout, place: *const Region) ?[]const u8 { // ── The two stock looks ──────────────────────────────────────────────────────────────────────── -/// A tab's look: the view's title, uppercase, in the heading font, highlighted when selected. -pub fn label(view: *const Layout.Surface, selected: bool) void { +/// A tab's look: the view's title, uppercase, in the heading font, highlighted when selected and +/// brightened under the pointer. +pub fn label(view: *const Layout.Surface, selected: bool, hovered: bool) void { var buf: [64]u8 = undefined; const title = if (view.title.len <= buf.len) std.ascii.upperString(&buf, view.title) else view.title; const theme = dvui.themeGet(); dvui.labelNoFmt(@src(), title, .{}, .{ - .color_text = .{ .color = if (selected) theme.color(.highlight, .fill) else theme.color(.control, .text) }, + .color_text = .{ .color = if (selected) + theme.color(.highlight, .fill) + else if (hovered) + theme.color(.window, .text) + else + theme.color(.control, .text) }, .font = dvui.Font.theme(.heading), .padding = dvui.Rect.all(4), .gravity_y = 0.5, @@ -390,9 +396,15 @@ pub fn label(view: *const Layout.Surface, selected: bool) void { } /// A rail's look: the view's icon, `size` points tall and centred, highlighted when selected and -/// brightened under the pointer. A view with no tvg icon gets a dot rather than no item. -pub fn icon(view: *const Layout.Surface, selected: bool, hovered: bool, size: f32, id_extra: usize) void { +/// brightened under the pointer, on a soft row-hover wash (`core.dialogs.rowHover`) across +/// `cell` (the item's box, `Item.data`) at the hover-wash radius. A view with no tvg icon gets a dot rather than no +/// item. Draw it first inside the item, so the wash sits under the icon. +pub fn icon(view: *const Layout.Surface, selected: bool, hovered: bool, cell: *dvui.WidgetData, size: f32, id_extra: usize) void { const theme = dvui.themeGet(); + if (hovered and !selected) { + const rs = cell.borderRectScale(); + rs.r.fill(.all(core.corners.scaled(core.corners.row) * rs.s), .{ .color = .{ .color = core.dialogs.rowHover() }, .fade = 1.0 }); + } const color: dvui.Color = if (selected) theme.color(.highlight, .fill) else if (hovered) diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index f85619d14..4b9cfd225 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -1284,7 +1284,7 @@ pub fn tabsIn(f: *Layout, r: *const Region) void { for (strip.views()) |view| { var it = strip.item(@src(), view, .{}); defer it.deinit(); - Chooser.label(view, it.selected); + Chooser.label(view, it.selected, it.hovered()); } } diff --git a/core/widgets.zig b/core/widgets.zig index 189beffde..efec29f9b 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -393,20 +393,19 @@ pub fn floatingWindow(src: std.builtin.SourceLocation, floating_opts: FloatingWi return ret; } +/// Under the pointer, for a hover look. Decided by where the pointer ends the frame: dvui's one +/// `.position` event, always the frame's last. Not while the widget holds the pointer itself +/// (pressed, or dragged along), so a tab carried across its strip does not stay lit. pub fn hovered(wd: *dvui.WidgetData) bool { - for (dvui.events()) |*event| { - if (!dvui.eventMatchSimple(event, wd)) { - continue; - } - - switch (event.evt) { - .mouse => |mouse| { - return wd.borderRectScale().r.contains(mouse.p); - }, - else => {}, - } + if (dvui.captured(wd.id)) return false; + const events = dvui.events(); + var i = events.len; + while (i > 0) { + i -= 1; + const e = &events[i]; + if (e.evt != .mouse or e.evt.mouse.action != .position) continue; + return dvui.eventMatchSimple(e, wd) and wd.borderRectScale().r.contains(e.evt.mouse.p); } - return false; } diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 2b7762dba..2de84b1a1 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -312,7 +312,11 @@ fn drawTabs(self: *Workspace, region: sdk.Host.Region, tabs: []const *sdk.Surfac // Not under a carried tab: what is under it is where it would go in, not a tab to close. const tab_hovered = carried == null and core.widgets.hovered(hbox.data()); - hbox.drawBackground(); + // A tab not on show lifts to the window's hover fill under the pointer. + hbox.data().borderAndBackground(.{ .fill_color = if (tab_hovered and !is_selected) + .{ .color = dvui.themeGet().color(.window, .fill_hover).opacity(runtime.host().contentOpacity()) } + else + null }); if (!is_selected and pane_is_active and carried == null) { // Edge shadows between the active tab and its neighbours. diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index fca4c18c3..26291822e 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -207,7 +207,7 @@ fn drawIcon(editor: *Editor, c: *Chooser, view: *Layout.Surface, index: usize) f // topmost icon(s) actually sit inside the strip — anything below is registered harmlessly. if (fizzy.backend.custom_titlebar) Editor.pushTitleStripRect(it.data().rectScale().r); - Chooser.icon(view, it.selected, it.hovered(), rail_icon, index); + Chooser.icon(view, it.selected, it.hovered(), it.data(), rail_icon, index); // Attention badge, same idiom (and colour) as the infobar's app-update dot: a plugin build // is sitting in `plugins/` waiting for the user to say whether to load it, and the card that From 9df1a04292cb25966e67d74c2e2b2689fa793b9a Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:19:50 -0500 Subject: [PATCH 15/31] app: the scrub bar grows into a strip of the document's history as pictures (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), steps 5 and 6: the first of two PRs that make export an extension of the scrub bar. This one grows the bar into a strip of pictures while you scrub. The next adds an export button to the strip that opens a trim window dragged from both sides, and exports what the window holds. ## What changes **The strip.** Scrubbing grows the transport upward into a strip of the document's history as pictures, the way a video's scrubber shows frames: - **What scrubbing is:** a drag on the line, or an undo or redo held down (a second repeat). - **How long it stays:** the strip lingers like the bar does, and stays while the pointer is on the bar. - **The tiles:** each tile stands for a stretch of the history and shows the picture nearest its middle. The tile under the knob is outlined. - **Pressing a picture** goes to that point, as a press on the line does. - **Gaps:** a stretch with no picture stays plain glass until a scrub passes over it. - **Its corners:** the line's bubble is a full capsule round the line alone, and eases to the card radius (`core.corners.card`, at the person's roundness) as it grows, so the pictures sit inside its corners. `core.dialogs.Bubble` gains an optional `radius` for it; without one a bubble is a capsule as before. **The pictures** (`app/HistoryFrames.zig`, one per document pane): - **What a picture is:** the pane as it was drawn, cropped above the bar as it stands (its strip included, so no picture holds the strip), read from the frame the app already keeps in a texture (`FrameTarget.snapshot`, which now takes an output size and scales on the GPU). - **When one is taken:** the frame after the document's track moves (a step recorded, undone, redone, or scrubbed to), from the frame that first drew the move. The move is seen after the document draws, so an edit made in or before its draw is in that frame, and a scrub's jump (made after the document drew) is seen, and drawn, the frame after. A drag across the line takes a picture at each stop it lands on. - **No plugin involvement:** pictures are keyed by the history entry on top, so any owner's documents have them, with no SDK change. - **Limits:** at most 64 a document (192×96 each, under 5 MB), the oldest taken going first. They're freed with the pane. - **Only while recording:** a document without a track has no strip, and the bar is as before. **Measured** in a running Debug fizzy, profiled over 5 s while scrubbing back and forth: `history.frame` averaged 0.05 ms where it ran, 0.44 ms at most, on about one frame in ten. Nothing runs on a frame where the history did not move. ## Review fixes - **No strip inside pictures.** A picture was cropped a fixed 76 px above the pane's bottom, so pictures taken while the strip stood (about 120 px) held the strip; it is now cropped at the bar's top. - **Pictures fill in while scrubbing.** A two-frame wait was reset by every move, so a continuous drag took no pictures until it stopped; it was not needed (see "When one is taken") and is gone. - **The tile search stops at its stretch's ends.** It ran on to the whole history per tile while one end was passed. - **The memory note was wrong** (96 at 240×120 is 11 MB): now 64 at 192×96, under 5 MB. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **In a running fizzy** (its own profile, tracks on, driven through the agent plugin's bridge, seen through fizzy's frame capture): after a few edits, a drag back and forth on `history.line` grew the bar into a strip of pictures of the document at its steps, the current one outlined, and the strip went back down a few seconds after. After the review fixes, a 1.5 s drag across five fresh steps filled every tile, and none of the pictures contains the strip. - **Not covered by a test:** the headless test backend has no render targets, so no pictures are taken there; the strip is seen, not tested. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. On the web the frame is kept in a texture only when something asks (`FrameTarget.want`), which a pending picture does. ## Follow-ups - **Export from the strip** (next PR): an export button on the strip opens a trim window over it, dragged from both sides. The export holds what you scrubbed: the document at the window's left edge, then each history step to its right edge. It reuses #497's yellow bracket and trim rules. - **Pictures for steps never seen while open** (from before the session, or skipped by a fast scrub) stay plain. An owner-drawn preview hook could fill them; not needed yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/HistoryFrames.zig | 82 ++++++++++++++++++++++++++++ app/HistoryTransport.zig | 113 ++++++++++++++++++++++++++++++++------- app/layout/Floats.zig | 2 +- app/layout/ViewDrag.zig | 2 +- app/root.zig | 2 + core/dialogs.zig | 17 ++++-- core/gfx/FrameTarget.zig | 14 ++--- src/editor/Editor.zig | 27 +++++++++- 8 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 app/HistoryFrames.zig diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig new file mode 100644 index 000000000..84e2b2e89 --- /dev/null +++ b/app/HistoryFrames.zig @@ -0,0 +1,82 @@ +//! A document's history as pictures: a small copy of its pane at each step it has stood at, for +//! the transport's strip (`HistoryTransport`), as a video's scrubber shows frames. +//! +//! Owner-agnostic: a picture is what the pane drew, read from the frame the app already keeps in +//! a texture (`core.FrameTarget.snapshot`, scaled down on the GPU), so any document has frames +//! without its owner knowing. Taken only after the document's history moved (`moved`): the frame +//! after an edit lands, an undo, a redo, a scrub's jump. Each is the pane above the bar, keyed by +//! the history entry on top then (0 before any). A step the document never stood at while open +//! here has no picture: the strip shows it plain until a scrub passes over it. +//! +//! Costs nothing on a frame the history did not move; on one it did, a copy of the pane into a +//! texture the strip's size. At most `cap` pictures a document, the oldest taken going first. +const HistoryFrames = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); + +/// Each picture, by the history entry on top when it was taken; oldest taken first. +frames: std.AutoArrayHashMapUnmanaged(u64, dvui.Texture) = .empty, +/// The entry on top the frame just drawn shows, to be taken from it at the next (`take`). +due: ?u64 = null, + +/// At most this many pictures a document: 64 of 192 by 96 is under 5 MB. +pub const cap = 64; +/// A picture's height in pixels; its width keeps the pane's shape, up to `max_w`. +pub const height: u32 = 96; +pub const max_w: u32 = 192; + +/// The document's history moved and now has `top` (its entry on top, 0 for none) on top, and the +/// frame the app is drawing shows it: the next frame takes the picture from it. The app asks after +/// the document draws, and sees a move once its owner has reported it: an edit made in the +/// document's own draw or before it is in the frame, and the transport's jumps (made after the +/// document drew) are seen the frame after, which draws them. +pub fn moved(self: *HistoryFrames, top: u64) void { + self.due = top; + // Keep the frame in a texture to read from, on a backend that would draw it straight to the + // window (the web). + core.FrameTarget.want(); + dvui.refresh(null, @src(), null); +} + +/// Before the document draws: take the picture asked for (`moved`) from what the last frame drew of +/// `pane` (physical), above `bar_h` (physical) of its bottom, where the transport and the feed +/// are drawn over it: the bar's height as it stands, its strip included, so a picture never holds +/// the strip itself. +pub fn take(self: *HistoryFrames, gpa: std.mem.Allocator, pane: dvui.Rect.Physical, bar_h: f32) void { + const top = self.due orelse return; + self.due = null; + var r = pane; + r.h -= bar_h; + if (r.w < 8 or r.h < 8) return; + const prof = core.profile.begin("fizzy", "history.frame"); + defer prof.end(); + const h: f32 = @floatFromInt(height); + const w: u32 = @intFromFloat(@min(@as(f32, @floatFromInt(max_w)), @round(h * r.w / r.h))); + const tex = core.FrameTarget.snapshot(r, .{ @max(w, 1), height }) orelse return; + const gop = self.frames.getOrPut(gpa, top) catch { + dvui.textureDestroyLater(tex); + return; + }; + if (gop.found_existing) { + // Taken again: the newest, so the last to go. + dvui.textureDestroyLater(gop.value_ptr.*); + _ = self.frames.orderedRemove(top); + self.frames.put(gpa, top, tex) catch dvui.textureDestroyLater(tex); + } else gop.value_ptr.* = tex; + while (self.frames.count() > cap) { + dvui.textureDestroyLater(self.frames.values()[0]); + self.frames.orderedRemoveAt(0); + } +} + +/// The picture of the document with `top` on top, if one was taken. +pub fn get(self: *const HistoryFrames, top: u64) ?dvui.Texture { + return self.frames.get(top); +} + +pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void { + for (self.frames.values()) |t| dvui.textureDestroyLater(t); + self.frames.deinit(gpa); +} diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index 3b471d71a..a61caa173 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -7,6 +7,11 @@ //! Over any owner's history (`Plugin.historyLine`, `historyGoTo`); an owner without them has no //! transport. Nothing runs for a bar that is not showing: no history is asked for, no timer is //! set. One per document pane, kept by the app beside the pane. +//! +//! **The strip.** Given the document's pictures (`DrawOptions.frames`, `HistoryFrames`), scrubbing +//! (a drag on the line, a held undo or redo) grows the bar upward into a strip of them, as a +//! video's scrubber shows frames: each tile the document at a stretch of its history, the nearest +//! step to the tile's middle that has a picture. It stays while the pointer is on the bar. const HistoryTransport = @This(); const std = @import("std"); @@ -17,12 +22,16 @@ const icons = @import("icons"); const rules = @import("history_transport.zig"); shown: rules.Shown = .{}, +/// The strip: grown while scrubbing, and while the pointer is on the bar after. +strip: rules.Shown = .{}, repeat: rules.Repeat = .{}, /// The stop the pointer holds the knob at while it drags the line, until the drag ends. Only /// while the bar holds the mouse: a drag whose capture went (the bar stopped drawing) is over. drag: ?usize = null, const bar_h: f32 = 40; +/// How much taller the bar grows for its strip. +const strip_h: f32 = 66; const button_d: f32 = 34; const gap: f32 = 10; const max_w: f32 = 460; @@ -34,10 +43,13 @@ pub fn stir(self: *HistoryTransport, now_ns: i128) void { } /// A press of undo or redo (`forward`) at `now_ns` on this document: the bar shows, and the -/// press moves this many steps (`history_transport.Repeat`: more the longer the key is held). +/// press moves this many steps (`history_transport.Repeat`: more the longer the key is held). A +/// held key is scrubbing: the strip grows. pub fn press(self: *HistoryTransport, now_ns: i128, forward: bool) usize { self.stir(now_ns); - return self.repeat.press(now_ns, forward); + const by = self.repeat.press(now_ns, forward); + if (self.repeat.run > 1) self.strip.stir(now_ns); + return by; } /// Where the bar is over `area` (a document's pane, physical) while it shows, as far as the @@ -45,16 +57,18 @@ pub fn press(self: *HistoryTransport, now_ns: i128, forward: bool) usize { /// Null when it is not showing at `now_ns`, or the pane is too narrow for it. pub fn place(self: *const HistoryTransport, area: dvui.Rect.Physical, now_ns: i128) ?dvui.Rect.Physical { if (self.drag == null and self.shown.openness(now_ns) <= 0) return null; - const whole = geometry(area.toNatural()) orelse return null; + const whole = geometry(area.toNatural(), self.strip.openness(now_ns)) orelse return null; return dvui.windowRectScale().rectToPhysical(whole.outsetAll(4)); } -/// The bar's rect over a pane's natural rect, or null when the pane is too narrow for it. -fn geometry(nat: dvui.Rect.Natural) ?dvui.Rect { +/// The bar's rect over a pane's natural rect, its strip `grown` (0 to 1) of the way up, or null +/// when the pane is too narrow for it. +fn geometry(nat: dvui.Rect.Natural, grown: f32) ?dvui.Rect { const w = @min(max_w, nat.w - 24); if (w < 3 * button_d) return null; - const center: dvui.Point = .{ .x = nat.x + nat.w / 2, .y = nat.y + nat.h - 14 - bar_h / 2 }; - return .{ .x = center.x - w / 2, .y = center.y - bar_h / 2, .w = w, .h = bar_h }; + const h = bar_h + strip_h * grown; + const bottom = nat.y + nat.h - 14; + return .{ .x = nat.x + nat.w / 2 - w / 2, .y = bottom - h, .w = w, .h = h }; } /// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. @@ -66,8 +80,12 @@ pub const DrawOptions = struct { /// A share button at the bar's end: the app has a way to share how the document was made /// (fizzy: its track as a tape). What a press does is the app's (`Pressed.share`). share: bool = false, + /// The document's pictures, for the strip scrubbing grows; null: no strip. + frames: ?*const HistoryFrames = null, }; +const HistoryFrames = @import("HistoryFrames.zig"); + /// What was pressed on the bar this frame that is the app's to act on. pub const Pressed = struct { share: bool = false, @@ -79,7 +97,14 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica var pressed: Pressed = .{}; const now = dvui.frameTimeNS(); // The pointer on it, or a drag in it, keeps it. - if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now); + if (self.drag != null) { + self.shown.stir(now); + if (opts.frames != null) self.strip.stir(now); + } else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) { + self.shown.stir(now); + // On the bar, a strip grown stays. + if (self.strip.openness(now) > 0) self.strip.stir(now); + }; const p = self.shown.openness(now); if (p <= 0) return pressed; const arena = dvui.currentWindow().arena(); @@ -88,15 +113,17 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const wrs = dvui.windowRectScale(); const s = wrs.s; - const whole = geometry(area.toNatural()) orelse return pressed; + const grown = if (opts.frames != null) self.strip.openness(now) else 0; + const whole = geometry(area.toNatural(), grown) orelse return pressed; const w = whole.w; - const center = whole.center(); + // The buttons and the line keep to the bar's bottom row; the strip grows over them. + const center: dvui.Point = .{ .x = whole.x + w / 2, .y = whole.y + whole.h - bar_h / 2 }; // Share, when there is a way to: the last bubble, inside the bar's width. const end_w: f32 = if (opts.share) button_d + gap else 0; const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; const fwd: dvui.Rect = .{ .x = whole.x + w - button_d - end_w, .y = back.y, .w = button_d, .h = button_d }; const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = if (opts.share) button_d else 0, .h = button_d }; - const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap) - end_w, .h = bar_h }; + const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap) - end_w, .h = whole.h }; const hit = wrs.rectToPhysical(whole.outsetAll(4)); var float: dvui.FloatingWidget = undefined; @@ -112,9 +139,11 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const pad = 14; const x0 = phys[1].x + pad * s; const x1 = phys[1].x + phys[1].w - pad * s; - // Where a press goes to a step: the line's band, under the title. A press on the title or - // the capsule's ends is the bar's, and does nothing. - const line_hit: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = phys[1].y + phys[1].h * 0.45, .w = x1 - x0 + 16 * s, .h = phys[1].h * 0.55 }; + // Where a press goes to a step: the line's band, under the title, and the strip above it + // while it shows. A press on the title or the capsule's ends is the bar's, and does nothing. + const band_y = phys[1].y + phys[1].h - bar_h * s * 0.55; + const line_band: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = band_y, .w = x1 - x0 + 16 * s, .h = bar_h * s * 0.55 }; + const strip_band: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = phys[1].y, .w = x1 - x0 + 16 * s, .h = @max(0, phys[1].h - bar_h * s) }; // Named for a tape or a test to aim at: the line from its first stop to its last, so a point // a fraction along it is that fraction of the history. const path = doc.owner.documentPath(doc); @@ -140,7 +169,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica go = @min(n, line.at + 1); } else if (opts.share and phys[3].contains(me.p)) { pressed.share = true; - } else if (line_hit.contains(me.p)) { + } else if (line_band.contains(me.p) or (grown > 0 and strip_band.contains(me.p))) { dvui.captureMouse(float.data(), e.num); self.drag = rules.stopAt(me.p.x, x0, x1, n); } @@ -168,8 +197,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica defer dvui.alphaSet(prev_alpha); const pt = dvui.currentWindow().mouse_pt; var bubbles: [4]core.dialogs.Bubble = undefined; + // The line's bubble: a capsule round the line alone, easing to a surface's corners as the + // strip grows, so the pictures sit inside them. + const line_radius = std.math.lerp(phys[1].h / 2, core.corners.scaled(core.corners.card) * s, @min(1, grown * 1.5)); for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, + .radius = if (i == 1 and grown > 0) line_radius else null, .light = if (r.contains(pt) and switch (i) { 0 => at > 0, 2 => at < n, @@ -189,8 +222,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica var buf: [96]u8 = undefined; const title = if (at == 0) "Before any change" else line.entries[at - 1].title; const label = std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title; + if (grown > 0) if (opts.frames) |frames| { + const film: dvui.Rect.Physical = .{ .x = x0, .y = phys[1].y + 8 * s, .w = x1 - x0, .h = strip_h * grown * s - 6 * s }; + paintStrip(film, frames, line, at, s, grown); + }; dvui.labelNoFmt(@src(), label, .{}, .{ - .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + track.h - bar_h + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, .padding = .{}, .margin = .{}, .font = font, @@ -200,11 +237,51 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // Comes in, goes: a frame each while it fades, and one wake for when it next changes on its // own (it starts to go, it is gone). - if (p < 1) dvui.refresh(null, @src(), fid); - if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); + if (p < 1 or (grown > 0 and grown < 1)) dvui.refresh(null, @src(), fid); + const next = if (self.strip.nextChange(now)) |t| @min(t, self.shown.nextChange(now) orelse t) else self.shown.nextChange(now); + if (next) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); return pressed; } +/// The strip `r` (physical): tiles across it, each the picture nearest its stretch's middle of the +/// history (`line`), the tile under the knob at `at` lit; a stretch with no picture stays glass. +fn paintStrip(r: dvui.Rect.Physical, frames: *const HistoryFrames, line: sdk.Plugin.HistoryLine, at: usize, s: f32, grown: f32) void { + if (r.h < 4 * s or r.w < 4 * s) return; + const n = line.entries.len; + // Tiles about square, as many as fit, each picture cropped to its middle: a wide pane's + // pictures at their own shape fit only three or four. + const aspect: f32 = 1.1; + const count: usize = @max(1, @as(usize, @intFromFloat(@floor(r.w / (r.h * aspect))))); + const tile_w = r.w / @as(f32, @floatFromInt(count)); + const gap_px = 2 * s; + const ink = dvui.themeGet().color(.window, .text); + for (0..count) |i| { + // The stretch of stops this tile stands for, and the stop nearest its middle with a picture. + const lo = i * (n + 1) / count; + const hi = @max(lo + 1, (i + 1) * (n + 1) / count); + const mid = (lo + hi) / 2; + var best: ?dvui.Texture = null; + // Outward from the middle, until both ends of the stretch are passed. + var d: usize = 0; + while (best == null and (mid + d < hi or mid >= lo + d)) : (d += 1) { + if (mid + d < hi) best = frames.get(stopId(line, mid + d)); + if (best == null and d > 0 and mid >= lo + d) best = frames.get(stopId(line, mid - d)); + } + const tile: dvui.Rect.Physical = .{ .x = r.x + @as(f32, @floatFromInt(i)) * tile_w + gap_px / 2, .y = r.y, .w = tile_w - gap_px, .h = r.h }; + const lit = at >= lo and at < hi or (at == n and i == count - 1); + if (best) |tex| { + // Cover the tile, the picture's middle kept. + const ta = @as(f32, @floatFromInt(tex.width)) / @as(f32, @floatFromInt(@max(1, tex.height))); + const want = tile.w / tile.h; + const uv: dvui.Rect = if (ta > want) .{ .x = (1 - want / ta) / 2, .y = 0, .w = want / ta, .h = 1 } else .{ .x = 0, .y = (1 - ta / want) / 2, .w = 1, .h = ta / want }; + dvui.renderTexture(tex, .{ .r = tile, .s = s }, .{ .uv = uv, .corners = .all(4), .colormod = dvui.Color.white.opacity(if (lit) grown else 0.75 * grown) }) catch {}; + } else { + tile.fill(.all(4 * s), .{ .color = .{ .color = ink.opacity(0.06 * grown) } }); + } + if (lit) tile.stroke(.all(4 * s), .{ .thickness = 1.5 * s, .color = .{ .color = dvui.themeGet().color(.highlight, .fill).opacity(grown) } }); + } +} + /// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { const d = r.w * 0.44; diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index 2536b42d9..0b48b5e52 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -339,7 +339,7 @@ pub fn liftedFrom(l: *Layout, place: []const u8, view: []const u8) void { const r = f.bounds.intersect(dvui.windowRectPixels()); // No picture where the frame cannot be read (no targets; the web before a frame is read): the // live float fades instead (`draw`). - if (core.FrameTarget.snapshot(r)) |tex| f.aside_photo = .{ .texture = tex, .rect = r }; + if (core.FrameTarget.snapshot(r, null)) |tex| f.aside_photo = .{ .texture = tex, .rect = r }; } /// The photograph of a float fading to its ghost, `blur` of the way to its frost and at `alpha`, in diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 89208ddb5..2848ee76b 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -703,7 +703,7 @@ pub fn photographFromFrame(l: *Layout, rect: dvui.Rect.Physical) bool { if (core.dialogs.carry_windows) return false; const d = &l.state.view_drag; const r = rect.intersect(dvui.windowRectPixels()); - const tex = core.FrameTarget.snapshot(r) orelse return false; + const tex = core.FrameTarget.snapshot(r, null) orelse return false; if (d.texture) |old| dvui.Texture.destroyLater(old); d.texture = backed(tex, r); d.texture_rect = r; diff --git a/app/root.zig b/app/root.zig index f37618e4f..f507f72b2 100644 --- a/app/root.zig +++ b/app/root.zig @@ -45,6 +45,7 @@ pub const Tracks = @import("Tracks.zig"); /// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. pub const HistoryTransport = @import("HistoryTransport.zig"); pub const TrimBar = @import("TrimBar.zig"); +pub const HistoryFrames = @import("HistoryFrames.zig"); pub const history_transport = @import("history_transport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. @@ -186,4 +187,5 @@ test { _ = HistoryTransport; _ = control.routes; _ = TrimBar; + _ = HistoryFrames; } diff --git a/core/dialogs.zig b/core/dialogs.zig index 1e2f79e6e..63d88306c 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -238,6 +238,15 @@ pub const Bubble = struct { rect: dvui.Rect.Physical, /// White added over it, 0…1: `bubble_hover` under the pointer, `bubble_press` held. light: f32 = 0, + /// Its corners' radius, physical; null: fully round, a capsule (a circle when square). A bubble + /// that grows to hold more than a line (the transport's strip) eases to a surface's radius, + /// so what it holds fits inside its corners. + radius: ?f32 = null, + + fn cornerRadius(self: Bubble) f32 { + const half = @min(self.rect.w, self.rect.h) / 2; + return if (self.radius) |r| @min(r, half) else half; + } }; pub const bubble_hover: f32 = 0.14; pub const bubble_press: f32 = 0.24; @@ -265,14 +274,16 @@ pub fn glassBubbles(id: dvui.Id, bubbles: []const Bubble, scale: f32, opts: Bubb // The ring after the glass, so the glass never blurs it in. defer if (opts.shadow > 0) for (bubbles) |b| { if (b.rect.w < 1 or b.rect.h < 1) continue; - glassShadow(b.rect, .round(@min(b.rect.w, b.rect.h) / 2 / scale), scale, bs, opts.shadow); + glassShadow(b.rect, .round(b.cornerRadius() / scale), scale, bs, opts.shadow); }; if (widgets.LiquidField.ready()) { if (widgets.liquidFrost()) |frost| { var field: widgets.LiquidField = .{ .merge_px = opts.merge * scale, .scale = scale }; for (bubbles) |b| { if (b.rect.w < 1 or b.rect.h < 1) continue; - field.add(.{ .rect = b.rect, .radii = @splat(@min(b.rect.w, b.rect.h)), .round = true, .light = b.light }); + // A capsule's edge bends round as a drop's does; a rounded box's keeps to its sides. + const r = if (b.radius == null) @min(b.rect.w, b.rect.h) else b.cornerRadius(); + field.add(.{ .rect = b.rect, .radii = @splat(r), .round = b.radius == null, .light = b.light }); } var pane = frost; pane.form = 1; @@ -283,7 +294,7 @@ pub fn glassBubbles(id: dvui.Id, bubbles: []const Bubble, scale: f32, opts: Bubb } for (bubbles, 0..) |b, i| { if (b.rect.w < 1 or b.rect.h < 1) continue; - const round: dvui.CornerRect = .round(@min(b.rect.w, b.rect.h) / 2 / scale); + const round: dvui.CornerRect = .round(b.cornerRadius() / scale); const physical = round.scale(scale, dvui.CornerRect.Physical); const piece = id.update(std.mem.asBytes(&i)); const frosted = if (opts.witness) |w| frostPaneKept(piece, b.rect, round, scale, w) else frostPane(piece, b.rect, round, scale); diff --git a/core/gfx/FrameTarget.zig b/core/gfx/FrameTarget.zig index 21bbb5056..dbfb8706c 100644 --- a/core/gfx/FrameTarget.zig +++ b/core/gfx/FrameTarget.zig @@ -250,10 +250,12 @@ pub fn deinit(self: *FrameTarget) void { } /// `rect` (window pixels) as the last frame drew it, copied into a texture the caller owns -/// (`dvui.textureDestroyLater` it). What a pane showed a moment ago, after what it showed has -/// gone: a document closing can unload at once and its pane still slide shut over the picture -/// of it. Null before a frame has been drawn, off the window, or on a backend without targets. -pub fn snapshot(rect: dvui.Rect.Physical) ?dvui.Texture { +/// (`dvui.textureDestroyLater` it): at its own size, or scaled to `size` (pixels; a picture of a +/// document's pane small enough to line up in a strip, `HistoryFrames`). What a pane showed a +/// moment ago, after what it showed has gone: a document closing can unload at once and its pane +/// still slide shut over the picture of it. Null before a frame has been drawn, off the window, or +/// on a backend without targets. +pub fn snapshot(rect: dvui.Rect.Physical, size: ?[2]u32) ?dvui.Texture { const self = current orelse return null; // The last frame shown: the other target while one is bound; in a frame nobody will see, the // one bound last (`begin` leaves the targets as they were). @@ -262,8 +264,8 @@ pub fn snapshot(rect: dvui.Rect.Physical) ?dvui.Texture { const prev = self.targets[last] orelse return null; const r = rect.intersect(dvui.windowRectPixels()); if (r.w < 1 or r.h < 1) return null; - const w: u32 = @intFromFloat(@round(r.w)); - const h: u32 = @intFromFloat(@round(r.h)); + const w: u32, const h: u32 = size orelse .{ @intFromFloat(@round(r.w)), @intFromFloat(@round(r.h)) }; + if (w == 0 or h == 0) return null; const src = dvui.Texture.fromTargetTemp(prev) catch return null; const out = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = .linear }) catch return null; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d19744f58..e332c1969 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2755,6 +2755,11 @@ const DocSurface = struct { feed: @import("app").ActivityFeed = .{}, /// Its history as a line at the bottom of the pane: undo and redo show it. transport: @import("app").HistoryTransport = .{}, + /// Its history as pictures, for the transport's strip: one taken each time its track moves. + frames: @import("app").HistoryFrames = .{}, + /// The track, and how many steps it held, when a picture was last asked for. + frames_track: usize = 0, + frames_steps: usize = 0, }; pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { @@ -2814,6 +2819,7 @@ fn registerDocSurface(editor: *Editor, doc: sdk.DocHandle) !void { fn unregisterDocSurface(editor: *Editor, doc_id: u64) void { const kv = editor.doc_surfaces.fetchRemove(doc_id) orelse return; + kv.value.frames.deinit(editor.app.gpa); editor.app.host.unregisterSurface(kv.value.id); editor.app.gpa.free(kv.value.id); editor.app.gpa.destroy(kv.value); @@ -2963,10 +2969,27 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { // the next frame is held here too. if (claim_pill) |pill| if (!editor.demo.live.playing()) holdInput(canvas.data(), ds, pill.physical); + // The picture of it the history asked for, from the last frame, above where the bar sits. + const recorded = if (editor.app.tracks) |*t| t.trackOf(doc.id) else null; + if (recorded != null) { + const pane = canvas.data().borderRectScale().r; + // Above the bar as it stands, its strip and the feed over it included. + const under = if (ds.transport.place(pane, dvui.frameTimeNS())) |bar| pane.y + pane.h - bar.y + 40 * dvui.windowRectScale().s else 0; + ds.frames.take(editor.app.gpa, pane, @max(76 * dvui.windowRectScale().s, under)); + } + const focus_before = dvui.lastFocusedIdInFrame(); _ = try doc.owner.drawDocument(doc); ds.focus_inside = dvui.lastFocusedIdInFrameSince(focus_before) != null; + // Its history moved (a step recorded, undone, redone, scrubbed to): a picture of it as it + // stands, a frame from now. The first frame it draws is one too. + if (recorded) |tr| if (@intFromPtr(tr) != ds.frames_track or tr.steps.items.len != ds.frames_steps) { + ds.frames_track = @intFromPtr(tr); + ds.frames_steps = tr.steps.items.len; + if (doc.owner.historyLine(doc, dvui.currentWindow().arena())) |line| ds.frames.moved(if (line.at == 0) 0 else line.entries[line.at - 1].id); + }; + // The commands run on it, as they run; none while a tape seeks, which replays them unseen. if (editor.app.host.activity_muted) ds.feed.clear() else { const now = dvui.frameTimeNS(); @@ -3006,7 +3029,7 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { TapeTabs.drawTrim(doc, trim[0], trim[1], canvas.data().borderRectScale().r, @truncate(ds.doc_id)); } else { const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; - const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable }); + const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable, .frames = if (recorded != null) &ds.frames else null }); if (pressed.share) { editor.share_pending = doc.id; dvui.refresh(null, @src(), null); @@ -6387,7 +6410,7 @@ fn snapshotClosingPane(editor: *Editor, doc: sdk.DocHandle) void { for (wb.workspaces.values()) |*ws| { if (!ws.hasTab(id) or ws.tabCount() != 1) continue; const r = ws.pane_rect_physical orelse return; - const snap = fizzy.core.FrameTarget.snapshot(r) orelse return; + const snap = fizzy.core.FrameTarget.snapshot(r, null) orelse return; if (ws.closing_snapshot) |old| dvui.textureDestroyLater(old); ws.closing_snapshot = snap; return; From 43c816c260542c26e70a7aca2ae82a7022a50152 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:19:51 -0500 Subject: [PATCH 16/31] app: export a window of the strip, dragged from both sides (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6. Stacked on #500 (the strip): export becomes part of the scrub bar. This replaces #497's trim in a tape's own tab. ## What changes **An export button comes with the strip.** When scrubbing grows the bar into its strip (#500), a share bubble grows in at the bar's end. It shows only for a recorded document, from where its track began on. **Pressed, it opens a window over the strip**, as macOS trims a recording: - **The bracket:** a yellow bracket over the part to export, at first the whole history the track holds, with a handle at each side. The strip and line outside it are dimmed. - **Dragging a side** takes the document to that point (`historyGoTo`, one jump a frame, as scrubbing does), so the person sees where the part begins and ends. A press inside the window moves the playhead within it. - **The summary:** over the line, in words: *2 steps, 12 s, by you and Claude* (`Tracks.lineSummary`). - **Buttons:** undo's button becomes × and closes the window; the export bubble becomes a yellow ✓ and exports. Redo is off while the window is open. - **Sides are clamped:** neither can go before the track's start (`export_from`), and the window always holds at least one step (`history_transport.Trim`, the rules from #497). - **Closes itself:** the window closes when the history changes under it. **What's exported is what the strip shows** (`Tracks.exportLine`): - **The keyframe:** the document's file at the window's left stop. It's written by its owner with the document taken there and brought back in the same call (`fileAt`, now shared with `exportTape`). - **The steps:** the recorded ones that made each entry from there to the right stop (the step that added it, and those that joined it), in the line's order and timed as they came. Undos and redos taken between are left out, since the strip doesn't show them. An entry made before the track began refuses (`NotRecorded`). - **Where it goes:** the tape is kept in `tapes/` and noted in glass on the document. The person's document and its history end where they were. **Removed:** the tab trim from #497 (`TrimBar`, `TapeTabs`' trim mode, `Editor.shareDocument`). A tape tab plays a tape again, as in #485. `fizzy.tracks.export { from, to }` still exports a range of the raw track. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **Integration:** a document of five entries with the last undone, exported from stop 2 to stop 5. It checks: - `lineSummary` says 3 steps; - exactly one tape is kept, whose keyframe is the document after two entries (`"two\none\n"`) and which holds 3 steps, the undone entry's included; - the document's text and history position are unchanged. - **In a running fizzy** (its own profile, tracks on, driven through the agent plugin's bridge, seen through fizzy's frame capture): - a scrub grew the strip and the export bubble; - pressing it opened the yellow window over the whole history ("5 steps…"); - dragging the left side to 60% took the document there and the summary to "2 steps"; - ✓ wrote one tape, noted "Saved notes-….tape" in glass, and closed the window. - **A view moving panes:** during two earlier runs the document's tab moved to the other pane. It didn't reproduce when opening the window and pressing ✓ by themselves. Both times came right after I rebuilt, which restarts the sandbox and restores its layout, so the restart looks like the cause rather than this change. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not run in a browser. ## Follow-ups - **The window's summary** rescans the track each frame while the window is open (`lineSteps`): nothing at today's sizes; cache it on the window, keyed by its stops and the track's step count, if long tracks show it. - **Previewing the window** with play, as macOS has: the window's steps played over the document from its left side. The redo bubble is the natural place. - **Real-time pauses** (kept by `fizzy.tracks.export { real_time }`) have no control on the bar. - **Steps before the track began** can't be exported: their effects were never recorded. Once tracks outlive the document (plan step 10), that reaches back further. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/HistoryTransport.zig | 141 +++++++++++++++---- app/Tracks.zig | 142 +++++++++++++++---- app/TrimBar.zig | 191 -------------------------- app/history_transport.zig | 5 +- app/root.zig | 2 - src/editor/Editor.zig | 97 +++++++------ src/editor/TapeTabs.zig | 280 +------------------------------------- tests/integration.zig | 102 +++++--------- 8 files changed, 321 insertions(+), 639 deletions(-) delete mode 100644 app/TrimBar.zig diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index a61caa173..06f59bc70 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -12,6 +12,12 @@ //! (a drag on the line, a held undo or redo) grows the bar upward into a strip of them, as a //! video's scrubber shows frames: each tile the document at a stretch of its history, the nearest //! step to the tile's middle that has a picture. It stays while the pointer is on the bar. +//! +//! **Export.** With the strip comes an export button (`DrawOptions.export_from`). Pressed, it opens +//! a window over the strip, as macOS trims a recording: a yellow bracket over the part to export, +//! dragged from either side (`history_transport.Trim`), the document going to wherever a side is +//! dragged and the rest dimmed. Pressed again, the part is the app's (`Pressed.exported`); undo's +//! button, a cross while the window is open, closes it. const HistoryTransport = @This(); const std = @import("std"); @@ -24,6 +30,10 @@ const rules = @import("history_transport.zig"); shown: rules.Shown = .{}, /// The strip: grown while scrubbing, and while the pointer is on the bar after. strip: rules.Shown = .{}, +/// The export window over the strip while the person trims it; null when closed. +window: ?rules.Trim = null, +/// What the pointer holds of the window while it drags. +grip: ?rules.Trim.Grip = null, repeat: rules.Repeat = .{}, /// The stop the pointer holds the knob at while it drags the line, until the drag ends. Only /// while the bar holds the mouse: a drag whose capture went (the bar stopped drawing) is over. @@ -77,18 +87,21 @@ fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { } pub const DrawOptions = struct { - /// A share button at the bar's end: the app has a way to share how the document was made - /// (fizzy: its track as a tape). What a press does is the app's (`Pressed.share`). - share: bool = false, /// The document's pictures, for the strip scrubbing grows; null: no strip. frames: ?*const HistoryFrames = null, + /// An export button with the strip: the app can export a part of the history (fizzy: as a + /// tape). The history entry the part can begin at, earliest (0: before any); null: no button. + export_from: ?u64 = null, + /// What the window holds, said over the line while it is open (`window`): the app's words. + window_summary: []const u8 = "", }; const HistoryFrames = @import("HistoryFrames.zig"); /// What was pressed on the bar this frame that is the app's to act on. pub const Pressed = struct { - share: bool = false, + /// The window's part was exported: from stop `from` to stop `to` of the line. + exported: ?rules.Trim = null, }; /// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input. @@ -105,11 +118,27 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // On the bar, a strip grown stays. if (self.strip.openness(now) > 0) self.strip.stir(now); }; + // An open window keeps the bar and its strip. + if (self.window != null) { + self.shown.stir(now); + self.strip.stir(now); + } const p = self.shown.openness(now); if (p <= 0) return pressed; const arena = dvui.currentWindow().arena(); const line = doc.owner.historyLine(doc, arena) orelse return pressed; const n = line.entries.len; + // The first stop a part can begin at: where `export_from` is on the line. Gone from it (undone + // past and edited over), nothing can be exported. + const min_from: ?usize = if (opts.export_from) |id| (if (id == 0) 0 else for (line.entries, 1..) |e, i| { + if (e.id == id) break i; + } else null) else null; + const can_export = opts.frames != null and min_from != null and min_from.? < n; + // The history changed under an open window (an edit elsewhere): it closes. + if (self.window) |win| if (win.n != n or !can_export) { + self.window = null; + self.grip = null; + }; const wrs = dvui.windowRectScale(); const s = wrs.s; @@ -118,11 +147,12 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const w = whole.w; // The buttons and the line keep to the bar's bottom row; the strip grows over them. const center: dvui.Point = .{ .x = whole.x + w / 2, .y = whole.y + whole.h - bar_h / 2 }; - // Share, when there is a way to: the last bubble, inside the bar's width. - const end_w: f32 = if (opts.share) button_d + gap else 0; + // Export, when there is a way to: the last bubble, growing in with the strip. + const export_g: f32 = if (!can_export) 0 else if (self.window != null) 1 else grown; + const end_w: f32 = (button_d + gap) * export_g; const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; const fwd: dvui.Rect = .{ .x = whole.x + w - button_d - end_w, .y = back.y, .w = button_d, .h = button_d }; - const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = if (opts.share) button_d else 0, .h = button_d }; + const share: dvui.Rect = .{ .x = whole.x + w - button_d * export_g, .y = back.y + button_d * (1 - export_g) / 2, .w = button_d * export_g, .h = button_d * export_g }; const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap) - end_w, .h = whole.h }; const hit = wrs.rectToPhysical(whole.outsetAll(4)); @@ -134,6 +164,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // A drag lives as long as the bar holds the mouse: drawn again after it was not (a claim came // and went), it holds nothing, and the knob is the document's again. if (self.drag != null and !dvui.captured(fid)) self.drag = null; + if (self.grip != null and !dvui.captured(fid)) self.grip = null; const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd), wrs.rectToPhysical(share) }; const pad = 14; @@ -150,7 +181,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path}); core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path}); core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path}); - if (opts.share) core.anchor.markRect(fid, phys[3], true, "history.share:{s}", .{path}); + if (export_g > 0) core.anchor.markRect(fid, phys[3], true, "history.export:{s}", .{path}); // Input: the buttons step, the line goes where it is pressed and follows a drag. var go: ?usize = null; @@ -164,24 +195,40 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica .press => if (me.button.pointer()) { e.handle(@src(), float.data()); if (phys[0].contains(me.p)) { - go = line.at -| 1; + // A cross while the window is open: it closes. + if (self.window != null) self.window = null else go = line.at -| 1; } else if (phys[2].contains(me.p)) { - go = @min(n, line.at + 1); - } else if (opts.share and phys[3].contains(me.p)) { - pressed.share = true; + if (self.window == null) go = @min(n, line.at + 1); + } else if (export_g > 0.5 and phys[3].contains(me.p)) { + if (self.window) |win| { + pressed.exported = win; + self.window = null; + } else self.window = .{ .from = min_from.?, .to = n, .n = n }; } else if (line_band.contains(me.p) or (grown > 0 and strip_band.contains(me.p))) { dvui.captureMouse(float.data(), e.num); - self.drag = rules.stopAt(me.p.x, x0, x1, n); + const stop = rules.stopAt(me.p.x, x0, x1, n); + if (self.window) |*win| { + const grip = win.grab(me.p.x, x0, x1, 12 * s); + self.grip = grip; + go = dragWindow(win, grip, stop, min_from.?); + } else self.drag = stop; } }, - .motion => if (self.drag != null and dvui.captured(fid)) { - e.handle(@src(), float.data()); - self.drag = rules.stopAt(me.p.x, x0, x1, n); + .motion => if (dvui.captured(fid)) { + const stop = rules.stopAt(me.p.x, x0, x1, n); + if (self.grip) |grip| { + e.handle(@src(), float.data()); + go = dragWindow(&self.window.?, grip, stop, min_from.?); + } else if (self.drag != null) { + e.handle(@src(), float.data()); + self.drag = stop; + } }, .release => if (me.button.pointer() and dvui.captured(fid)) { e.handle(@src(), float.data()); dvui.captureMouse(null, e.num); self.drag = null; + self.grip = null; }, else => {}, } @@ -197,6 +244,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica defer dvui.alphaSet(prev_alpha); const pt = dvui.currentWindow().mouse_pt; var bubbles: [4]core.dialogs.Bubble = undefined; + const trimming = self.window != null; // The line's bubble: a capsule round the line alone, easing to a surface's corners as the // strip grows, so the pictures sit inside them. const line_radius = std.math.lerp(phys[1].h / 2, core.corners.scaled(core.corners.card) * s, @min(1, grown * 1.5)); @@ -204,28 +252,32 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica .rect = r, .radius = if (i == 1 and grown > 0) line_radius else null, .light = if (r.contains(pt) and switch (i) { - 0 => at > 0, - 2 => at < n, + 0 => trimming or at > 0, + 2 => !trimming and at < n, 3 => true, else => false, }) core.dialogs.bubble_hover else 0, }; - core.dialogs.glassBubbles(fid, bubbles[0..if (opts.share) 4 else 3], s, .{ .merge = 14, .shadow = 1 }); + core.dialogs.glassBubbles(fid, bubbles[0..if (export_g > 0) 4 else 3], s, .{ .merge = 14, .shadow = 1 }); const ink = dvui.themeGet().color(.window, .text); - button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); - button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3)); - if (opts.share) button(bounds, share, "history_share", icons.tvg.lucide.@"share-2", ink.opacity(0.85)); + if (trimming) { + button(bounds, back, "history_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); + } else button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); + button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (!trimming and at < n) 0.85 else 0.3)); + if (export_g > 0.3) { + if (trimming) { + button(bounds, share, "history_export_done", icons.tvg.lucide.check, yellow); + } else button(bounds, share, "history_export", icons.tvg.lucide.@"share-2", ink.opacity(0.85 * export_g)); + } // What the document is at, over the line: the step's title, and how far along. const font = dvui.Font.theme(.body).larger(-2); var buf: [96]u8 = undefined; const title = if (at == 0) "Before any change" else line.entries[at - 1].title; - const label = std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title; - if (grown > 0) if (opts.frames) |frames| { - const film: dvui.Rect.Physical = .{ .x = x0, .y = phys[1].y + 8 * s, .w = x1 - x0, .h = strip_h * grown * s - 6 * s }; - paintStrip(film, frames, line, at, s, grown); - }; + const label = if (trimming and opts.window_summary.len > 0) opts.window_summary else std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title; + const film: dvui.Rect.Physical = .{ .x = x0, .y = phys[1].y + 8 * s, .w = x1 - x0, .h = strip_h * grown * s - 6 * s }; + if (grown > 0) if (opts.frames) |frames| paintStrip(film, frames, line, at, s, grown); dvui.labelNoFmt(@src(), label, .{}, .{ .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + track.h - bar_h + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, .padding = .{}, @@ -233,7 +285,9 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica .font = font, .color_text = .{ .color = ink.opacity(0.85) }, }); - paintLine(.{ .x = x0, .y = phys[1].y + phys[1].h - 16 * s, .w = @max(1, x1 - x0), .h = 10 * s }, at, n, ink, s); + const line_r: dvui.Rect.Physical = .{ .x = x0, .y = phys[1].y + phys[1].h - 16 * s, .w = @max(1, x1 - x0), .h = 10 * s }; + paintLine(line_r, at, n, ink, s); + if (self.window) |win| paintWindow(.{ .x = x0, .y = film.y, .w = x1 - x0, .h = line_r.y + line_r.h - film.y }, win, s); // Comes in, goes: a frame each while it fades, and one wake for when it next changes on its // own (it starts to go, it is gone). @@ -243,6 +297,37 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica return pressed; } +/// macOS's trim yellow. +const yellow: dvui.Color = .{ .r = 0xF5, .g = 0xC0, .b = 0x2E }; + +/// Drag what `grip` holds of `win` to `stop`, the part never beginning before `min_from`. Returns +/// the stop the document goes to. +fn dragWindow(win: *rules.Trim, grip: rules.Trim.Grip, stop: usize, min_from: usize) usize { + return win.drag(grip, @max(stop, if (grip == .to) min_from + 1 else min_from)); +} + +/// The export window over `r` (physical: the strip and the line under it): the rest dimmed, a +/// yellow bracket over the part, a handle at each side. +fn paintWindow(r: dvui.Rect.Physical, win: rules.Trim, s: f32) void { + const xf = rules.stopX(win.from, r.x, r.x + r.w, win.n); + const xt = rules.stopX(win.to, r.x, r.x + r.w, win.n); + const dim: dvui.Color = dvui.Color.black.opacity(0.6); + if (xf > r.x) (dvui.Rect.Physical{ .x = r.x, .y = r.y, .w = xf - r.x, .h = r.h }).fill(.all(4 * s), .{ .color = .{ .color = dim } }); + if (xt < r.x + r.w) (dvui.Rect.Physical{ .x = xt, .y = r.y, .w = r.x + r.w - xt, .h = r.h }).fill(.all(4 * s), .{ .color = .{ .color = dim } }); + const handle_w = 8 * s; + const edge = 2.5 * s; + const top: dvui.Rect.Physical = .{ .x = xf, .y = r.y - edge, .w = xt - xf, .h = edge }; + top.fill(.{}, .{ .color = .{ .color = yellow } }); + const bottom: dvui.Rect.Physical = .{ .x = xf, .y = r.y + r.h, .w = xt - xf, .h = edge }; + bottom.fill(.{}, .{ .color = .{ .color = yellow } }); + for ([_]f32{ xf - handle_w, xt }) |x| { + const hr: dvui.Rect.Physical = .{ .x = x, .y = r.y - edge, .w = handle_w, .h = r.h + 2 * edge }; + hr.fill(.all(3 * s), .{ .color = .{ .color = yellow } }); + const notch: dvui.Rect.Physical = .{ .x = x + handle_w / 2 - 0.75 * s, .y = r.y + r.h / 2 - 6 * s, .w = 1.5 * s, .h = 12 * s }; + notch.fill(.all(0.75 * s), .{ .color = .{ .color = .black } }); + } +} + /// The strip `r` (physical): tiles across it, each the picture nearest its stretch's middle of the /// history (`line`), the tile under the knob at `at` lit; a stretch with no picture stays glass. fn paintStrip(r: dvui.Rect.Physical, frames: *const HistoryFrames, line: sdk.Plugin.HistoryLine, at: usize, s: f32, grown: f32) void { diff --git a/app/Tracks.zig b/app/Tracks.zig index 8272eaa14..40d0c5e86 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -463,6 +463,9 @@ pub const ExportError = error{ StartNotInHistory, /// The range holds no steps. EmptyRange, + /// An entry in the range was made before the document's track began: its steps were never + /// recorded. + NotRecorded, /// The owner could not write the document as a file. NoBytes, /// The document went back to the track's start for its file and could not come forward @@ -607,32 +610,8 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; const now_top = if (line.at == 0) 0 else line.entries[line.at - 1].id; - const name = blk: { - const base_name = std.fs.path.basename(doc.owner.documentPath(doc)); - if (std.fs.path.extension(base_name).len > 0) break :blk base_name; - const types = doc.owner.fileTypes(); - if (types.len == 0) break :blk base_name; - break :blk try std.mem.concat(arena, u8, &.{ base_name, types[0] }); - }; - - // Back to where the range begins, the file written there, and forward again: that last - // move is checked, since a failure would leave the person's document at the range's start. - const bytes = bytes: { - const was_track_muted = self.muted; - self.muted = doc.id; - defer self.muted = was_track_muted; - const was_muted = host.activity_muted; - host.activity_muted = true; - defer host.activity_muted = was_muted; - const moved = now_top != start_top; - if (moved and !doc.owner.historyGoTo(doc, start_top)) return error.StartNotInHistory; - const written = doc.owner.documentBytes(doc, name, arena) catch null; - if (moved and !doc.owner.historyGoTo(doc, now_top)) { - std.log.err("tracks: {s} went back to its track's start and could not come forward; redo brings the rest back", .{doc.owner.documentPath(doc)}); - return error.NotReturned; - } - break :bytes written orelse return error.NoBytes; - }; + const name = try fileName(doc, arena); + const bytes = try self.fileAt(host, doc, arena, name, start_top, now_top); const files = try arena.alloc(tape.Tape.File, 1); files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; @@ -649,6 +628,117 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m return tape.binary.encode(gpa, .{ .name = "share", .title = title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch return error.OutOfMemory; } +/// The document's file name for a tape: its own, with its owner's first extension when it has +/// none (an untitled document), so it opens with the right owner. +fn fileName(doc: sdk.DocHandle, arena: std.mem.Allocator) error{OutOfMemory}![]const u8 { + const base_name = std.fs.path.basename(doc.owner.documentPath(doc)); + if (std.fs.path.extension(base_name).len > 0) return base_name; + const types = doc.owner.fileTypes(); + if (types.len == 0) return base_name; + return std.mem.concat(arena, u8, &.{ base_name, types[0] }); +} + +/// `doc`'s file as it stood with history entry `top` on top, written by its owner: the document +/// taken back there through its history, written, and brought forward to `now_top` again in the +/// same call, with nothing drawn between and neither its track nor the activity feed hearing of +/// it. The move forward is checked, since a failure would leave the person's document back there. +fn fileAt(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, arena: std.mem.Allocator, name: []const u8, top: u64, now_top: u64) ExportError![]const u8 { + const was_track_muted = self.muted; + self.muted = doc.id; + defer self.muted = was_track_muted; + const was_muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = was_muted; + const moved = now_top != top; + if (moved and !doc.owner.historyGoTo(doc, top)) return error.StartNotInHistory; + const written = doc.owner.documentBytes(doc, name, arena) catch null; + if (moved and !doc.owner.historyGoTo(doc, now_top)) { + std.log.err("tracks: {s} went back for a tape's file and could not come forward; redo brings the rest back", .{doc.owner.documentPath(doc)}); + return error.NotReturned; + } + return written orelse error.NoBytes; +} + +/// The recorded steps that made the entries of `doc`'s history line from stop `lo` to stop +/// `hi` (the entries `line.entries[lo..hi]`): each entry's step that added it and those that +/// joined it, in the order they came, which is the line's. What the strip shows played forward, +/// with the undos and redos between left out. `error.NotRecorded` when an entry was made before +/// its track began. +fn lineSteps(t: *const Track, arena: std.mem.Allocator, line: sdk.Plugin.HistoryLine, lo: usize, hi: usize) ExportError![]Track.Rec { + var want: std.AutoHashMapUnmanaged(u64, bool) = .empty; + for (line.entries[lo..hi]) |e| try want.put(arena, e.id, false); + var out: std.ArrayListUnmanaged(Track.Rec) = .empty; + for (t.steps.items) |r| switch (r.kind) { + .added, .joined => if (want.getPtr(r.id)) |seen| { + if (r.kind == .added) seen.* = true; + try out.append(arena, r); + }, + else => {}, + }; + var it = want.valueIterator(); + while (it.next()) |seen| if (!seen.*) return error.NotRecorded; + return out.items; +} + +/// What a stretch of the history line holds, for the strip's export window to say: how many +/// entries, how long they took to make (as the tape plays them), whose they are. Null when an +/// entry in it was made before the track began. +pub fn lineSummary(self: *Tracks, host: *const sdk.Host, doc: sdk.DocHandle, arena: std.mem.Allocator, line: sdk.Plugin.HistoryLine, lo: usize, hi: usize) ?Summary { + const t = self.open.get(doc.id) orelse return null; + if (lo >= hi or hi > line.entries.len) return null; + const recs = lineSteps(t, arena, line, lo, hi) catch return null; + if (recs.len == 0) return null; + var timing: Timing = .{ .recorded_end = recs[0].at, .real_time = false }; + var out: Summary = .{ .from = lo, .to = hi, .steps = hi - lo, .recorded_ms = 0, .played_ms = 0, .authors = &.{} }; + var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; + for (recs) |r| { + out.played_ms = timing.place(r) + r.ms; + const who = host.stepAuthor(doc.id, r.id); + for (authors.items) |a| { + if (a.same(who)) break; + } else authors.append(arena, who) catch {}; + } + out.recorded_ms = timing.recorded_end -| recs[0].at; + out.authors = authors.items; + return out; +} + +/// A stretch of `doc`'s history line as a tape, as the strip shows it (`HistoryTransport`'s +/// export window): the document's file at stop `lo` (`fileAt`), then the steps that made each +/// entry to stop `hi` (`lineSteps`), timed as they came. Owned by `gpa`. +pub fn exportLine(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.mem.Allocator, lo: usize, hi: usize, opts: ExportOptions) ExportError![]u8 { + const prof = core.profile.begin("fizzy", "tracks.export"); + defer prof.end(); + const t = self.open.get(doc.id) orelse return error.NoTrack; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; + if (lo >= hi or hi > line.entries.len) return error.EmptyRange; + const recs = try lineSteps(t, arena, line, lo, hi); + const stop = struct { + fn id(l: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else l.entries[i - 1].id; + } + }.id; + const name = try fileName(doc, arena); + const bytes = try self.fileAt(host, doc, arena, name, stop(line, lo), stop(line, line.at)); + + const files = try arena.alloc(tape.Tape.File, 1); + files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; + const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; + const ops = try arena.alloc(tape.Tape.Op, recs.len + 2); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; + var timing: Timing = .{ .recorded_end = recs[0].at, .real_time = opts.real_time }; + for (recs, ops[2..]) |r, *op| { + op.* = .{ .at = timing.place(r), .ms = r.ms, .do = .{ .step = .{ .file = name, .effect = t.effect(r), .motion = t.motion(r) } } }; + } + const title = try std.fmt.allocPrint(arena, "How {s} was made", .{name}); + return tape.binary.encode(gpa, .{ .name = "share", .title = title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch return error.OutOfMemory; +} + /// The open document's track, or else the latest that ended for it. pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { if (self.open.get(doc_id)) |t| return t; diff --git a/app/TrimBar.zig b/app/TrimBar.zig deleted file mode 100644 index 99b91322d..000000000 --- a/app/TrimBar.zig +++ /dev/null @@ -1,191 +0,0 @@ -//! A trim of a document's history, as macOS trims a recording: the bar at the bottom of its pane -//! holds the history as a line with a yellow bracket over the part kept, and the document shows -//! wherever a handle is dragged, so the person sees where the part they keep begins and ends rather -//! than choosing a time. Cancel and share either side, play to see the part kept. -//! -//! What a trim keeps, and how a press takes a handle, are `history_transport.Trim`'s rules. The -//! bar seeks the document itself while a handle or the playhead is dragged (one `historyGoTo` a -//! frame at most, as the transport does); what cancel, play and share do is the app's (`Pressed`). -//! Shown for as long as the app draws it: a trim is something the person is in the middle of. -const TrimBar = @This(); - -const std = @import("std"); -const dvui = @import("dvui"); -const core = @import("core"); -const sdk = @import("fizzy_sdk"); -const icons = @import("icons"); -const rules = @import("history_transport.zig"); - -/// What the pointer holds while it drags on the line, until the drag ends. -grip: ?rules.Trim.Grip = null, - -const bar_h: f32 = 48; -const button_d: f32 = 34; -const gap: f32 = 10; -const max_w: f32 = 620; -/// macOS's trim yellow. -const yellow: dvui.Color = .{ .r = 0xF5, .g = 0xC0, .b = 0x2E }; - -/// What was pressed this frame, for the app to act on. -pub const Pressed = struct { - cancel: bool = false, - play: bool = false, - share: bool = false, - /// The playhead or a handle was taken: a playing preview stops. - grabbed: bool = false, -}; - -/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. -fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { - return if (i == 0) 0 else line.entries[i - 1].id; -} - -/// Draw the bar over `area` (the document's pane, physical) and take its input. `trim` is the -/// part kept, over `doc`'s history; `summary` says what it holds, over the line; `playing` turns -/// play into pause. `id_extra` keeps one pane's bar apart from another's. -pub fn draw(self: *TrimBar, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize, trim: *rules.Trim, summary: []const u8, playing: bool) Pressed { - var pressed: Pressed = .{}; - const arena = dvui.currentWindow().arena(); - const line = doc.owner.historyLine(doc, arena) orelse return pressed; - const n = line.entries.len; - if (n != trim.n) return pressed; - - const wrs = dvui.windowRectScale(); - const s = wrs.s; - const nat = area.toNatural(); - const w = @min(max_w, nat.w - 24); - if (w < 6 * button_d) return pressed; - const whole: dvui.Rect = .{ .x = nat.x + (nat.w - w) / 2, .y = nat.y + nat.h - 14 - bar_h, .w = w, .h = bar_h }; - const cy = whole.y + bar_h / 2; - const cancel: dvui.Rect = .{ .x = whole.x, .y = cy - button_d / 2, .w = button_d, .h = button_d }; - const play: dvui.Rect = .{ .x = cancel.x + button_d + gap, .y = cancel.y, .w = button_d, .h = button_d }; - const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = cancel.y, .w = button_d, .h = button_d }; - const track: dvui.Rect = .{ .x = play.x + button_d + gap, .y = whole.y, .w = share.x - gap - (play.x + button_d + gap), .h = bar_h }; - - var float: dvui.FloatingWidget = undefined; - float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra }); - defer float.deinit(); - const bounds = float.data().rect; - const fid = float.data().id; - if (self.grip != null and !dvui.captured(fid)) self.grip = null; - - const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(cancel), wrs.rectToPhysical(play), wrs.rectToPhysical(track), wrs.rectToPhysical(share) }; - const pad = 16; - const x0 = phys[2].x + pad * s; - const x1 = phys[2].x + phys[2].w - pad * s; - const line_r: dvui.Rect.Physical = .{ .x = x0, .y = phys[2].y + phys[2].h - 24 * s, .w = @max(1, x1 - x0), .h = 16 * s }; - const line_hit = line_r.outsetAll(8 * s); - const path = doc.owner.documentPath(doc); - core.anchor.markRect(fid, phys[0], true, "trim.cancel:{s}", .{path}); - core.anchor.markRect(fid, phys[1], true, "trim.play:{s}", .{path}); - core.anchor.markRect(fid, .{ .x = x0, .y = line_r.y, .w = x1 - x0, .h = line_r.h }, true, "trim.line:{s}", .{path}); - core.anchor.markRect(fid, phys[3], true, "trim.share:{s}", .{path}); - - var go: ?usize = null; - const hit = wrs.rectToPhysical(whole.outsetAll(4)); - for (dvui.events()) |*e| { - if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue; - const me = switch (e.evt) { - .mouse => |m| m, - else => continue, - }; - switch (me.action) { - .press => if (me.button.pointer()) { - e.handle(@src(), float.data()); - if (phys[0].contains(me.p)) { - pressed.cancel = true; - } else if (phys[1].contains(me.p)) { - pressed.play = true; - } else if (phys[3].contains(me.p)) { - pressed.share = true; - } else if (line_hit.contains(me.p)) { - dvui.captureMouse(float.data(), e.num); - const grip = trim.grab(me.p.x, x0, x1, 10 * s); - self.grip = grip; - go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); - pressed.grabbed = true; - } - }, - .motion => if (self.grip) |grip| if (dvui.captured(fid)) { - e.handle(@src(), float.data()); - go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); - }, - .release => if (me.button.pointer() and dvui.captured(fid)) { - e.handle(@src(), float.data()); - dvui.captureMouse(null, e.num); - self.grip = null; - }, - else => {}, - } - } - var at = line.at; - if (go) |to| if (to != at) { - if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to; - dvui.refresh(null, @src(), fid); - }; - - const pt = dvui.currentWindow().mouse_pt; - var bubbles: [4]core.dialogs.Bubble = undefined; - for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, .light = if (i != 2 and r.contains(pt)) core.dialogs.bubble_hover else 0 }; - core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); - - const ink = dvui.themeGet().color(.window, .text); - button(bounds, cancel, "trim_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); - button(bounds, play, "trim_play", if (playing) icons.tvg.lucide.pause else icons.tvg.lucide.play, ink.opacity(0.85)); - button(bounds, share, "trim_share", icons.tvg.lucide.check, yellow); - - const font = dvui.Font.theme(.body).larger(-2); - dvui.labelNoFmt(@src(), summary, .{}, .{ - .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 5 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, - .padding = .{}, - .margin = .{}, - .font = font, - .color_text = .{ .color = ink.opacity(0.85) }, - }); - paintLine(line_r, trim.*, at, ink, s); - return pressed; -} - -/// A button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). -fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { - const d = r.w * 0.44; - core.icon.icon(@src(), name, tvg, .{ - .stroke_color = .{ .color = color }, - .fill_color = .{ .color = color }, - }, .{ - .id_extra = @intFromPtr(name.ptr), - .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, - }); -} - -/// The line `r` (physical): the whole history faint, the part kept brighter inside the yellow -/// bracket, its two handles, and the playhead at `at`. -fn paintLine(r: dvui.Rect.Physical, trim: rules.Trim, at: usize, ink: dvui.Color, s: f32) void { - const h = 6 * s; - const mid = r.y + r.h / 2; - const whole: dvui.Rect.Physical = .{ .x = r.x, .y = mid - h / 2, .w = r.w, .h = h }; - whole.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.12) } }); - - const xf = rules.stopX(trim.from, r.x, r.x + r.w, trim.n); - const xt = rules.stopX(trim.to, r.x, r.x + r.w, trim.n); - const kept: dvui.Rect.Physical = .{ .x = xf, .y = whole.y, .w = @max(1, xt - xf), .h = h }; - kept.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.4) } }); - - // The bracket: yellow over and under the part kept, a handle at each end. - const handle_w = 7 * s; - const bracket: dvui.Rect.Physical = .{ .x = xf - handle_w, .y = r.y, .w = xt - xf + 2 * handle_w, .h = r.h }; - const edge = 2 * s; - (dvui.Rect.Physical{ .x = xf, .y = bracket.y, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); - (dvui.Rect.Physical{ .x = xf, .y = bracket.y + bracket.h - edge, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); - for ([_]f32{ xf - handle_w, xt }) |x| { - const hr: dvui.Rect.Physical = .{ .x = x, .y = bracket.y, .w = handle_w, .h = bracket.h }; - hr.fill(.all(3 * s), .{ .color = .{ .color = yellow } }); - const notch: dvui.Rect.Physical = .{ .x = x + handle_w / 2 - 0.75 * s, .y = mid - 4 * s, .w = 1.5 * s, .h = 8 * s }; - notch.fill(.all(0.75 * s), .{ .color = .{ .color = .black } }); - } - - // The playhead: where the document is. - const xa = rules.stopX(at, r.x, r.x + r.w, trim.n); - const head: dvui.Rect.Physical = .{ .x = xa - 1 * s, .y = r.y - 3 * s, .w = 2 * s, .h = r.h + 6 * s }; - head.fill(.all(1 * s), .{ .color = .{ .color = .white } }); -} diff --git a/app/history_transport.zig b/app/history_transport.zig index 7578f8b95..e9618748a 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -76,8 +76,9 @@ pub const Repeat = struct { } }; -/// A trim of a history of `n` steps (`TrimBar`): the steps from after `from` through `to` are -/// kept, at least one, as macOS's trim keeps the part of a recording between its two handles. +/// A window over a history of `n` steps (`HistoryTransport`'s export window): the steps from after +/// `from` through `to`, at least one, as macOS's trim keeps the part of a recording between its two +/// handles. pub const Trim = struct { from: usize, to: usize, diff --git a/app/root.zig b/app/root.zig index f507f72b2..33075ee05 100644 --- a/app/root.zig +++ b/app/root.zig @@ -44,7 +44,6 @@ pub const ActivityFeed = @import("ActivityFeed.zig"); pub const Tracks = @import("Tracks.zig"); /// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. pub const HistoryTransport = @import("HistoryTransport.zig"); -pub const TrimBar = @import("TrimBar.zig"); pub const HistoryFrames = @import("HistoryFrames.zig"); pub const history_transport = @import("history_transport.zig"); @@ -186,6 +185,5 @@ test { _ = Tracks; _ = HistoryTransport; _ = control.routes; - _ = TrimBar; _ = HistoryFrames; } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index e332c1969..c68235667 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -133,9 +133,9 @@ command_palette: @import("CommandPalette.zig") = .{}, demo: Demo = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). tape_tabs: TapeTabs = .{}, -/// A document whose transport's share was pressed: exported at the top of the next frame, before -/// anything draws, since the export walks the document's history back and forth. -share_pending: ?u64 = null, +/// A part of a document's history to export (the strip's export window): at the top of the next +/// frame, before anything draws, since the export walks the document's history back and forth. +share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, /// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). plugin_reloads: @import("PluginReloads.zig") = .{}, @@ -3024,58 +3024,67 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { } } // Its history, while undo or redo or the pointer keep it: not while someone else holds it. - // A shared tape being trimmed has the trim bar instead. - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else if (editor.tape_tabs.trimOf(doc.id, dvui.currentWindow().arena())) |trim| { - TapeTabs.drawTrim(doc, trim[0], trim[1], canvas.data().borderRectScale().r, @truncate(ds.doc_id)); - } else { - const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; - const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable, .frames = if (recorded != null) &ds.frames else null }); - if (pressed.share) { - editor.share_pending = doc.id; + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else { + // Exported from where its track began on; while the export window is open, what it holds. + const track = if (recorded) |tr| if (tr.steps.items.len > 0 and tr.base != null) tr else null else null; + const summary: []const u8 = if (track != null) if (ds.transport.window) |win| exportSummary(editor, doc, win.from, win.to) else "" else ""; + const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ + .frames = if (recorded != null) &ds.frames else null, + .export_from = if (track) |tr| tr.base_top else null, + .window_summary = summary, + }); + if (pressed.exported) |win| { + editor.share_pending = .{ .doc_id = doc.id, .from = win.from, .to = win.to }; dvui.refresh(null, @src(), null); } } return .ok; } -/// How `doc` was made, as a tape to trim: its whole track exported (`Tracks.exportTape`) and -/// opened in a tab of its own, played in at once, for the person to trim and share -/// (`TapeTabs`, `TrimBar`). Nothing is written until they share. The transport's share. -pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle) void { +/// What the export window from stop `from` to stop `to` of `doc`'s history holds, in words: "4 +/// steps, 12 s, by you and Claude". Empty when part of it was made before its track began. +fn exportSummary(editor: *Editor, doc: sdk.DocHandle, from: usize, to: usize) []const u8 { + const arena = dvui.currentWindow().arena(); + const t = &(editor.app.tracks orelse return ""); + const line = doc.owner.historyLine(doc, arena) orelse return ""; + const sum = t.lineSummary(&editor.app.host, doc, arena, line, from, to) orelse return ""; + var out: std.Io.Writer.Allocating = .init(arena); + const w = &out.writer; + const secs = (sum.played_ms + 500) / 1000; + w.print("{d} step{s}, ", .{ sum.steps, if (sum.steps == 1) "" else "s" }) catch {}; + if (secs < 60) w.print("{d} s", .{secs}) catch {} else w.print("{d} min {d} s", .{ secs / 60, secs % 60 }) catch {}; + if (sum.authors.len > 0) w.writeAll(", by ") catch {}; + for (sum.authors, 0..) |a, i| { + if (i > 0) w.writeAll(if (i == sum.authors.len - 1) " and " else ", ") catch {}; + w.writeAll(switch (a.kind) { + .person => "you", + .unattributed => "someone unknown", + else => if (a.label.len > 0) a.label else @tagName(a.kind), + }) catch {}; + } + return out.written(); +} + +/// A part of `doc`'s history as a tape (the strip's export window, stops `from` to `to`): +/// exported as the strip shows it (`Tracks.exportLine`), kept in the config folder's `tapes/`, +/// and said on the document. +pub fn exportDocument(editor: *Editor, doc: sdk.DocHandle, from: usize, to: usize) void { const gpa = editor.app.gpa; const t = &(editor.app.tracks orelse return); - const bytes = t.exportTape(&editor.app.host, doc, gpa, .{}) catch |err| { - dvui.log.warn("share: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); - _ = editor.noteOnDocument(doc.id, "Could not share"); + const bytes = t.exportLine(&editor.app.host, doc, gpa, from, to, .{}) catch |err| { + dvui.log.warn("export: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + _ = editor.noteOnDocument(doc.id, "Could not export"); return; }; defer gpa.free(bytes); - // Whose each step is, for the trim to say. + if (comptime builtin.target.cpu.arch == .wasm32) return; const arena = dvui.currentWindow().arena(); - const steps = (t.trackOf(doc.id) orelse return).steps.items; - const by = arena.alloc(u8, steps.len) catch return; - var names: std.ArrayListUnmanaged([]const u8) = .empty; - var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; - for (steps, by) |r, *b| { - const who = editor.app.host.stepAuthor(doc.id, r.id); - const i = for (authors.items, 0..) |a, i| { - if (a.same(who)) break i; - } else i: { - if (authors.items.len == 255) break :i 254; - authors.append(arena, who) catch return; - names.append(arena, switch (who.kind) { - .person => "you", - .unattributed => "someone unknown", - else => if (who.label.len > 0) who.label else @tagName(who.kind), - }) catch return; - break :i authors.items.len - 1; - }; - b.* = @intCast(i); - } - editor.tape_tabs.open(editor, bytes, .{ .trim = .{ .by = by, .names = names.items } }) catch |err| { - dvui.log.warn("share: could not open the tape: {t}", .{err}); - _ = editor.noteOnDocument(doc.id, "Could not open the tape"); + const saved = editor.keepTape(arena, doc, bytes, "") catch |err| { + dvui.log.warn("export: could not keep the tape: {t}", .{err}); + _ = editor.noteOnDocument(doc.id, "Could not save the tape"); + return; }; + _ = editor.noteOnDocument(doc.id, std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(saved)}) catch "Saved the tape"); } /// Say `message` on the document's own panes, in the glass its commands show in @@ -4096,9 +4105,9 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.doc_io.tick(); // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); - if (editor.share_pending) |id| { + if (editor.share_pending) |p| { editor.share_pending = null; - if (editor.app.open_files.get(id)) |doc| editor.shareDocument(doc); + if (editor.app.open_files.get(p.doc_id)) |doc| editor.exportDocument(doc, p.from, p.to); } editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig index e9027c8c2..1d51c0d1b 100644 --- a/src/editor/TapeTabs.zig +++ b/src/editor/TapeTabs.zig @@ -11,13 +11,6 @@ //! tape is a document among theirs. Closing it ends the playback and the mount. While nothing is //! playing a frame costs one check of an empty list; while one is, a timer wakes it for the next //! step and nothing more. -//! -//! **Trimming.** A tape opened to be shared (`OpenOptions.trim`, the transport's share) plays in -//! at once and stays in its tab for the person to trim, as macOS trims a recording: the pane's bar -//! is a `TrimBar`, the document showing wherever its handles are dragged, and play previews the -//! part kept. Share writes that part as a tape of its own (`trimmed`): the document's file where -//! the part begins, the steps from there, timed from its first. Nothing is written until then; -//! cancel closes the tab. const TapeTabs = @This(); const std = @import("std"); @@ -25,8 +18,7 @@ const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const core = fizzy.core; const sdk = fizzy.sdk; -const app = @import("app"); -const automation = app.automation; +const automation = @import("app").automation; const Editor = @import("Editor.zig"); playing: std.ArrayListUnmanaged(*Playback) = .empty, @@ -51,45 +43,11 @@ const Playback = struct { opened_ns: i128, /// Said on the tape's own tab once it shows (`Editor.noteOnDocument`); owned. note: ?[]u8 = null, - /// Being trimmed before it is shared; null for a tape that only plays. - trim: ?*Trimming = null, -}; - -/// A tape in its tab, being trimmed (`TrimBar`). -pub const Trimming = struct { - span: app.history_transport.Trim = .all(0), - bar: app.TrimBar = .{}, - /// Each step's time and length in the tape, ms, in order. - at: []u32, - ms: []u32, - /// Who made each step, as an index into `names`; empty when not known. - by: []u8, - names: [][]u8, - /// The preview playing the part kept: when its first step played, as frame time. - playing_since: ?i128 = null, - /// Pressed on the bar, done at the next frame's top: sharing walks the document's history. - action: enum { none, share, cancel } = .none, - - fn deinit(self: *Trimming, gpa: std.mem.Allocator) void { - gpa.free(self.at); - gpa.free(self.ms); - gpa.free(self.by); - for (self.names) |n| gpa.free(n); - gpa.free(self.names); - } -}; - -/// Who made a shared track's steps, for the trim's summary: an author name per step, by index. -pub const Authors = struct { - by: []const u8, - names: []const []const u8, }; pub const OpenOptions = struct { /// Said on the tape's tab once it shows. note: ?[]const u8 = null, - /// Open it to be trimmed and shared (the transport's share): played in at once, then trimmed. - trim: ?Authors = null, }; const load_limit_ns: i128 = 30 * std.time.ns_per_s; @@ -108,8 +66,7 @@ pub const OpenError = error{ OutOfMemory, }; -/// Open the tape in `bytes` in a tab of its own and play it, or play it in at once and trim it -/// (`opts.trim`). The bytes may be let go of at once. +/// Open the tape in `bytes` in a tab of its own and play it. The bytes may be let go of at once. pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, opts: OpenOptions) (OpenError || anyerror)!void { const gpa = editor.app.gpa; var check = automation.Input.check; @@ -148,11 +105,6 @@ pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, opts: OpenOptio errdefer gpa.destroy(p); p.* = .{ .owned = owned, .mem = mem, .root = root, .path = path, .opened_ns = dvui.frameTimeNS(), .note = if (opts.note) |n| try gpa.dupe(u8, n) else null }; errdefer if (p.note) |n| gpa.free(n); - if (opts.trim) |authors| p.trim = try trimming(gpa, tape, authors); - errdefer if (p.trim) |t| { - t.deinit(gpa); - gpa.destroy(t); - }; _ = try editor.openFilePath(path, editor.workbench.currentGroupingID()); // Last, once nothing above can fail: the list owns it from here. try self.playing.append(gpa, p); @@ -188,58 +140,18 @@ pub fn tick(self: *TapeTabs, editor: *Editor) void { editor.app.gpa.free(n); p.note = null; }; - if (p.trim) |t| switch (t.action) { - .none => preview(t, doc.?, now), - .cancel => { - // Nothing of it was written: the tab goes, and the playback with it next frame. - editor.rawCloseFileID(doc.?.id) catch {}; - t.action = .none; - }, - .share => { - t.action = .none; - share(editor, p, t, doc.?); - }, - }; advance(editor, p, doc.?, now); i += 1; } } -/// Write the part kept as a tape in the config folder's `tapes/`, and end the trim: the tab is the -/// tape's from here, its transport back. -fn share(editor: *Editor, p: *Playback, t: *Trimming, doc: sdk.DocHandle) void { - const gpa = editor.app.gpa; - const bytes = trimmed(editor, p, t, doc, gpa) catch |err| { - dvui.log.warn("share: could not trim {s}: {t}", .{ p.path, err }); - _ = editor.noteOnDocument(doc.id, "Could not share"); - return; - }; - defer gpa.free(bytes); - const arena = dvui.currentWindow().arena(); - if (comptime @import("builtin").target.cpu.arch != .wasm32) { - const saved = editor.keepTape(arena, doc, bytes, "") catch |err| { - dvui.log.warn("share: could not keep the tape: {t}", .{err}); - _ = editor.noteOnDocument(doc.id, "Could not save the tape"); - return; - }; - _ = editor.noteOnDocument(doc.id, std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(saved)}) catch "Saved the tape"); - } - t.deinit(gpa); - gpa.destroy(t); - p.trim = null; - // The handles moved the document about; as it stands now is the tab's, so closing it asks - // nothing. - editor.writeDocument(doc) catch |err| dvui.log.warn("tape: could not keep {s}: {t}", .{ p.path, err }); -} - /// Apply `p`'s steps due by `now`, and wake for the next. Played out, the document is written to /// its own mount: it is as the tape left it, so closing it asks nothing (an edit of the person's /// after that would). fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { const ops = p.owned.tape.ops; if (p.op == ops.len) return; - // To be trimmed, it plays in at once: the whole of it is there to trim. - const elapsed_ms: u64 = if (p.trim != null) std.math.maxInt(u64) else @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); + const elapsed_ms: u64 = @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); while (p.op < ops.len and ops[p.op].at <= elapsed_ms) : (p.op += 1) { const op = ops[p.op]; switch (op.do) { @@ -261,188 +173,6 @@ fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(p)), @intCast(@min(wait_ms * 1000 + 1, std.math.maxInt(i32)))); } -fn trimming(gpa: std.mem.Allocator, tape: automation.Tape, authors: Authors) !*Trimming { - var n: usize = 0; - for (tape.ops) |op| n += @intFromBool(op.do == .step); - const t = try gpa.create(Trimming); - errdefer gpa.destroy(t); - const at = try gpa.alloc(u32, n); - errdefer gpa.free(at); - const ms = try gpa.alloc(u32, n); - errdefer gpa.free(ms); - var i: usize = 0; - for (tape.ops) |op| if (op.do == .step) { - at[i] = op.at; - ms[i] = op.ms; - i += 1; - }; - const by = try gpa.dupe(u8, if (authors.by.len == n) authors.by else &.{}); - errdefer gpa.free(by); - const names = try gpa.alloc([]u8, authors.names.len); - var made: usize = 0; - errdefer { - for (names[0..made]) |nm| gpa.free(nm); - gpa.free(names); - } - for (authors.names, names) |a, *nm| { - nm.* = try gpa.dupe(u8, a); - made += 1; - } - t.* = .{ .span = .all(n), .at = at, .ms = ms, .by = by, .names = names }; - return t; -} - -/// The tape at `path` being trimmed, and the summary its bar shows: how many steps the part kept -/// holds, how long it plays, whose they are. Null for a tape that only plays, or one still -/// playing in. -pub fn trimOf(self: *const TapeTabs, doc_id: u64, arena: std.mem.Allocator) ?struct { *Trimming, []const u8 } { - for (self.playing.items) |p| { - if (p.doc_id != doc_id) continue; - const t = p.trim orelse return null; - if (p.op < p.owned.tape.ops.len) return null; - return .{ t, summary(arena, t) }; - } - return null; -} - -fn summary(arena: std.mem.Allocator, t: *const Trimming) []const u8 { - const from = t.span.from; - const to = t.span.to; - if (to <= from) return ""; - const ms: u64 = t.at[to - 1] + t.ms[to - 1] - t.at[from]; - var out: std.Io.Writer.Allocating = .init(arena); - const w = &out.writer; - w.print("{d} step{s}, ", .{ to - from, if (to - from == 1) "" else "s" }) catch {}; - const secs = (ms + 500) / 1000; - if (secs < 60) w.print("{d} s", .{secs}) catch {} else w.print("{d} min {d} s", .{ secs / 60, secs % 60 }) catch {}; - if (t.by.len == t.at.len and t.names.len > 0) { - // Whose, each once, in the order they first come. - var seen: [8]u8 = undefined; - var count: usize = 0; - for (t.by[from..to]) |b| { - if (std.mem.indexOfScalar(u8, seen[0..count], b) != null) continue; - if (count == seen.len) break; - seen[count] = b; - count += 1; - } - w.writeAll(", by ") catch {}; - for (seen[0..count], 0..) |b, i| { - if (i > 0) w.writeAll(if (i == count - 1) " and " else ", ") catch {}; - w.writeAll(t.names[b]) catch {}; - } - } - return out.written(); -} - -/// The bar on a trimmed tape's pane, drawn by the pane in place of its transport; what was -/// pressed is done at the next frame's top (`tick`). -pub fn drawTrim(doc: sdk.DocHandle, t: *Trimming, text: []const u8, area: dvui.Rect.Physical, id_extra: usize) void { - const pressed = t.bar.draw(doc, area, id_extra, &t.span, text, t.playing_since != null); - if (pressed.grabbed) t.playing_since = null; - if (pressed.play) { - if (t.playing_since != null) t.playing_since = null else play(doc, t); - dvui.refresh(null, @src(), null); - } - if (pressed.cancel) t.action = .cancel; - if (pressed.share) t.action = .share; - if (t.action != .none) dvui.refresh(null, @src(), null); -} - -/// Start the preview from the playhead, or from the part's start when the playhead is outside it -/// or at its end. -fn play(doc: sdk.DocHandle, t: *Trimming) void { - const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; - var head = line.at; - if (head < t.span.from or head >= t.span.to) { - head = t.span.from; - _ = doc.owner.historyGoTo(doc, if (head == 0) 0 else line.entries[head - 1].id); - } - const into: i128 = t.at[head] -| t.at[t.span.from]; - t.playing_since = dvui.frameTimeNS() - into * std.time.ns_per_ms; -} - -/// The preview's next frame: the document goes to the last step of the part whose moment has -/// come, and a timer wakes it for the one after. Ends at the part's end. -fn preview(t: *Trimming, doc: sdk.DocHandle, now: i128) void { - const since = t.playing_since orelse return; - const elapsed: i128 = @divTrunc(now - since, std.time.ns_per_ms); - const first = t.at[t.span.from]; - var stop = t.span.from; - while (stop < t.span.to and @as(i128, t.at[stop] - first) <= elapsed) stop += 1; - const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; - if (line.entries.len != t.span.n) { - t.playing_since = null; - return; - } - if (stop != line.at) _ = doc.owner.historyGoTo(doc, if (stop == 0) 0 else line.entries[stop - 1].id); - if (stop >= t.span.to) { - t.playing_since = null; - return; - } - const wait_ms: i128 = @as(i128, t.at[stop] - first) - elapsed; - dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(t)), @intCast(@min(@max(wait_ms, 0) * 1000 + 1, std.math.maxInt(i32)))); -} - -pub const TrimError = error{ NotTheTape, NoBytes, NotReturned, OutOfMemory }; - -/// The part of `p`'s tape that `t` keeps, as a tape of its own (binary, deflated, owned by `gpa`): -/// its keyframe the document's file where the part begins, written by its owner with the document -/// taken back there and brought forward again in the same call, unheard by its track and the -/// activity feed; then the part's steps, timed from its first. -fn trimmed(editor: *Editor, p: *Playback, t: *const Trimming, doc: sdk.DocHandle, gpa: std.mem.Allocator) TrimError![]u8 { - var arena_state: std.heap.ArenaAllocator = .init(gpa); - defer arena_state.deinit(); - const arena = arena_state.allocator(); - const tape = p.owned.tape; - const file = tape.keyframes[0].files[0]; - const line = doc.owner.historyLine(doc, arena) orelse return error.NotTheTape; - if (line.entries.len != t.span.n) return error.NotTheTape; - const stopId = struct { - fn of(l: sdk.Plugin.HistoryLine, i: usize) u64 { - return if (i == 0) 0 else l.entries[i - 1].id; - } - }.of; - - const bytes = bytes: { - const host = &editor.app.host; - const tracks = if (editor.app.tracks) |*tr| tr else null; - const was_track_muted = if (tracks) |tr| tr.muted else 0; - if (tracks) |tr| tr.muted = doc.id; - defer if (tracks) |tr| { - tr.muted = was_track_muted; - }; - const was_muted = host.activity_muted; - host.activity_muted = true; - defer host.activity_muted = was_muted; - const now = line.at; - const moved = now != t.span.from; - if (moved and !doc.owner.historyGoTo(doc, stopId(line, t.span.from))) return error.NotTheTape; - const written = doc.owner.documentBytes(doc, file.path, arena) catch null; - if (moved and !doc.owner.historyGoTo(doc, stopId(line, now))) return error.NotReturned; - break :bytes written orelse return error.NoBytes; - }; - - const files = try arena.dupe(automation.Tape.File, tape.keyframes[0].files); - files[0].text = bytes; - const keyframes = try arena.dupe(automation.Tape.Keyframe, tape.keyframes); - keyframes[0].files = files; - const ops = try arena.alloc(automation.Tape.Op, t.span.to - t.span.from + 2); - ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; - ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; - var i: usize = 0; - var out: usize = 2; - const first = t.at[t.span.from]; - for (tape.ops) |op| if (op.do == .step) { - if (i >= t.span.from and i < t.span.to) { - ops[out] = op; - ops[out].at = op.at - first; - out += 1; - } - i += 1; - }; - return automation.binary.encode(gpa, .{ .name = tape.name, .title = tape.title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch error.OutOfMemory; -} - fn openUnder(editor: *Editor, root: []const u8) bool { for (editor.app.open_files.values()) |d| { const path = d.owner.documentPath(d); @@ -465,10 +195,6 @@ fn free(editor: *Editor, p: *Playback) void { gpa.free(p.root); gpa.free(p.path); if (p.note) |n| gpa.free(n); - if (p.trim) |t| { - t.deinit(gpa); - gpa.destroy(t); - } p.owned.deinit(); gpa.destroy(p); } diff --git a/tests/integration.zig b/tests/integration.zig index 070035425..07974f918 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8232,70 +8232,45 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(editor.docFromPath(played_path) == null); try std.testing.expectEqual(@as(usize, 0), editor.tape_tabs.playing.items.len); - // The transport's share (pressed: `share_pending`), at the top of the next frame: the - // whole track opened in a tab to trim, played in at once, nothing written yet. Trimmed - // to the part after two steps and shared, the tape kept holds that part: the document as - // it was after two steps, then the four after. Cancelled, nothing is written and the tab - // goes. + // The strip's export window (pressed: `share_pending`), at the top of the next frame: + // stops 2 to 5 of a line of five entries with the last undone. The tape kept holds what + // the strip shows: the document after two entries, then the steps that made the next + // three, the undone one too; the person's document and its history are where they were. + { + const tracks = &editor.app.tracks.?; + const line = doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 5), line.entries.len); + try std.testing.expectEqual(@as(usize, 4), line.at); + const sum = tracks.lineSummary(host, doc, host.arena(), line, 2, 5) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 3), sum.steps); + } const tapes_dir = try std.fs.path.join(gpa, &.{ editor.app.config_folder, "tapes" }); defer gpa.free(tapes_dir); - const newest = struct { - /// How many tapes there are, and the newest name not in `before` (owned by `a`). - fn newest(a: std.mem.Allocator, dir_path: []const u8, before: []const []const u8) !struct { usize, ?[]const u8 } { - var dir = try std.Io.Dir.cwd().openDir(dvui.io, dir_path, .{ .iterate = true }); - defer dir.close(dvui.io); - var it = dir.iterate(); - var n: usize = 0; - var fresh: ?[]const u8 = null; - while (try it.next(dvui.io)) |e| { - n += 1; - for (before) |b| { - if (std.mem.eql(u8, b, e.name)) break; - } else fresh = try a.dupe(u8, e.name); - } - return .{ n, fresh }; - } - }.newest; var keep_state: std.heap.ArenaAllocator = .init(gpa); defer keep_state.deinit(); const keep = keep_state.allocator(); - var names: std.ArrayListUnmanaged([]const u8) = .empty; - { - var dir = try std.Io.Dir.cwd().openDir(dvui.io, tapes_dir, .{ .iterate = true }); - defer dir.close(dvui.io); - var it = dir.iterate(); - while (try it.next(dvui.io)) |e| try names.append(keep, try keep.dupe(u8, e.name)); - } - const kept_before = names.items.len; - const openTrim = struct { - fn open(ed: *fizzy.Editor, id: u64, at_path: []const u8) !sdk.DocHandle { - ed.share_pending = id; - for (0..400) |_| { - _ = try dvui.testing.step(headlessFrame); - if (ed.docFromPath(at_path)) |d| if (ed.tape_tabs.trimOf(d.id, dvui.currentWindow().arena()) != null) return d; - } - return error.TestUnexpectedResult; + const listing = struct { + fn names(a: std.mem.Allocator, dir_path: []const u8) ![]const []const u8 { + var dir = try std.Io.Dir.cwd().openDir(dvui.io, dir_path, .{ .iterate = true }); + defer dir.close(dvui.io); + var it = dir.iterate(); + var found: std.ArrayListUnmanaged([]const u8) = .empty; + while (try it.next(dvui.io)) |e| try found.append(a, try a.dupe(u8, e.name)); + return found.items; } - }.open; - const shared_path = try std.fmt.allocPrint(gpa, "tape://2/{s}", .{std.fs.path.basename(path)}); - defer gpa.free(shared_path); - const shared = try openTrim(editor, doc.id, shared_path); - try std.testing.expectEqual(kept_before, (try newest(keep, tapes_dir, names.items))[0]); + }.names; + const before = try listing(keep, tapes_dir); + editor.share_pending = .{ .doc_id = doc.id, .from = 2, .to = 5 }; try dvui.testing.settle(headlessFrame); - try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); - { - const trim, const said = editor.tape_tabs.trimOf(shared.id, host.arena()).?; - try std.testing.expectEqual(@as(usize, 6), trim.span.n); - try std.testing.expect(std.mem.startsWith(u8, said, "6 steps")); - trim.span.from = 2; - trim.action = .share; - } - for (0..20) |_| _ = try dvui.testing.step(headlessFrame); - try std.testing.expect(editor.tape_tabs.trimOf(shared.id, host.arena()) == null); - const kept_after, const fresh = try newest(keep, tapes_dir, names.items); - try std.testing.expectEqual(kept_before + 1, kept_after); + const after = try listing(keep, tapes_dir); + try std.testing.expectEqual(before.len + 1, after.len); + const fresh = for (after) |name| { + for (before) |b| { + if (std.mem.eql(u8, b, name)) break; + } else break name; + } else return error.TestUnexpectedResult; { - const kept_path = try std.fs.path.join(keep, &.{ tapes_dir, fresh.? }); + const kept_path = try std.fs.path.join(keep, &.{ tapes_dir, fresh }); const kept = try std.Io.Dir.cwd().readFileAlloc(dvui.io, kept_path, gpa, .limited(1 << 20)); defer gpa.free(kept); var kept_check = app.automation.Input.check; @@ -8305,21 +8280,10 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("two\none\n", kept_tape.tape.keyframes[0].files[0].text); var kept_steps: usize = 0; for (kept_tape.tape.ops) |op| kept_steps += @intFromBool(op.do == .step); - try std.testing.expectEqual(@as(usize, 4), kept_steps); + try std.testing.expectEqual(@as(usize, 3), kept_steps); } - // The tab and the person's document are where they were. - try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); try std.testing.expectEqualStrings(want, try textOf(host, path)); - try editor.rawCloseFileID(shared.id); - try dvui.testing.settle(headlessFrame); - - const cancelled_path = try std.fmt.allocPrint(gpa, "tape://3/{s}", .{std.fs.path.basename(path)}); - defer gpa.free(cancelled_path); - const cancelled = try openTrim(editor, doc.id, cancelled_path); - editor.tape_tabs.trimOf(cancelled.id, host.arena()).?[0].action = .cancel; - try dvui.testing.settle(headlessFrame); - try std.testing.expect(editor.docFromPath(cancelled_path) == null); - try std.testing.expectEqual(kept_after, (try newest(keep, tapes_dir, names.items))[0]); + try std.testing.expectEqual(@as(usize, 4), (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult).at); try editor.rawCloseFileID(doc.id); From 6155170474c076b87f1c87582dd9b5b354be3a4b Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:20:50 -0500 Subject: [PATCH 17/31] web: start fetching the editor before the page is shown (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while working on #514: why the web app sometimes takes a long time to load. ## Why `web/index.html` starts the editor only after two animation frames (`waitForLayout`, so the canvas has its size), and a page that is not shown gets none. Measured in a hidden page: 0 animation frames in 5 s, the loading screen up the whole time. So a tab opened in the background, or a page in a window that is covered or on another Space, does not even begin downloading the editor until someone looks at it. Then it downloads: `fizzyed.it/app/web.wasm` is 3.4 MB gzipped, and it came at 80–130 KB/s in three tries (26–42 s; see "Not this change"). ## What changes In worker mode (Chromium), the page fetches and compiles `web.wasm` as soon as its script runs (`WebAssembly.compileStreaming`), shown or not: - `startWorker` posts the compiled module with the worker's `init`, after waiting for the compile, which has usually finished by then. - The worker instantiates the module it was handed (`WebAssembly.instantiate`) and fetches nothing. Its old path (fetch and `instantiateStreaming`) stays for when there is no module: a failed early compile logs a warning and falls back to it. - **Nothing downloads twice.** A plain preload or early `fetch` would warm the cache, but a page shown at once starts its worker a frame later, while the first download is still running, and fetches the wasm again. Handing over the module avoids that. - **What's left to the frame gate** is creating the worker and instantiating, both quick. The main-thread path (Safari, Firefox) loads through dvui's `web.js`, which fetches the wasm itself; it is unchanged here. ## Not this change The download itself is slow because Cloudflare does not cache `.wasm`: `cf-cache-status: DYNAMIC` on every request to `fizzyed.it/app/web.wasm`, so every load goes back to GitHub Pages at about 100 KB/s, while the same machine reads 31 MB/s from Cloudflare. A Cloudflare cache rule ("eligible for cache", long edge and browser TTL) for `/app/*.wasm` and plugin wasm fixes that, and every URL here carries a cache-buster, so long TTLs are safe. It is a setting in the Cloudflare dashboard, not code. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] `scripts/check-fmt.sh`, `zig build`, `zig build check-web`; `zig build test-integration` 53/53 steps (nothing here is in it). - [x] **Shown at once** (a headless Chrome driven over the DevTools protocol, fresh profile): the editor came up ("startup: shown 134 ms after main", loading screen gone), and the local server logged `web.wasm` fetched once, before the worker script. - [x] **Not shown** (the desktop app's browser pane, behind the chat, `visibilityState: "hidden"`): the page fetched `web.wasm` while hidden (the Resource Timing entry is there). - [ ] Firefox and Safari: unchanged path, not run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- web/fizzy-worker.js | 8 ++++++-- web/index.html | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index ff9958184..89aaebdeb 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -589,11 +589,15 @@ async function start(init) { if (len > 0) postMessage({ type: "clipboard", text: app.stringFromPointer(ptr, len) }); }; - const result = await WebAssembly.instantiateStreaming(fetch(init.wasmUrl), { + const wasmImports = { dvui: imports, fizzy: fizzyImports(() => wasmInstance), env: { __indirect_function_table: functionTable }, - }); + }; + // The page compiled the editor while it waited to be shown (`wasmModule`); without one, fetch it. + const result = init.wasmModule + ? { instance: await WebAssembly.instantiate(init.wasmModule, wasmImports) } + : await WebAssembly.instantiateStreaming(fetch(init.wasmUrl), wasmImports); wasmInstance = result.instance; app.setInstance(result.instance); app.setCanvas(env.canvas); diff --git a/web/index.html b/web/index.html index 564b6c84c..00c6491a4 100644 --- a/web/index.html +++ b/web/index.html @@ -854,7 +854,7 @@ // what only a page can be: where input lands (sent on as dvui would have queued it), and // where the worker's cursor, on-screen keyboard, links, downloads, file pickers, // clipboard, full screen and OAuth popups happen. - function startWorker(wasmUrl) { + function startWorker(wasmUrl, wasmModule) { const canvas = dvuiCanvas; const worker = new Worker("fizzy-worker.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", { type: "module" }); const post = (msg, transfer) => worker.postMessage(msg, transfer || []); @@ -909,10 +909,12 @@ let boundKeys = new Set(); const offscreen = canvas.transferControlToOffscreen(); - post({ + // The init waits for the early compile (`wasmModule`), which by now has usually finished. + Promise.resolve(wasmModule).then((module) => post({ type: "init", canvas: offscreen, wasmUrl: new URL(wasmUrl, document.baseURI).href, + wasmModule: module || null, webJsUrl: new URL("web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", document.baseURI).href, size: size(), prefs: prefs(), @@ -921,7 +923,7 @@ buildStorageName, search: location.search, baseURI: document.baseURI, - }, [offscreen]); + }, [offscreen])); new ResizeObserver(() => post({ type: "size", size: size() })).observe(canvas); window.addEventListener("resize", () => post({ type: "size", size: size() })); @@ -1159,7 +1161,16 @@ showError("This browser does not support WebGL, which fizzy needs to run."); } else if (workerMode) { const wasmUrl = "web.wasm?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256"; - waitForLayout().then(() => startWorker(wasmUrl)).catch((err) => { + // Fetched and compiled now, seen or not. A page opened in a background tab gets no + // animation frames, so `waitForLayout` holds the worker until the tab is shown; the + // editor's download used to wait with it, tens of seconds on a slow link. The worker is + // handed the compiled module and fetches nothing itself, so a page shown at once never + // downloads it twice. Null on failure: the worker then fetches it as before. + const wasmModule = WebAssembly.compileStreaming(fetch(wasmUrl)).catch((err) => { + console.warn("fizzy: compiling the editor early failed; the worker fetches it", err); + return null; + }); + waitForLayout().then(() => startWorker(wasmUrl, wasmModule)).catch((err) => { console.error(err); showError(`Failed to load the editor (${err instanceof Error ? err.message : String(err)}).`); }); From ae60b4fcfc03a5f4f1a66220a5f250a5f8890666 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:21:42 -0500 Subject: [PATCH 18/31] web: play a tape from a link, loading the plugins it needs (#514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 7: "web: play a tape from a link. `?tape=`, loading the tape's owners from the store, the player shape; the site embeds one." ## What changes **`?tape=`** on the web app fetches the tape (the page and its worker both read it) and hands it to fizzy (`FizzyWebPlayTape`, listed in `build/web.zig`'s exports). **The tape waits for its plugins** (`TapeTabs.openWhenReady`): - **What it needs:** it reads which plugins its documents belong to (each keyframe file's `owner`), with a check that accepts any owner, since whether they can apply its steps is asked once they are loaded. - **Missing ones on the web** are asked of the store each frame until they arrive, up to a minute. The store's catalog is fetched first if nothing has (`PluginStore.wantCatalog`): on a page that never opened the store, nothing had, so an install by id silently did nothing. - **Loaded for the visit, not installed** (`PluginStore.loadForVisit`, `PluginManager.loadFromUrl`): the build is linked as an install would be, but not remembered for the next visit and nothing is asked. Before this, the first-install "File types" dialog opened over the tape. Someone who wants the plugin installs it from the store as ever. - **Then it plays:** in a tab of its own (one document's tape), or on the demo stage (anything else, as `?demo=` does). It plays with nothing else in view (`show_only_document`, as `?open=…&show=` does), so the page is the tape. - **On the desktop**, `Demo: Play a Tape…` opens a tape the same way. With a plugin missing it says which ("This tape needs the pixi plugin") instead of opening a document no owner can step. Nothing is fetched natively. - The tape is looked at from the next frame's top, never inside the page's call: the page can call between frames, where dvui's frame state is not there. **The player shape:** the plan's "one view and the transport, nothing else" is the existing show-only-the-document state, so no new layout shape is needed. A copyable shape for apps built on fizzy stays a follow-up. **A trap written down** (`web/AGENTS.md`): a new `export fn` the page calls must be listed in `build/web.zig`'s `export_symbol_names`. Unlisted, it compiles and its name even appears in the binary, but the page sees `exports.FizzyWebX` undefined and its guard returns without a word. This cost most of the debugging here. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 561/561 tests. - **Integration:** the tab player's test now opens its tape through `openWhenReady`, played the next frame. The same tape re-encoded to name a plugin that isn't loaded opens nothing and stops waiting. - [x] **Web, in Chrome** (a headless Chrome driven over the DevTools protocol, its page and worker consoles read, screenshots taken), against `zig build web` served locally: - `?tape=tapes/notes.tape`, a text tape exported from the strip: the page opened only the tape's tab, played it to its end, and the tab was clean. - `?tape=tapes/pixi.tape`, three pencil strokes recorded in pixi on the desktop and exported (744 bytes), on a fresh profile with no plugins (`-Doptimize=ReleaseSmall`, to match the store's builds): pixi v0.1.47's web build loaded from the store at 3.1 s and the strokes played into the sprite. No dialog, and nothing in `localStorage` afterwards. - [ ] Windows, Linux: CI. ## Seen while testing, not this change - **The web app's loading can take a minute.** `fizzyed.it/app/web.wasm` (3.4 MB gzipped) came at 80–130 KB/s in three tries (26–42 s), while the same machine reads 31 MB/s from Cloudflare. Cloudflare answers `cf-cache-status: DYNAMIC` for `.wasm`, so it never caches it and every load goes back to GitHub Pages. A Cloudflare cache rule for `/app/*.wasm` (and plugin wasm) fixes it; that is a dashboard setting. `max-age=600` on files whose URLs carry a cache-buster could be far longer too. - **A page opened unseen does not start loading.** `waitForLayout` waits for two animation frames, and a hidden or background page gets none (0 in 5 s, measured), so the editor's download only begins once the tab is shown. Starting the wasm and worker fetch at once would take that off the critical path: a small `web/index.html` change, for its own PR. ## Follow-ups - **The site's embed:** fizzyed.it showing a tape from `?tape=` (the website repo). - **A copyable player shape** for apps built on fizzy. - **Fit to View** for a played pixi tape (#444). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/store/PluginManager.zig | 8 +++ app/store/PluginStore.zig | 31 +++++++++ build/web.zig | 2 + docs/AUTOMATION.md | 6 ++ src/editor/Editor.zig | 28 ++++++++- src/editor/Keybinds.zig | 6 +- src/editor/TapeTabs.zig | 121 ++++++++++++++++++++++++++++++++++++ tests/integration.zig | 25 +++++++- web/AGENTS.md | 3 + web/fizzy-worker.js | 20 ++++++ web/index.html | 20 ++++++ 11 files changed, 265 insertions(+), 5 deletions(-) diff --git a/app/store/PluginManager.zig b/app/store/PluginManager.zig index 393151e4a..003084612 100644 --- a/app/store/PluginManager.zig +++ b/app/store/PluginManager.zig @@ -104,6 +104,10 @@ pub const VTable = struct { /// URL (there is no plugins directory to download into). Null for an app that does not /// load web plugins. installFromUrl: ?*const fn (ctx: *anyopaque, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void = null, + /// The web's load for one visit: link the build at `url` as `installFromUrl` does, without + /// installing it. Not remembered for the next visit and nothing asked about it: a shared + /// tape's owner, loaded so the tape plays. Null for an app that does not load web plugins. + loadFromUrl: ?*const fn (ctx: *anyopaque, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void = null, /// The web's update: link the build at `url` alongside the running one and hand the id over, /// within the session. Null for an app that does not load web plugins. updateFromUrl: ?*const fn (ctx: *anyopaque, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void = null, @@ -160,6 +164,10 @@ pub fn installFromUrl(self: PluginManager, id: []const u8, url: []const u8, sha2 const f = self.vtable.installFromUrl orelse return error.Unsupported; return f(self.ctx, id, url, sha256); } +pub fn loadFromUrl(self: PluginManager, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void { + const f = self.vtable.loadFromUrl orelse return error.Unsupported; + return f(self.ctx, id, url, sha256); +} pub fn updateFromUrl(self: PluginManager, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void { const f = self.vtable.updateFromUrl orelse return error.Unsupported; return f(self.ctx, id, url, sha256); diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 6a23ef2f4..4d205aec3 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -202,6 +202,37 @@ pub fn uninstalledCatalog(arena: std.mem.Allocator) []const CatalogOffer { return out.items; } +/// The registry's catalog, fetched now if nothing has fetched it yet: for something that names a +/// plugin before the person has opened the store (a shared tape's owners, fizzy's `TapeTabs`), +/// so `queueInstall` has releases to look in. No-op while it fetches or once it has. +pub fn wantCatalog() void { + const c = &(catalog orelse return); + if (c.status() == .idle) c.refresh(); +} + +/// Load `id`'s release for this visit only, on the web (`PluginManager.loadFromUrl`): not an +/// install, so not remembered or asked about. For a page that plays a shared tape whose +/// documents need it. No-op without a compatible release in the catalog (`wantCatalog`), while +/// one is loading, or natively. Whether the load started: false while there is nothing yet to +/// start it from (the catalog still fetching), so the caller asks again. +pub fn loadForVisit(id: []const u8) bool { + if (comptime builtin.target.cpu.arch != .wasm32) return false; + if (web_in_flight.contains(id)) return true; + const c = &(catalog orelse return false); + const snap = c.acquire(); + defer c.release(); + const s = snap orelse return false; + const rel = s.shard.releaseFor(id) orelse return false; + const dl = rel.downloadFor(compat.hostKey()) orelse return false; + if (!releaseSdkSatisfied(rel)) return false; + markWebInFlight(id); + app.loadFromUrl(id, dl.url, dl.sha256) catch |err| { + clearWebInFlight(id); + reportError("could not load '{s}': {s}", .{ id, @errorName(err) }); + }; + return true; +} + /// Start a download+install for `id`. No-op when there is no compatible release, or on wasm. pub fn queueInstall(id: []const u8) void { const c = &(catalog orelse return); diff --git a/build/web.zig b/build/web.zig index 6688a1a1f..448678cf4 100644 --- a/build/web.zig +++ b/build/web.zig @@ -107,6 +107,8 @@ pub fn addSteps( "FizzyWebShowInRoot", // `?demo=`: a bundled demo by name, or a fetched tape (`Editor.Demo`). "FizzyWebPlayDemo", + // `?tape=`: a shared tape, played once the plugins it needs are loaded (`TapeTabs`). + "FizzyWebPlayTape", "__stack_pointer", "dvui_c_alloc", "dvui_c_free", diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index bdd003dd5..6df89c98a 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -313,6 +313,12 @@ keyframe opened. A document's own history becomes such a tape with `fizzy.tracks tracks record, `FIZZY_TRACKS=1` or `fizzy.tracks.record`): its file as the track began, then its steps, pauses past 2 s played as 0.6 s unless `real_time`. `fizzy.demo.playTape` plays it. +A link plays one on the web: `fizzyed.it/app?tape=` fetches the tape, loads each plugin its +documents belong to from the store if the page has not (for the visit: not installed, not +remembered, nothing asked), and plays it in a tab of its own with nothing else in view +(`TapeTabs.openWhenReady`). On the desktop, `Demo: Play a Tape…` opens one the same way, and says +which plugin a tape needs when it is not loaded. + ### The binary form (`.tape`) `tape.binary` writes and reads the same `Tape` as a few flat tables: a header (`FZTP`, a diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c68235667..0c454f978 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1614,7 +1614,7 @@ const WebPluginRequest = struct { // whether this host will accept the build (fingerprint, SDK version, declared id), and a // remembered build that is refused would greet the user with the same failure every // visit. What is remembered is what ran. - PluginLoader.remember(req.id, req.url); + if (!web_visit_only.contains(req.id)) PluginLoader.remember(req.id, req.url); PluginStore.webLoadSucceeded(req.id); dvui.log.info("web plugin '{s}' loaded from {s}", .{ req.id, req.url }); editor.app.host.refresh(); @@ -1730,8 +1730,26 @@ export fn FizzyWebPlayDemo(ptr: [*]u8, len: usize, is_tape: bool) void { editor.app.host.refresh(); } +/// The page plays a tape it fetched from a link (`?tape=`): once the plugins its documents +/// belong to are loaded, asked of the store as needed (`TapeTabs.openWhenReady`), in a tab of its +/// own with nothing else in view: the page is the tape. +export fn FizzyWebPlayTape(ptr: [*]u8, len: usize) void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + const editor = web_editor orelse return; + const bytes = ptr[0..len]; + defer editor.app.gpa.free(bytes); + editor.tape_tabs.openWhenReady(editor, bytes, true) catch |err| { + dvui.log.err("web: could not play the tape: {t}", .{err}); + editor.demo.playTapeSoon(bytes); + }; + editor.app.host.refresh(); +} + /// The one editor, for the page's calls. Set by `postInit` on the web. var web_editor: ?*Editor = null; +/// Plugin ids loaded for this visit only (`PluginManager.loadFromUrl`: a shared tape's owners), +/// which the page does not bring back on the next. +var web_visit_only: std.StringHashMapUnmanaged(void) = .empty; /// Plugin ids the page is fetching for us right now. var web_loads_in_flight: std.StringHashMapUnmanaged(void) = .empty; /// Plugin ids whose file-type prompt (`maybeShowFileTypeDialog`) waits for their web load to @@ -6784,6 +6802,14 @@ const plugin_manager_vtable: PluginManager.VTable = .{ return editor.loadWebPlugin(id, url, sha256); } }.f, + .loadFromUrl = struct { + fn f(ctx: *anyopaque, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void { + const editor = pmSelf(ctx); + // For the visit: the arrival neither remembers it nor asks about its file types. + if (!web_visit_only.contains(id)) web_visit_only.put(editor.app.gpa, try editor.app.gpa.dupe(u8, id), {}) catch {}; + return editor.loadWebPlugin(id, url, sha256); + } + }.f, .updateFromUrl = struct { fn f(ctx: *anyopaque, id: []const u8, url: []const u8, sha256: []const u8) anyerror!void { return pmSelf(ctx).updateWebPlugin(id, url, sha256); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index c70f8cc76..7b54f2fcb 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -213,7 +213,7 @@ fn playTapeCallback(paths: ?[][:0]const u8) void { /// sets the person's session aside and gives it back when it ends. fn cmdPlayTapePath(state: *anyopaque, args: TapePath.Args, call: *sdk.Command.Call) anyerror!void { if (args.path.len == 0) return cmdPlayTape(state); - if (comptime is_web) return call.fail("a page plays a tape from its address (?demo=)", .{}); + if (comptime is_web) return call.fail("a page plays a tape from its address (?tape=)", .{}); if (!std.fs.path.isAbsolute(args.path)) return call.fail("give an absolute path: {s}", .{args.path}); playTapeFile(editorFromState(state), args.path) catch |err| return call.fail("could not play {s}: {t}", .{ args.path, err }); } @@ -225,8 +225,8 @@ fn playTapeFile(editor: *Editor, path: []const u8) !void { defer gpa.free(bytes); // One document's track (a shared tape) plays in a tab of its own, beside the person's work; // anything else on the demo stage. - editor.tape_tabs.open(editor, bytes, .{}) catch |err| switch (err) { - error.NotOneDocument, error.BadTape => editor.demo.playTapeSoon(bytes), + editor.tape_tabs.openWhenReady(editor, bytes, false) catch |err| switch (err) { + error.BadTape => editor.demo.playTapeSoon(bytes), else => return err, }; } diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig index 1d51c0d1b..a81d99220 100644 --- a/src/editor/TapeTabs.zig +++ b/src/editor/TapeTabs.zig @@ -24,6 +24,31 @@ const Editor = @import("Editor.zig"); playing: std.ArrayListUnmanaged(*Playback) = .empty, /// The next playback's number, for its root. next: u32 = 1, +/// A tape waiting for the plugins its documents need (`openWhenReady`): its bytes, owned. +waiting: ?Waiting = null, + +const Waiting = struct { + bytes: []u8, + /// The plugins its documents belong to, owned. + owners: [][]u8, + /// Which of `owners` the store has been asked to load (on the web): asked once, so a build + /// that fails is not fetched again every look. + asked: []bool, + /// The frame it was first looked at; null before. + since_ns: ?i128, + /// Played with nothing else in view (a page that is only the tape, `?tape=`). + alone: bool, + + fn deinit(self: Waiting, gpa: std.mem.Allocator) void { + gpa.free(self.bytes); + for (self.owners) |o| gpa.free(o); + gpa.free(self.owners); + gpa.free(self.asked); + } +}; + +/// How long a tape waits for its plugins before saying it cannot be played. +const wait_limit_ns: i128 = 60 * std.time.ns_per_s; const Playback = struct { owned: automation.Tape.Owned, @@ -55,6 +80,101 @@ const load_limit_ns: i128 = 30 * std.time.ns_per_s; pub fn deinit(self: *TapeTabs, editor: *Editor) void { for (self.playing.items) |p| free(editor, p); self.playing.deinit(editor.app.gpa); + if (self.waiting) |w| w.deinit(editor.app.gpa); + self.waiting = null; +} + +/// Play the tape in `bytes` once the plugins its documents belong to are loaded: at once when they +/// are; else, where the app can fetch them from the store on its own (the web, where a link to a +/// tape is how one is shared), asking the store for each and waiting, up to `wait_limit_ns`. One +/// document's tape plays in a tab of its own (`open`), anything else on the demo stage. `alone`: +/// nothing else in view, the tape the page (`Editor.show_only_document`). The bytes are copied. +pub fn openWhenReady(self: *TapeTabs, editor: *Editor, bytes: []const u8, alone: bool) !void { + const gpa = editor.app.gpa; + // Read for its owners alone: whether they apply its steps is asked once they are loaded. + var check = automation.Input.check; + check.steps = struct { + fn any(_: []const u8, _: u32) bool { + return true; + } + }.any; + var owned = automation.Tape.load(gpa, bytes, check) catch return error.BadTape; + defer owned.deinit(); + var owners: std.ArrayListUnmanaged([]u8) = .empty; + errdefer { + for (owners.items) |o| gpa.free(o); + owners.deinit(gpa); + } + for (owned.tape.keyframes) |kf| for (kf.files) |f| { + if (f.owner.len == 0) continue; + for (owners.items) |o| { + if (std.mem.eql(u8, o, f.owner)) break; + } else try owners.append(gpa, try gpa.dupe(u8, f.owner)); + }; + if (self.waiting) |w| w.deinit(gpa); + self.waiting = null; + const asked = try gpa.alloc(bool, owners.items.len); + errdefer gpa.free(asked); + @memset(asked, false); + const copy = try gpa.dupe(u8, bytes); + errdefer gpa.free(copy); + // Looked at from the next frame's top (`tick`): the page can call this between frames. + self.waiting = .{ .bytes = copy, .owners = try owners.toOwnedSlice(gpa), .asked = asked, .since_ns = null, .alone = alone }; + editor.app.host.refresh(); +} + +/// The waiting tape's next look: played once every owner is loaded; meanwhile each missing one +/// asked of the store (on the web), and a wake set to look again. +fn wait(self: *TapeTabs, editor: *Editor) void { + const w = if (self.waiting) |*waiting| waiting else return; + const since = w.since_ns orelse since: { + w.since_ns = dvui.frameTimeNS(); + break :since w.since_ns.?; + }; + const gpa = editor.app.gpa; + var missing: ?[]const u8 = null; + // A plugin asked for whose load has ended without it: it failed, and will not come. + var failed = false; + for (w.owners, w.asked) |o, *asked| { + if (editor.app.host.pluginById(o) != null) continue; + missing = o; + if (comptime @import("builtin").target.cpu.arch == .wasm32) { + if (asked.*) { + if (!app.store.Store.isInstalling(o)) failed = true; + continue; + } + // The catalog first, on a page that has not opened the store; then the plugin, for + // this visit: playing a tape installs nothing. + app.store.Store.wantCatalog(); + asked.* = app.store.Store.loadForVisit(o); + } + } + if (missing) |o| { + const native = comptime @import("builtin").target.cpu.arch != .wasm32; + if (native or failed or dvui.frameTimeNS() - since > wait_limit_ns) { + dvui.log.warn("tape: it needs the plugin '{s}', which is not loaded", .{o}); + dvui.toast(@src(), .{ .message = std.fmt.allocPrint(dvui.currentWindow().arena(), "This tape needs the {s} plugin", .{o}) catch "This tape needs a plugin that is not loaded" }); + w.deinit(gpa); + self.waiting = null; + return; + } + // The store's catalog, or the plugin, lands in a moment: look again then. + dvui.timer(dvui.Id.extendId(null, @src(), 0), 250_000); + return; + } + const ready = w.*; + self.waiting = null; + defer ready.deinit(gpa); + self.open(editor, ready.bytes, .{}) catch |err| switch (err) { + // Anything but one document's tape, or steps of a format its owner does not write: the + // demo stage plays it, or says why it cannot. + error.NotOneDocument, error.BadTape => editor.demo.playTapeSoon(ready.bytes), + else => { + dvui.log.warn("tape: could not play it: {t}", .{err}); + return; + }, + }; + if (ready.alone) editor.show_only_document = true; } pub const OpenError = error{ @@ -115,6 +235,7 @@ pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, opts: OpenOptio /// Once a frame, before anything draws: each tape's steps whose moment has come. Returns at once /// with nothing playing. pub fn tick(self: *TapeTabs, editor: *Editor) void { + if (self.waiting != null) self.wait(editor); if (self.playing.items.len == 0) return; const now = dvui.frameTimeNS(); var i: usize = 0; diff --git a/tests/integration.zig b/tests/integration.zig index 07974f918..56ae0cef6 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8206,7 +8206,30 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("", kf.files[0].text); // Played in a tab of its own (`TapeTabs`, the transport's share), each step at its moment: // the same text, under the tape's own mount, with the person's documents untouched. - try editor.tape_tabs.open(editor, bytes, .{}); + // A tape naming a plugin that is not loaded is not played: natively nothing is fetched, + // and it says which plugin it needs rather than open a document no owner can step. + { + var tape_check = app.automation.Input.check; + tape_check.steps = fizzy.Editor.Demo.stepsCheck; + var loaded = try app.automation.Tape.load(gpa, bytes, tape_check); + defer loaded.deinit(); + const files = try host.arena().dupe(app.automation.Tape.File, loaded.tape.keyframes[0].files); + files[0].owner = "no-such-plugin"; + const keyframes = try host.arena().dupe(app.automation.Tape.Keyframe, loaded.tape.keyframes); + keyframes[0].files = files; + var orphan_tape = loaded.tape; + orphan_tape.keyframes = keyframes; + const orphan = try app.automation.binary.encode(gpa, orphan_tape, .{}); + defer gpa.free(orphan); + const docs_before = editor.app.open_files.count(); + try editor.tape_tabs.openWhenReady(editor, orphan, false); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(editor.tape_tabs.waiting == null); + try std.testing.expectEqual(docs_before, editor.app.open_files.count()); + } + // Its plugin loaded, a tape plays at the next frame's top (`openWhenReady`), as + // `Demo: Play a Tape…` and a page's `?tape=` both open one. + try editor.tape_tabs.openWhenReady(editor, bytes, false); const played_path = try std.fmt.allocPrint(gpa, "tape://1/{s}", .{std.fs.path.basename(path)}); defer gpa.free(played_path); for (0..2000) |_| { diff --git a/web/AGENTS.md b/web/AGENTS.md index 7fec3c56a..fc4892a70 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -19,6 +19,9 @@ The page and its JavaScript (`index.html`, `web.js`, workers). The Zig half is (`fizzy.web_plugins`), so clear that key to reset. The host and plugin must be the same optimize class, and an optimized plugin imports libm by name (`ldexpf`), which the host exports from `web_main.zig`. +- **A new `export fn` the page calls goes in `build/web.zig`'s `export_symbol_names`.** The wasm + exports only what that list names. An unlisted one still compiles, and its name even shows in + the binary, but the page finds `exports.FizzyWebX` undefined and its guard returns silently. - **The heap never shrinks.** The wasm allocator rounds blocks over 64 KB up to a power-of-two class and reuses a freed block only within its class, so a large image can step memory from tens to hundreds of MB. Before blaming memory for a failed open, read the logged error. diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index 89aaebdeb..c9cf2ce71 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -381,6 +381,26 @@ function requestUrlPlugins() { // `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`, the binary form) fetches a // tape and plays that. for (const demo of params.getAll("demo")) playDemo(demo, env.baseURI); + // `?tape=` plays a shared tape: the plugins it needs come from the store, and the page + // shows only the tape. + const tape = params.get("tape"); + if (tape) playTape(tape, env.baseURI); +} + +function playTape(url, base) { + fetch(new URL(url, base), { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayTape) return; + const bytes = new Uint8Array(buf); + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(mem(), ptr, bytes.length).set(bytes); + exports.FizzyWebPlayTape(ptr, bytes.length); + render(); + }) + .catch((err) => console.error("fizzy: could not fetch the tape", url, err)); } function playDemo(demo, base) { diff --git a/web/index.html b/web/index.html index 00c6491a4..18b9dd286 100644 --- a/web/index.html +++ b/web/index.html @@ -572,6 +572,26 @@ // `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`, the binary form) fetches a // tape and plays that. for (const demo of new URLSearchParams(location.search).getAll("demo")) playDemo(demo); + // `?tape=` plays a shared tape: the plugins it needs come from the store, and the page + // shows only the tape. + const tape = new URLSearchParams(location.search).get("tape"); + if (tape) playTape(tape); + } + + function playTape(url) { + fetch(url, { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayTape) return; + const bytes = new Uint8Array(buf); + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + exports.FizzyWebPlayTape(ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }) + .catch((err) => console.error("fizzy: could not fetch the tape", url, err)); } function playDemo(demo) { From 68b4f6cfa1beeb81c6dce03191d027ad13e68b46 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:25:49 -0500 Subject: [PATCH 19/31] sdk: a plugin can replace a command it names, and gets it back on unload (#536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #518 ## What changes An app's plugin had no way to take over one of fizzy's commands (`fizzy.about`, `fizzy.reportBug`). Fizzy registers its commands first, `Host.registerCommand` appended a duplicate id, and `Host.command` returned the first match. This is the SDK half of #518; the About/Help half is #533. **The model.** Each id has a `CommandSlot` holding: - **bases**: registrations without `replaces`. The first is the base. - Another plugin's later base is ignored with a warning naming both owners, and kept in reserve in order. - The same plugin registering its own again updates its entry in place, with no warning. - **overrides**: registrations with `Command.replaces = true`, oldest first, at most one per owner. Re-registering moves an owner's override to the top. The **active** command is the newest override, else the base. `commands`, the list the palette, routes and keybind settings walk, holds one active command per id, in first-registration order. - **A base with none before it becomes the base**, beneath any overrides. That covers an override registered before its base's plugin loads, and a reload under an override. - **Unloading a plugin** removes its bases and overrides, and the next registration is active again. A reserve base takes over when the first base's plugin goes. An id with nothing left leaves `commands`. - **Reaching what was replaced**: `Host.callReplaced(caller, id, args, results)` runs the next entry below `caller`'s override (the next older override, else the base), the way `callCommand` would. - **Recursion**: while an **override** of an id is running, or a registration it reached through `callReplaced`, running that id by id from inside is refused. `callCommand` returns `.failed`; `runCommand` returns `error.CommandRanItself` and logs a warning. - The count is per slot (`overriding`), kept by `running`/`ran`. - A plain command with no override running its own id works as on `main`. - **Lookup without scanning**: `Host.command_slots` (`StringHashMapUnmanaged` via `getPtr`, keys are host-owned copies) maps an id to its slot and the active command's index. - `command(id)` now returns `?*const Command`: it points into the mirrored `commands`, so writing through it would be lost. No caller in this repo writes through it. - For a new id, everything that can fail happens before the map or `commands` changes. - **`running_command` is a copy** (`?Command`), and `runCommand`/`callCommand` run a copy of the entry. - **Claims**: a command is held on a claimed active document when either: - (a) its owner is the document's owner, which is `main`'s rule, unchanged; or - (b) it is named for the document's owner. `commandNamespace` gives the loaded plugin whose id is the longest prefix of the command id followed by `.`, else the first segment for a plugin not loaded yet. - That holds an override of `pixi.undo` even when it was registered before pixi's own, and handles dotted plugin ids (`acme.paint.undo` belongs to `acme.paint`, not `acme`). There is no base lookup. - Pixi overriding `fizzy.about` is held on a claimed pixi document again, by rule (a), as any pixi command is on `main`. - **New File is out of scope for now**: overriding a `.new*` command (detected through the same `commandNamespace`) is refused with a warning. The chooser and `dispatchNewDocument` are as on `main`. - **Plugin info** (`pluginList`) counts each plugin's registrations (bases, reserve, overrides) in one pass over `command_slots`. - `docs/PLUGINS.md` §3.4 documents: - a duplicate id without `replaces` is ignored (kept in reserve), while a plugin re-registering its own updates in place; - overrides, `callReplaced` and the recursion refusal; - claims; - the New File limit. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (0xcd68cc1a32bd51c9, combined with #540's and #544's moves after rebasing onto `main`), `sdk_version` untouched, PR labelled `sdk`. - The move comes from the new `Command.replaces`, `Host.command_slots`, and `Host.running_command` becoming a value. - `command()` now returns `?*const Command`; that did not move the fingerprint. A plugin that wrote through `host.command(id)` would stop compiling. None does: - No caller in this repo does. - `scripts/check-examples.sh` (example-plugin and every example-app shape) builds against this tree. - Local checkouts of pixi, atlas, zig, ghostty, drive, agent and chat were grepped: only chat reads it, for null checks (`src/Explorer.zig`, `src/Commands.zig`). - So no repin PR needs a change for it. - For plugin authors: - a duplicate command id without `replaces` is ignored (in reserve) with a warning; - `replaces` overrides a command; - `callReplaced` reaches what an override replaced. - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - Rebased onto `main` (#533, #540, #544, #537). - `scripts/check-fmt.sh`, `zig build`, `zig build test` (105/105 steps), `zig build check-tests-reached`, `zig build test-sdk-version` and `EXAMPLES_JOBS=1 scripts/check-examples.sh` (all 8 builds ok) pass. - `zig build test-integration` passes (54/54 steps; fizzy-sdk-tests 70/70). Its "failed command:" line is the existing automation demo-replay warning. - Tests cover: - an override before its base, and a second base ignored while an override stands; - the same owner re-overriding (one entry, moved to the top); - a plugin re-registering its own base (updated in place, no reserve copy); - three deep with the middle one unloaded, and a reload under an override; - a reserve base taking over; - the id leaving with its last registration; - `callReplaced` falling through, with `callCommand(id)` refused from the override and from the base it reached; - a plain command running itself three deep; - `runCommand` from inside an override returning `error.CommandRanItself`; - claims: an override held when registered before pixi's own and before pixi loads, another plugin's command not held, and a dotted `acme.paint` id held for `acme.paint` but not `acme`; - a New File override refused. - Tests that warn on purpose lower `testing.log_level` and restore it with `defer`. - [ ] Windows: - [ ] Linux: - [x] Web: `zig build check-web` passes. ## Follow-ups - Overriding a New File command (`.new*`): which plugin the chooser lists it under, and who receives the new document when the override wraps the plugin's flow. - `runReplaced` for an override that runs without arguments, if one is wanted. `callReplaced` with empty args covers it today. - Help's About label (static since #533) should follow an override's title. It needs a null-terminated title for the native menu, so it is left out of this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/App.zig | 11 +- docs/PLUGINS.md | 24 +++ sdk/src/Command.zig | 4 + sdk/src/Host.zig | 447 +++++++++++++++++++++++++++++++++++++++++--- sdk/src/version.zig | 2 +- 5 files changed, 461 insertions(+), 27 deletions(-) diff --git a/app/App.zig b/app/App.zig index 487a9b949..ec75e5e7a 100644 --- a/app/App.zig +++ b/app/App.zig @@ -544,6 +544,15 @@ pub fn pluginList(app: *App, arena: std.mem.Allocator) error{OutOfMemory}![]sdk. const Api = sdk.services.plugins.Api; var out: std.ArrayList(Api.Info) = .empty; const host = &app.host; + // Every command registration each plugin holds, base, reserve and override, in one pass. + var command_counts: std.AutoHashMapUnmanaged(*sdk.Plugin, u32) = .empty; + var slots = host.command_slots.valueIterator(); + while (slots.next()) |slot| for ([_][]const sdk.Command{ slot.bases.items, slot.overrides.items }) |list| { + for (list) |c| if (c.owner) |o| { + const gop = try command_counts.getOrPut(arena, o); + gop.value_ptr.* = if (gop.found_existing) gop.value_ptr.* + 1 else 1; + }; + }; for (host.plugins.items) |plugin| { if (plugin.internal) continue; var info: Api.Info = .{ @@ -560,7 +569,7 @@ pub fn pluginList(app: *App, arena: std.mem.Allocator) error{OutOfMemory}![]sdk. }; const r = &info.registered; r.surfaces = countOwned(host.surfaces.items, plugin); - r.commands = countOwned(host.commands.items, plugin); + r.commands = command_counts.get(plugin) orelse 0; r.services = countOwned(host.services.items, plugin); r.menus = countOwned(host.menus.items, plugin) + countOwned(host.menu_sections.items, plugin) + countOwned(host.native_menu_items.items, plugin) + countOwned(host.rail_items.items, plugin) + diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 3ad29197c..ef2f78328 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -957,6 +957,30 @@ fn goToLine(state: *anyopaque, args: GoToLine.Args, call: *sdk.Command.Call) any - **`run` and `runWith`.** A command needs at least one; `registerCommand` refuses one with neither. Keep `run` alongside `runWith` only if invoking with no arguments should do something of its own (open your own dialog, say) rather than ask. +- **One registration per id, unless you override.** An id has one registration of its own, + its *base*. **A second registration of an id without `replaces` is ignored** while the first + stands: `registerCommand` logs a warning naming both plugins and returns without an error, so + check the log if your command never runs. It is kept in reserve, and takes the first's place + if that one's plugin is unloaded. Registering your *own* command again (same id, same plugin) + updates it in place, with no warning. +- **Overriding one.** To take a command over on purpose (an app's plugin replacing `fizzy.about` + or `fizzy.reportBug`), register it with `.replaces = true`, an *override*. The newest override + is the command everyone runs (menus, keys, the palette); registering your override again + replaces your earlier one and makes it the newest. An override may come before its base, and + a base that registers later, or again after its plugin reloads, waits beneath it. When your + plugin is unloaded, the next override down, or the base, is the command again. + - To add to a command rather than replace it, call through to what you replaced with + `host.callReplaced(&plugin, id, args, arena)`: the next older override, else the base. + While your override is running (or what it reached through `callReplaced`), running `id` by + id from inside would run your override again: `callCommand` refuses it (`.failed`) and + `runCommand` returns `error.CommandRanItself`. A command with no override running its own id + is not stopped. + - A claim on a document holds the commands its owner registered, and any command named for + it: the plugin whose id is the longest prefix of the command id before a `.` (`pixi.undo`, + `acme.paint.undo` for a plugin `acme.paint`). So another plugin's override of `pixi.undo` is + held on a claimed pixi document, even registered before pixi's own. + - New File commands (`.new`, `.new.`) cannot be overridden yet: such an + override is ignored with a warning. - **Who can call it.** Any plugin can run any command with arguments, as it already could without them. Treat arguments as input from outside: check them as you would a user's. - **Listing.** `host.commandCount()` / `host.commandAt(i)` walk the registry, `params` included, diff --git a/sdk/src/Command.zig b/sdk/src/Command.zig index 99f7b65ba..756cc10e7 100644 --- a/sdk/src/Command.zig +++ b/sdk/src/Command.zig @@ -57,6 +57,10 @@ params: []const Param = &.{}, /// `Params(…).bind`, which parses the arguments into the plugin's own struct and reports a /// mismatch as `call.bad_args`. Absent = the command takes no arguments. runWith: ?*const fn (state: *anyopaque, call: *Call) anyerror!void = null, +/// Take over a command already registered with this id (fizzy's `fizzy.about`, say) rather +/// than be dropped as a duplicate. The replaced command comes back when this one's plugin is +/// unregistered (`Host.registerCommand`). +replaces: bool = false, /// Who sees a command. pub const Visibility = enum(u8) { diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 6cdd7996b..0658b4f91 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -265,8 +265,12 @@ account_providers: std.ArrayListUnmanaged(accounts.Provider) = .empty, /// Pure-data menu leaf items the native (macOS NSMenu) menu builder consumes; see /// `NativeMenuItem`. native_menu_items: std.ArrayListUnmanaged(NativeMenuItem) = .empty, -/// Plugin-contributed commands, invoked by id (menus, keybinds, palette) — see `Command`. +/// Plugin-contributed commands, invoked by id (menus, keybinds, palette) — see `Command`. One +/// per id, the active one (`CommandSlot`), in the order the ids were first registered. commands: std.ArrayListUnmanaged(Command) = .empty, +/// Each registered id's registrations and where its active command sits in `commands`. Keys are +/// the host's own copies, so an id outlives the plugin that registered it. +command_slots: std.StringHashMapUnmanaged(CommandSlot) = .empty, /// The commands run on documents lately, oldest first and at most `activity_cap`, for each /// document's panes to show as they run: by whoever ran them (a person, a tape, an agent). Titles /// are copies, so an entry outlives the plugin that ran it. @@ -278,7 +282,8 @@ activity_muted: bool = false, /// (`Command.Call.showing_ms`), for whoever called it. last_showing_ms: u32 = 0, /// The command whose body is running, if any: the edits made meanwhile are its (`documentEdited`). -running_command: ?*const Command = null, +/// A copy, so the registry can change under a running command (`registerCommand`'s `replaces`). +running_command: ?Command = null, /// While above 0, documents opened or created land beside the pane the person is in /// (`EditorAPI.OpenOptions.beside`), not in it: an agent's work opens where it will be seen, /// rather than over the person's document and then moved. Nested scopes count; UI thread only. @@ -375,6 +380,13 @@ pub fn deinit(self: *Host) void { self.account_providers.deinit(self.allocator); self.native_menu_items.deinit(self.allocator); self.commands.deinit(self.allocator); + var slots = self.command_slots.iterator(); + while (slots.next()) |e| { + self.allocator.free(e.key_ptr.*); + e.value_ptr.bases.deinit(self.allocator); + e.value_ptr.overrides.deinit(self.allocator); + } + self.command_slots.deinit(self.allocator); self.language_support.deinit(self.allocator); self.subscriptions.deinit(self.allocator); self.event_declarations.deinit(self.allocator); @@ -939,7 +951,7 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { removeOwned(RailItemContribution, &self.rail_items, plugin); removeOwned(accounts.Provider, &self.account_providers, plugin); removeOwned(NativeMenuItem, &self.native_menu_items, plugin); - removeOwned(Command, &self.commands, plugin); + self.removeCommandsOf(plugin); { var i: usize = 0; while (i < self.claims.items.len) { @@ -991,11 +1003,34 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { /// this and more for a plugin that is going away while the app runs. pub fn withdrawPlugin(self: *Host, plugin: *Plugin) void { removeOwned(ServiceEntry, &self.services, plugin); - removeOwned(Command, &self.commands, plugin); + self.removeCommandsOf(plugin); removeOwned(LanguageSupport, &self.language_support, plugin); self.removeEventsOf(plugin); } +/// Drop `plugin`'s commands, its base registrations and its overrides alike. An id with another +/// registration left gets that one back as its active command (an app's plugin overriding +/// `fizzy.about` gives fizzy's back); an id with none leaves `commands`. +fn removeCommandsOf(self: *Host, plugin: *Plugin) void { + var w: usize = 0; + for (self.commands.items) |c| { + const slot = self.command_slots.getPtr(c.id).?; + removeOwned(Command, &slot.bases, plugin); + removeOwned(Command, &slot.overrides, plugin); + if (slot.active() == null) { + var kv = self.command_slots.fetchRemove(c.id).?; + self.allocator.free(kv.key); + kv.value.bases.deinit(self.allocator); + kv.value.overrides.deinit(self.allocator); + continue; + } + slot.index = w; + self.commands.items[w] = slot.active().?; + w += 1; + } + self.commands.items.len = w; +} + fn removeEventsOf(self: *Host, plugin: *Plugin) void { removeOwned(events.Subscription, &self.subscriptions, plugin); removeOwned(events.Declaration, &self.event_declarations, plugin); @@ -1473,11 +1508,102 @@ pub fn registerNativeMenuItem(self: *Host, item: NativeMenuItem) !void { // ---- commands -------------------------------------------------------------- +/// Every registration of one id. The **base** is the registration without `Command.replaces` +/// (at most one); the **overrides** are those with it, oldest first, at most one per owner. The +/// active command, the one `commands` holds and `command(id)` returns, is the newest override, else +/// the base. +const CommandSlot = struct { + /// Where the active command sits in `commands`. + index: usize, + /// The base first; the later registrations without `replaces`, ignored while it stands, in + /// reserve after it, in order, so the next takes its place when its plugin goes. + bases: std.ArrayListUnmanaged(Command) = .empty, + overrides: std.ArrayListUnmanaged(Command) = .empty, + /// How many runs of an override of this id are in progress, or of what one reached through + /// `callReplaced`: while one is, running the id by id from inside would run the override + /// again, and is refused. A plain command running itself is not counted. + overriding: u32 = 0, + + fn active(self: CommandSlot) ?Command { + return self.overrides.getLastOrNull() orelse if (self.bases.items.len > 0) self.bases.items[0] else null; + } + + /// Where `owner`'s override sits in `overrides`, if it has one. + fn overrideOf(self: CommandSlot, owner: ?*Plugin) ?usize { + for (self.overrides.items, 0..) |o, i| { + if (o.owner == owner) return i; + } + return null; + } +}; + /// Register a plugin command. Ids should be plugin-namespaced (`"pixi.packProject"`). /// Refused when the command has neither `run` nor `runWith`, so nothing could invoke it. +/// +/// An id has one base registration: a second one without `replaces` is ignored while the first +/// stands, with a warning naming both owners, and held in reserve to take its place when the +/// first's plugin goes. One with `replaces` overrides the id (how an +/// app's plugin takes over `fizzy.about`), whether or not the base is registered yet; the same +/// owner overriding again replaces its own override and becomes the newest. Unregistering a +/// plugin removes its registrations and the newest remaining one becomes active again. An +/// override reaches what it replaced with `callReplaced`. New File commands (`.new`, +/// `.new.`) cannot be overridden yet. pub fn registerCommand(self: *Host, cmd: Command) !void { if (cmd.run == null and cmd.runWith == null) return error.CommandCannotRun; - try self.commands.append(self.allocator, cmd); + if (cmd.replaces and isNewDocumentCommand(self.commandNamespace(cmd.id), cmd.id)) { + std.log.warn("command {s}: overriding a New File command is not supported yet; ignored", .{cmd.id}); + return; + } + if (self.command_slots.getPtr(cmd.id)) |slot| { + if (!cmd.replaces) { + const own = for (slot.bases.items) |*b| { + if (b.owner == cmd.owner) break b; + } else null; + if (own) |b| { + // Its own base again (a reload of its registrations): the new one stands in place. + b.* = cmd; + } else { + if (slot.bases.items.len > 0) std.log.warn("command {s}: {s} registered it again; {s}'s is kept, this one held in reserve (set `replaces` to override it)", .{ + cmd.id, + if (cmd.owner) |o| o.id else "the host", + if (slot.bases.items[0].owner) |o| o.id else "the host", + }); + try slot.bases.append(self.allocator, cmd); + } + } else if (slot.overrideOf(cmd.owner)) |i| { + // The same owner again: its override moves to the top, as the newest. + _ = slot.overrides.orderedRemove(i); + slot.overrides.appendAssumeCapacity(cmd); + } else { + try slot.overrides.append(self.allocator, cmd); + std.log.info("command {s}: {s} overrides it", .{ cmd.id, if (cmd.owner) |o| o.id else "the host" }); + } + self.commands.items[slot.index] = slot.active().?; + return; + } + // A new id. Everything that can fail goes first, so a failure leaves nothing half-registered. + try self.commands.ensureUnusedCapacity(self.allocator, 1); + try self.command_slots.ensureUnusedCapacity(self.allocator, 1); + const key = try self.allocator.dupe(u8, cmd.id); + errdefer self.allocator.free(key); + var slot: CommandSlot = .{ .index = self.commands.items.len }; + try (if (cmd.replaces) &slot.overrides else &slot.bases).append(self.allocator, cmd); + self.command_slots.putAssumeCapacityNoClobber(key, slot); + self.commands.appendAssumeCapacity(cmd); +} + +/// The plugin a command id is named for: the longest registered plugin id that is a prefix of +/// `id` followed by `.` (so a dotted id like `acme.paint` is one plugin), else the id's first +/// dotted segment, for a plugin not loaded yet. +fn commandNamespace(self: *const Host, id: []const u8) []const u8 { + var best: []const u8 = id[0 .. std.mem.indexOfScalar(u8, id, '.') orelse id.len]; + var found = false; + for (self.plugins.items) |p| { + if (id.len <= p.id.len or id[p.id.len] != '.' or !std.mem.startsWith(u8, id, p.id)) continue; + if (!found or p.id.len > best.len) best = p.id; + found = true; + } + return best; } /// How many commands are registered — with `commandAt`, the way to list them without reaching @@ -1492,12 +1618,10 @@ pub fn commandAt(self: *Host, index: usize) ?*const Command { return &self.commands.items[index]; } -/// The registered command with `id`, or null. -pub fn command(self: *Host, id: []const u8) ?*Command { - for (self.commands.items) |*c| { - if (std.mem.eql(u8, c.id, id)) return c; - } - return null; +/// The registered command with `id`, or null: its active one (`registerCommand`). +pub fn command(self: *Host, id: []const u8) ?*const Command { + const slot = self.command_slots.getPtr(id) orelse return null; + return &self.commands.items[slot.index]; } /// Whether `id` is registered at all, regardless of its current enabled state. @@ -1578,13 +1702,14 @@ fn claimIndex(self: *const Host, id: u32) ?usize { } /// The claim that refuses the person command `c` on the active document: the document is claimed -/// and `c` belongs to the plugin that draws it (Undo, Copy and Delete reach it as `.`). +/// and `c` belongs to the plugin that draws it, as its own (Undo, Copy and Delete reach it as +/// `.`) or by name (`commandNamespace`): another plugin's override of `pixi.undo` is +/// held on a claimed pixi document too, even registered before pixi's own. fn claimRefusing(self: *Host, c: *const Command) ?Claim { // Nothing held, nothing to ask: a command costs no more than before. if (self.claims.items.len == 0) return null; - const owner = c.owner orelse return null; const doc = self.activeDoc() orelse return null; - if (doc.owner != owner) return null; + if (c.owner != doc.owner and !std.mem.eql(u8, self.commandNamespace(c.id), doc.owner.id)) return null; return self.documentClaim(doc.id); } @@ -1831,31 +1956,49 @@ pub fn documentEdited(self: *Host, doc_id: u64) void { } /// Run a command's body as the running command, so the edits it makes are its own. -fn running(self: *Host, c: *const Command) ?*const Command { +/// `replaced`: reached through `callReplaced`. It and an override count as overriding (`inProgress`). +fn running(self: *Host, c: Command, replaced: bool) ?Command { const outer = self.running_command; self.running_command = c; self.command_runs += 1; + if (c.replaces or replaced) if (self.command_slots.getPtr(c.id)) |slot| { + slot.overriding += 1; + }; return outer; } + +/// `running`'s end: `outer` runs again. The slot is looked up anew, as the body may have changed +/// the registry, and its id may be gone. +fn ran(self: *Host, c: Command, outer: ?Command, replaced: bool) void { + self.running_command = outer; + if (c.replaces or replaced) if (self.command_slots.getPtr(c.id)) |slot| { + slot.overriding -|= 1; + }; +} /// Run the command `id` with no arguments (no-op when unknown), as a menu row or a keybind /// does. The owner's opaque `state` is passed to `run`. A command with only `runWith` runs it /// with no arguments, unless a parameter is required: then the app is asked to collect them /// (`EditorAPI.askCommandArguments` — fizzy opens the palette on the command), and without an /// app that can, the run fails with `error.MissingArguments`. pub fn runCommand(self: *Host, id: []const u8) !void { - const c = self.command(id) orelse return; + // A copy, not the registry's entry: the body may register, replace or unregister commands. + const c = (self.command(id) orelse return).*; const owner = c.owner orelse return; + if (self.inProgress(id)) { + std.log.warn("{s}: ran itself; an override reaches what it replaced with callReplaced", .{id}); + return error.CommandRanItself; + } // A person's command on a document someone holds waits until they let go (`Claim`). The // holder reaches it through `callCommand`. - if (self.claimRefusing(c)) |claim| { + if (self.claimRefusing(&c)) |claim| { std.log.info("{s}: {s} is working on this document", .{ id, claim.label }); return error.DocumentClaimed; } if (c.run) |run| { const prof = core.profile.begin(owner.id, c.id); defer prof.end(); - const outer = self.running(c); - defer self.running_command = outer; + const outer = self.running(c, false); + defer self.ran(c, outer, false); run(owner.state) catch |err| return owner.failed(c.id, err); return; } @@ -1985,7 +2128,32 @@ fn freeQueued(allocator: std.mem.Allocator, e: events.Queued) void { } pub fn callCommand(self: *Host, id: []const u8, args: []const u8, results: std.mem.Allocator) CommandOutcome { - const c = self.command(id) orelse return .unknown; + // A copy, not the registry's entry: the body may register, replace or unregister commands. + const c = (self.command(id) orelse return .unknown).*; + if (self.inProgress(id)) return .{ .failed = "it called itself; an override reaches what it replaced with callReplaced" }; + return self.callResolved(c, args, results, false); +} + +/// From inside `caller`'s override of `id` (`Command.replaces`): call what it replaced, the next +/// older override, else the base, the way `callCommand` would. How an override adds to a command +/// rather than taking it over: calling `id` itself would run the override again, and is refused. +/// `.unknown` when `caller` has no override of `id`, or it replaced nothing. +pub fn callReplaced(self: *Host, caller: *Plugin, id: []const u8, args: []const u8, results: std.mem.Allocator) CommandOutcome { + const slot = self.command_slots.get(id) orelse return .unknown; + const i = slot.overrideOf(caller) orelse return .unknown; + const below = if (i > 0) slot.overrides.items[i - 1] else if (slot.bases.items.len > 0) slot.bases.items[0] else return .unknown; + return self.callResolved(below, args, results, true); +} + +/// Whether an override of `id` is running, or what one reached through `callReplaced`: run by id +/// from inside, it would run the override again. +fn inProgress(self: *Host, id: []const u8) bool { + const slot = self.command_slots.getPtr(id) orelse return false; + return slot.overriding > 0; +} + +/// `replaced`: reached through `callReplaced`, so running inside an override (`inProgress`). +fn callResolved(self: *Host, c: Command, args: []const u8, results: std.mem.Allocator, replaced: bool) CommandOutcome { const owner = c.owner orelse return .unknown; if (c.isEnabled) |enabled| { if (!enabled(owner.state)) return .disabled; @@ -1995,15 +2163,15 @@ pub fn callCommand(self: *Host, id: []const u8, args: []const u8, results: std.m const run_with = c.runWith orelse { const trimmed = std.mem.trim(u8, args, " \t\r\n"); if (trimmed.len > 0 and !std.mem.eql(u8, trimmed, ".{}")) return .{ .bad_args = "this command takes no arguments" }; - const outer = self.running(c); - defer self.running_command = outer; + const outer = self.running(c, replaced); + defer self.ran(c, outer, replaced); c.run.?(owner.state) catch |err| return .{ .failed = owner.errorName(err) }; return .{ .ok = null }; }; self.last_showing_ms = 0; var call: Command.Call = .{ .args = args, .arena = results }; - const outer = self.running(c); - defer self.running_command = outer; + const outer = self.running(c, replaced); + defer self.ran(c, outer, replaced); run_with(owner.state, &call) catch |err| { const msg = call.message orelse owner.errorName(err); return if (call.bad_args) .{ .bad_args = msg } else .{ .failed = msg }; @@ -2746,6 +2914,235 @@ test "a withdrawn plugin is out of reach: its services, commands and highlightin try testing.expectEqual(@as(usize, 2), host.plugins.items.len); } +test "command overrides: the newest is active, a base comes back beneath them" { + var host = Host.init(testing.allocator); + defer host.deinit(); + // Duplicates are warned about, on purpose; keep the warnings out of the test's output. + const log_level = testing.log_level; + testing.log_level = .err; + defer testing.log_level = log_level; + const vtable = Plugin.VTable{}; + var pixi = Plugin{ .state = undefined, .vtable = &vtable, .id = "pixi", .display_name = "Pixi" }; + var a = Plugin{ .state = undefined, .vtable = &vtable, .id = "a", .display_name = "A" }; + var b = Plugin{ .state = undefined, .vtable = &vtable, .id = "b", .display_name = "B" }; + var c = Plugin{ .state = undefined, .vtable = &vtable, .id = "c", .display_name = "C" }; + const noopRun = struct { + fn f(_: *anyopaque) anyerror!void {} + }.f; + + // An override before its base: active at once; the base registers beneath it. + try host.registerCommand(.{ .id = "pixi.pack", .owner = &a, .title = "A1", .run = noopRun, .replaces = true }); + try host.registerCommand(.{ .id = "pixi.other", .owner = &pixi, .title = "Other", .run = noopRun }); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &pixi, .title = "Pack", .run = noopRun }); + try testing.expectEqualStrings("A1", host.command("pixi.pack").?.title); + try testing.expectEqual(@as(usize, 2), host.commandCount()); + + // A second base is dropped, override or no. + try host.registerCommand(.{ .id = "pixi.pack", .owner = &b, .title = "Stray", .run = noopRun }); + // The same owner overriding again replaces its own override. + try host.registerCommand(.{ .id = "pixi.pack", .owner = &a, .title = "A2", .run = noopRun, .replaces = true }); + try testing.expectEqual(@as(usize, 1), host.command_slots.get("pixi.pack").?.overrides.items.len); + try testing.expectEqualStrings("A2", host.command("pixi.pack").?.title); + + // Three deep: c on b on a. The middle one goes; the newest stays, in its place. + try host.registerCommand(.{ .id = "pixi.pack", .owner = &b, .title = "B", .run = noopRun, .replaces = true }); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &c, .title = "C", .run = noopRun, .replaces = true }); + host.withdrawPlugin(&b); + try testing.expectEqualStrings("C", host.command("pixi.pack").?.title); + try testing.expectEqualStrings("C", host.commandAt(0).?.title); + // Overriding again puts an owner's override on top: a's is newer than c's now. + try host.registerCommand(.{ .id = "pixi.pack", .owner = &a, .title = "A3", .run = noopRun, .replaces = true }); + try testing.expectEqualStrings("A3", host.command("pixi.pack").?.title); + host.withdrawPlugin(&a); + try testing.expectEqualStrings("C", host.command("pixi.pack").?.title); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &a, .title = "A2", .run = noopRun, .replaces = true }); + host.withdrawPlugin(&c); + try testing.expectEqualStrings("A2", host.command("pixi.pack").?.title); + + // The base's plugin reloads under the override: it is the base again. + host.withdrawPlugin(&pixi); + try testing.expectEqual(@as(usize, 1), host.commandCount()); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &pixi, .title = "Pack", .run = noopRun }); + try testing.expectEqualStrings("A2", host.command("pixi.pack").?.title); + host.withdrawPlugin(&a); + try testing.expectEqualStrings("Pack", host.command("pixi.pack").?.title); + try testing.expectEqual(@as(usize, 1), host.commandCount()); + + // A second base waits in reserve and takes the first's place when its plugin goes. + try host.registerCommand(.{ .id = "pixi.spare", .owner = &pixi, .title = "Spare", .run = noopRun }); + try host.registerCommand(.{ .id = "pixi.spare", .owner = &b, .title = "B Spare", .run = noopRun }); + try testing.expectEqualStrings("Spare", host.command("pixi.spare").?.title); + host.withdrawPlugin(&pixi); + try testing.expectEqualStrings("B Spare", host.command("pixi.spare").?.title); + host.withdrawPlugin(&b); + + // Its last registration gone, the id goes too. + host.withdrawPlugin(&pixi); + try testing.expectEqual(@as(usize, 0), host.commandCount()); + try testing.expect(host.command("pixi.pack") == null); +} + +test "an override adds to what it replaced through callReplaced, and cannot call itself" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const S = struct { + var base_runs: u32 = 0; + var override_runs: u32 = 0; + var fell_through: ?CommandOutcome = null; + var itself: ?CommandOutcome = null; + var base_itself: ?CommandOutcome = null; + var app: *Plugin = undefined; + fn base(state: *anyopaque) anyerror!void { + const h: *Host = @ptrCast(@alignCast(state)); + base_runs += 1; + // Reached through `callReplaced`, the id is still running: calling it would recurse. + base_itself = h.callCommand("pixi.pack", "", testing.allocator); + } + fn override(state: *anyopaque) anyerror!void { + const h: *Host = @ptrCast(@alignCast(state)); + override_runs += 1; + itself = h.callCommand("pixi.pack", "", testing.allocator); + fell_through = h.callReplaced(app, "pixi.pack", "", testing.allocator); + } + }; + const vtable = Plugin.VTable{}; + var pixi = Plugin{ .state = &host, .vtable = &vtable, .id = "pixi", .display_name = "Pixi" }; + var app = Plugin{ .state = &host, .vtable = &vtable, .id = "app", .display_name = "App" }; + S.app = &app; + try host.registerCommand(.{ .id = "pixi.pack", .owner = &pixi, .title = "Pack", .run = S.base }); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &app, .title = "Pack More", .run = S.override, .replaces = true }); + + try testing.expect(host.callCommand("pixi.pack", "", testing.allocator) == .ok); + try testing.expectEqual(@as(u32, 1), S.override_runs); + try testing.expectEqual(@as(u32, 1), S.base_runs); + try testing.expect(S.fell_through.? == .ok); + try testing.expect(S.itself.? == .failed); + try testing.expect(S.base_itself.? == .failed); + // Done, it runs by id again. + try testing.expect(host.callCommand("pixi.pack", "", testing.allocator) == .ok); + try testing.expectEqual(@as(u32, 2), S.override_runs); + // The base replaced nothing; another plugin has no override to fall through from. + try testing.expect(host.callReplaced(&pixi, "pixi.pack", "", testing.allocator) == .unknown); +} + +test "a plain command may run itself; an override running its id is refused" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const S = struct { + var depth: u32 = 0; + var ran_itself: ?anyerror = null; + fn countdown(state: *anyopaque) anyerror!void { + const h: *Host = @ptrCast(@alignCast(state)); + depth += 1; + if (depth < 3) try h.runCommand("t.countdown"); + } + fn override(state: *anyopaque) anyerror!void { + const h: *Host = @ptrCast(@alignCast(state)); + h.runCommand("t.wrapped") catch |err| { + ran_itself = err; + }; + } + }; + const vtable = Plugin.VTable{}; + var t = Plugin{ .state = &host, .vtable = &vtable, .id = "t", .display_name = "T" }; + var app = Plugin{ .state = &host, .vtable = &vtable, .id = "app", .display_name = "App" }; + + // As on main: a command with no override is not stopped from running itself. + try host.registerCommand(.{ .id = "t.countdown", .owner = &t, .title = "Countdown", .run = S.countdown }); + try host.runCommand("t.countdown"); + try testing.expectEqual(@as(u32, 3), S.depth); + + const log_level = testing.log_level; + testing.log_level = .err; + defer testing.log_level = log_level; + try host.registerCommand(.{ .id = "t.wrapped", .owner = &t, .title = "Wrapped", .run = S.countdown }); + try host.registerCommand(.{ .id = "t.wrapped", .owner = &app, .title = "Wrapper", .run = S.override, .replaces = true }); + try host.runCommand("t.wrapped"); + try testing.expectEqual(error.CommandRanItself, S.ran_itself.?); +} + +test "a New File command cannot be overridden yet" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const log_level = testing.log_level; + testing.log_level = .err; + defer testing.log_level = log_level; + const vtable = Plugin.VTable{}; + var pixi = Plugin{ .state = undefined, .vtable = &vtable, .id = "pixi", .display_name = "Pixi" }; + var app = Plugin{ .state = undefined, .vtable = &vtable, .id = "app", .display_name = "App" }; + const noopRun = struct { + fn f(_: *anyopaque) anyerror!void {} + }.f; + try host.registerCommand(.{ .id = "pixi.new.sprite", .owner = &pixi, .title = "Sprite", .run = noopRun }); + try host.registerCommand(.{ .id = "pixi.new.sprite", .owner = &app, .title = "App Sprite", .run = noopRun, .replaces = true }); + try testing.expectEqualStrings("Sprite", host.command("pixi.new.sprite").?.title); +} + +test "a claim on a document holds an override of its owner's command too" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var pixi = Plugin{ .state = undefined, .vtable = &vtable, .id = "pixi", .display_name = "Pixi" }; + var app = Plugin{ .state = undefined, .vtable = &vtable, .id = "app", .display_name = "App" }; + var holder = Plugin{ .state = undefined, .vtable = &vtable, .id = "holder", .display_name = "Holder" }; + const S = struct { + var runs: u32 = 0; + var owner: *Plugin = undefined; + fn run(_: *anyopaque) anyerror!void { + runs += 1; + } + fn active(_: *anyopaque) ?DocHandle { + return .{ .ptr = undefined, .owner = owner, .id = 7 }; + } + fn refresh(_: *anyopaque) void {} + }; + S.owner = &pixi; + var api_vt: EditorAPI.VTable = undefined; + api_vt.activeDoc = S.active; + api_vt.refresh = S.refresh; + var ctx: u8 = 0; + host.installFizzyApi(.{ .ctx = &ctx, .vtable = &api_vt }); + + // An override registered before pixi's own command, and before pixi loads: held by name. + try host.registerCommand(.{ .id = "pixi.undo", .owner = &app, .title = "App Undo", .run = S.run, .replaces = true }); + _ = try host.claimDocument(&holder, 7, "Holder"); + try testing.expectError(error.DocumentClaimed, host.runCommand("pixi.undo")); + try host.registerPlugin(&pixi); + try host.registerCommand(.{ .id = "pixi.undo", .owner = &pixi, .title = "Undo", .run = S.run }); + try testing.expectError(error.DocumentClaimed, host.runCommand("pixi.undo")); + try testing.expectEqual(@as(u32, 0), S.runs); + // Another plugin's command is not held. + try host.registerCommand(.{ .id = "app.other", .owner = &app, .title = "Other", .run = S.run }); + try host.runCommand("app.other"); + try testing.expectEqual(@as(u32, 1), S.runs); + + // A dotted plugin id is one name: `acme.paint.undo` is acme.paint's, not acme's. + var acme = Plugin{ .state = undefined, .vtable = &vtable, .id = "acme", .display_name = "Acme" }; + var paint = Plugin{ .state = undefined, .vtable = &vtable, .id = "acme.paint", .display_name = "Paint" }; + try host.registerPlugin(&acme); + try host.registerPlugin(&paint); + S.owner = &paint; + try host.registerCommand(.{ .id = "acme.paint.undo", .owner = &app, .title = "App Paint Undo", .run = S.run, .replaces = true }); + try testing.expectError(error.DocumentClaimed, host.runCommand("acme.paint.undo")); + S.owner = &acme; + try host.runCommand("acme.paint.undo"); + try testing.expectEqual(@as(u32, 2), S.runs); +} + +test "a plugin registering its own command again updates it in place" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var pixi = Plugin{ .state = undefined, .vtable = &vtable, .id = "pixi", .display_name = "Pixi" }; + const noopRun = struct { + fn f(_: *anyopaque) anyerror!void {} + }.f; + try host.registerCommand(.{ .id = "pixi.pack", .owner = &pixi, .title = "Pack", .run = noopRun }); + try host.registerCommand(.{ .id = "pixi.pack", .owner = &pixi, .title = "Pack Atlas", .run = noopRun }); + try testing.expectEqualStrings("Pack Atlas", host.command("pixi.pack").?.title); + try testing.expectEqual(@as(usize, 1), host.command_slots.getPtr("pixi.pack").?.bases.items.len); +} + const EventLog = struct { seen: [16][48]u8 = undefined, lens: [16]usize = @splat(0), @@ -3166,7 +3563,7 @@ test "an agent's edit says whose in the activity; the person's stays plain" { var host: Host = .init(std.testing.allocator); defer host.deinit(); const draw: Command = .{ .id = "pixi.draw", .title = "Draw" }; - host.running_command = &draw; + host.running_command = draw; host.setInputAuthor(.person); host.documentEdited(1); try std.testing.expectEqualStrings("Draw", host.activity.items[0].title); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 4dfd5e5c9..5ed391deb 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xd624ada9fe0e74d7; +pub const recorded_sdk_shape_fingerprint: u64 = 0xcd68cc1a32bd51c9; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From a14e8b142c615d05b194c0fe2f6a356ff50bb350 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:47:24 -0500 Subject: [PATCH 20/31] app: control server describes itself (/openapi.json) (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md`, step 3. Follows fizzyedit/fizzy#499 (merged), with `main` merged in. ## What changes `GET /openapi.json` on the control socket answers an OpenAPI 3.1 description of the server, generated per request from what is registered at that moment. Plugins loading and reloading change it, so a client or code generator always sees the app as it is. ```sh curl --unix-socket ~/fizzy-profile/control.sock localhost/openapi.json ``` - **A path per command, saying what the server enforces and no more.** Each `/commands/{id}` (the id percent-escaped) has a `post`: - **Declared parameters:** the body is a JSON Schema of exactly those, with `additionalProperties: false`, the required ones listed, and each parameter's type: `boolean`, `integer`, `number`, `string`, or `string` + `enum` for an enumeration, the one constraint `Command.Params` checks. - **Hints, not limits:** a number's declared bounds and suggested choices are only hints the server never checks, so they go in the parameter's description rather than its schema. Otherwise a generated client would refuse values the server takes. - **Hand-written `runWith`** (as the chat plugin's commands are): any object. - **Only `run`:** no body. - **Defaults** are included when they convert to JSON. - `operationId` is the command id, and `tags` names the owning plugin. Every command shares the error responses (400/404/409/500) and one `Error` schema. A repeated id gets one path, the one the server runs. - **`/events`** lists every declared event (`Host.declareEvent`) in its description and in `x-fizzy-events`, each with its example payload converted to JSON. - **Results are untyped (any JSON).** No command declares a result type yet. The seam is a result type beside `Command.Params(…)` that `openapi.commandPath` would read. Live: fizzy's own spec is 55 KB compact (56 paths, the 9 declared events, unique `operationId`s), and a fetch round-trips in 3–11 ms in Debug. `HEAD` doesn't generate it. ### Review An independent review pass found 8 issues. Fixed, each covered by the test: - **Invalid document:** the any-value schemas were written as `[]`, which isn't a schema. Every validator would reject the whole document. - **Chat's commands looked argument-less:** declaring no params, they were described as taking only `{}`. - **Limits the server doesn't have:** number bounds and suggested choices in the schema would make clients refuse values the server accepts. - **Invalid JSON:** an infinite bound would have produced invalid JSON (now moot, since no bounds are written). - **Repeats and collisions:** duplicate ids repeated a path key, and the fixed operationIds could collide with command ids (now under `control.`). - **Unescaped path keys.** - **Cost:** generation now writes compact output and skips `HEAD`. - **Gaps:** `/openapi.json` wasn't described. ### Changed from the plan - **No committed snapshot of fizzy's spec.** It would fail CI on every edit to any command's description, and the live endpoint is always current. Step 4's `docs/CONTROL.md` will point at it. - **No external validator in CI.** The test checks the structure, and that every `$ref` resolves. I didn't run a third-party OpenAPI validator, since none is installed here and fetching one is a download. A CI job running one is a cheap follow-up if you want it. ## SDK impact - [x] None. ## Verified - [x] macOS: an integration test over a `Host` with registered commands and a declared event checks: - the paths; - the body schema per parameter kind (types, bounds, enum choices, defaults, the required list); - every error status; - an optional body for a command with no required parameters; - the event's JSON example; - that every `$ref` resolves. Live, I fetched the spec from a running fizzy and inspected it. Gates: fmt, build, `test`, `test-integration`, `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: the x86_64 cross-compile links. Nothing here is platform-specific. - [ ] Linux: CI - [x] Web: `check-web`; nothing is added there. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/control/openapi.zig | 289 ++++++++++++++++++++++++++++++++++++++++ app/control/routes.zig | 10 ++ app/root.zig | 1 + tests/integration.zig | 101 ++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 app/control/openapi.zig diff --git a/app/control/openapi.zig b/app/control/openapi.zig new file mode 100644 index 000000000..3159158f2 --- /dev/null +++ b/app/control/openapi.zig @@ -0,0 +1,289 @@ +//! The control server's OpenAPI 3.1 description, generated per request from what is registered +//! now: a path per command, its body a JSON Schema built from the parameters the command declares, +//! and `/events` with every event plugins declared (`Host.declareEvent`). It changes as plugins load +//! and reload, so a client generated from it sees the app as it is (plans/CONTROL_SERVER_PLAN.md, +//! step 3). +//! +//! It says what the server enforces, no more: a parameter's type, and the choices of an enumeration. +//! The bounds and suggested choices a number declares are the palette's hints, never checked by +//! `Command.Params`, so they go in its description, not its schema, where a generated client would +//! refuse values the server takes. A command's result has no declared type yet, so its 200 response +//! is any JSON value; the seam for typing it is a result type beside `Command.Params(…)` that +//! `commandPath` would read. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const AppInfo = @import("../AppInfo.zig"); +const json = @import("json.zig"); + +const Stringify = std.json.Stringify; +const Error = Stringify.Error || error{OutOfMemory}; + +/// The whole description as JSON, in `arena`. +pub fn generate(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]u8 { + var out: std.Io.Writer.Allocating = .init(arena); + // Compact: generated on the UI thread per request, and half the bytes of an indented one. + var s: Stringify = .{ .writer = &out.writer, .options = .{} }; + write(&s, host, arena) catch return error.OutOfMemory; + return out.written(); +} + +fn write(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) Error!void { + try s.beginObject(); + try s.objectField("openapi"); + try s.write("3.1.0"); + try s.objectField("info"); + try s.write(.{ + .title = AppInfo.current.display_name ++ " control server", + .version = AppInfo.current.version, + .description = "The app's commands over HTTP on a Unix socket in its config folder (`control.sock`), " ++ + "readable by this user only: `curl --unix-socket http://localhost/commands`. Bodies are JSON; " ++ + "send `Content-Type: application/zon` to send and receive ZON instead.", + }); + try s.objectField("servers"); + try s.write(.{.{ .url = "http://localhost", .description = "Over the Unix socket; the host name is not used." }}); + + try s.objectField("paths"); + try s.beginObject(); + try s.objectField("/"); + try s.write(.{ .get = .{ + .operationId = "control.app", + .summary = "The app, its version and the SDK it was built against.", + .responses = .{ .@"200" = jsonResponse("The app.", .{ .@"$ref" = "#/components/schemas/App" }) }, + } }); + try s.objectField("/commands"); + try s.write(.{ .get = .{ + .operationId = "control.commands", + .summary = "Every registered command, with its parameters and whether it is enabled now.", + .responses = .{ .@"200" = jsonResponse("The commands.", .{ .type = "array", .items = .{ .@"$ref" = "#/components/schemas/Command" } }) }, + } }); + try s.objectField("/openapi.json"); + try s.write(.{ .get = .{ + .operationId = "control.openapi", + .summary = "This description, generated from what is registered now.", + .responses = .{ .@"200" = jsonResponse("The description.", true) }, + } }); + try eventsPath(s, host, arena); + // The first of an id is the one the server runs (`Host.command`): a later one is not a path + // of its own, which would repeat a key and an `operationId`. A command no plugin owns cannot + // run (`callCommand` answers 404) and is left out. + for (host.commands.items, 0..) |*c, i| { + if (c.owner == null) continue; + const first = for (host.commands.items[0..i]) |*o| { + if (std.mem.eql(u8, o.id, c.id)) break false; + } else true; + if (first) try commandPath(s, c, arena); + } + try s.endObject(); + + try s.objectField("components"); + try s.write(.{ .schemas = .{ + .App = .{ .type = "object", .properties = .{ + .name = .{ .type = "string" }, + .version = .{ .type = "string" }, + .sdk_version = .{ .type = "string" }, + } }, + .Error = .{ .type = "object", .required = .{"error"}, .properties = .{ .@"error" = .{ .type = "string" } } }, + .Param = .{ .type = "object", .properties = .{ + .key = .{ .type = "string" }, + .label = .{ .type = "string" }, + .description = .{ .type = "string" }, + .kind = .{ .type = "string", .@"enum" = .{ "bool", "int", "float", "string", "enum", "color", "zon" } }, + .required = .{ .type = "boolean" }, + .default = true, + .min = .{ .type = "number" }, + .max = .{ .type = "number" }, + .choices = .{ .type = "array", .items = .{ .type = "string" } }, + } }, + .Command = .{ .type = "object", .properties = .{ + .id = .{ .type = "string" }, + .title = .{ .type = "string" }, + .owner = .{ .type = "string" }, + .visibility = .{ .type = "string", .@"enum" = .{ "external", "internal" } }, + .enabled = .{ .type = "boolean" }, + .params = .{ .type = "array", .items = .{ .@"$ref" = "#/components/schemas/Param" } }, + } }, + } }); + try s.endObject(); +} + +fn jsonResponse(description: []const u8, schema: anytype) JsonResponse(@TypeOf(schema)) { + return .{ .description = description, .content = .{ .@"application/json" = .{ .schema = schema } } }; +} + +fn JsonResponse(comptime Schema: type) type { + return struct { + description: []const u8, + content: struct { @"application/json": struct { schema: Schema } }, + }; +} + +const error_response = jsonResponse("Why not, in words.", .{ .@"$ref" = "#/components/schemas/Error" }); + +fn eventsPath(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) Error!void { + var description: std.Io.Writer.Allocating = .init(arena); + const w = &description.writer; + try w.writeAll("The app's events as Server-Sent Events, one `event:` (its name) and one `data:` (its payload " ++ + "as JSON) each. Events plugins have declared:\n"); + for (host.event_declarations.items) |d| try w.print("\n- `{s}`: {s}", .{ d.name, d.description }); + try s.objectField("/events"); + try s.beginObject(); + try s.objectField("get"); + try s.beginObject(); + try s.objectField("operationId"); + try s.write("control.events"); + try s.objectField("summary"); + try s.write("Stream the app's events."); + try s.objectField("description"); + try s.write(description.written()); + try s.objectField("parameters"); + try s.write(.{.{ + .name = "topics", + .in = "query", + .description = "Comma-separated event names, or prefixes ending in `*` (`fizzy.document.*`). Every event when absent.", + .schema = .{ .type = "string" }, + }}); + try s.objectField("responses"); + try s.write(.{ .@"200" = .{ + .description = "The stream, open until the client or the app closes it.", + .content = .{ .@"text/event-stream" = .{ .schema = .{ .type = "string" } } }, + } }); + // Each declared event's payload, as JSON, for a client to generate its handlers from. + try s.objectField("x-fizzy-events"); + try s.beginArray(); + for (host.event_declarations.items) |d| { + try s.beginObject(); + try s.objectField("name"); + try s.write(d.name); + try s.objectField("owner"); + try s.write(if (d.owner) |o| o.id else null); + try s.objectField("description"); + try s.write(d.description); + try s.objectField("example"); + try s.write(try example(arena, d.payload)); + try s.endObject(); + } + try s.endArray(); + try s.endObject(); + try s.endObject(); +} + +fn commandPath(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) Error!void { + // Escaped as the server unescapes it (`routes`): an id with `{`, a space or `%` is still one path. + try s.objectField(try std.fmt.allocPrint(arena, "/commands/{f}", .{std.fmt.alt(std.Uri.Component{ .raw = c.id }, .formatEscaped)})); + try s.beginObject(); + try s.objectField("post"); + try s.beginObject(); + try s.objectField("operationId"); + try s.write(c.id); + try s.objectField("summary"); + try s.write(c.title); + if (c.owner) |o| { + try s.objectField("tags"); + try s.write(.{o.id}); + } + try s.objectField("x-fizzy-visibility"); + try s.write(@tagName(c.visibility)); + // A command with only `run` takes no body (`callCommand` refuses arguments): none described. + if (c.runWith != null) { + try s.objectField("requestBody"); + try s.beginObject(); + try s.objectField("required"); + try s.write(sdk.Command.requiresArguments(c.params)); + try s.objectField("content"); + try s.beginObject(); + try s.objectField("application/json"); + try s.beginObject(); + try s.objectField("schema"); + try s.beginObject(); + try s.objectField("type"); + try s.write("object"); + // Parameters declared: exactly those (`Command.Params` refuses others). None declared, as a + // `runWith` written by hand: whatever it reads, which the description cannot know. + if (c.params.len > 0) { + try s.objectField("additionalProperties"); + try s.write(false); + try s.objectField("properties"); + try s.beginObject(); + for (c.params) |p| { + try s.objectField(p.key); + try paramSchema(s, arena, p); + } + try s.endObject(); + try s.objectField("required"); + try s.beginArray(); + for (c.params) |p| if (p.required) try s.write(p.key); + try s.endArray(); + } + try s.endObject(); + try s.endObject(); + try s.endObject(); + try s.endObject(); + } + try s.objectField("responses"); + try s.write(.{ + .@"200" = jsonResponse("Its result: any JSON value, `null` when it returns nothing.", true), + .@"400" = error_response, + .@"404" = error_response, + .@"409" = error_response, + .@"500" = error_response, + }); + try s.endObject(); + try s.endObject(); +} + +fn paramSchema(s: *Stringify, arena: std.mem.Allocator, p: sdk.Command.Param) Error!void { + try s.beginObject(); + try s.objectField("title"); + try s.write(p.label); + try s.objectField("description"); + try s.write(switch (p.kind) { + // ZON of their own: JSON that converts to it (`json.zig`), described rather than typed. + .color, .other => try std.fmt.allocPrint(arena, "{s} (any JSON that converts to the ZON it takes)", .{p.description}), + .int => |k| if (k.choices.len > 0) + try std.fmt.allocPrint(arena, "{s} (suggested: {any})", .{ p.description, k.choices }) + else + try std.fmt.allocPrint(arena, "{s} (from {d} to {d})", .{ p.description, k.min, k.max }), + else => p.description, + }); + switch (p.kind) { + .bool => { + try s.objectField("type"); + try s.write("boolean"); + }, + .int => { + try s.objectField("type"); + try s.write("integer"); + }, + .float => { + try s.objectField("type"); + try s.write("number"); + }, + .string => { + try s.objectField("type"); + try s.write("string"); + }, + // The one constraint `Command.Params` checks: a tag it does not have is refused. + .enumeration => |k| { + try s.objectField("type"); + try s.write("string"); + try s.objectField("enum"); + try s.write(k.choices); + }, + .color, .other => {}, + } + // Left out when it does not convert, rather than a `null` the schema would refuse. + if (p.default) |d| if (try example(arena, d)) |value| { + try s.objectField("default"); + try s.write(value); + }; + try s.endObject(); +} + +/// `zon` as a JSON value, or null when it is not ZON that JSON can say. +fn example(arena: std.mem.Allocator, zon: []const u8) error{OutOfMemory}!?std.json.Value { + const text = json.jsonFromZon(arena, zon) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + return std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch null; +} diff --git a/app/control/routes.zig b/app/control/routes.zig index c4a301a17..34c97ba6e 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -6,6 +6,7 @@ //! GET /commands every command: id, title, owner, whether it is enabled, its params //! GET /commands/{id} one of them //! POST /commands/{id} run it: the body is its arguments, the response its result +//! GET /openapi.json all of this as OpenAPI 3.1, generated from what is registered now //! GET /events the app's events as Server-Sent Events, `?topics=a,b` to choose //! (a name, or a prefix ending in `*`); every event when absent //! @@ -17,6 +18,7 @@ const std = @import("std"); const sdk = @import("fizzy_sdk"); const AppInfo = @import("../AppInfo.zig"); const json = @import("json.zig"); +const openapi = @import("openapi.zig"); const Server = @import("Server.zig"); pub const Answer = struct { @@ -126,6 +128,14 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ for (host.commands.items) |*c| try list.append(arena, try describe(c, arena)); return ok(arena, list.items); } + if (std.mem.eql(u8, trimmed, "/openapi.json")) { + return switch (method) { + .GET => .{ .status = .ok, .body = try openapi.generate(host, arena) }, + // Not generated only to be thrown away. + .HEAD => .{ .status = .ok, .body = "" }, + else => notAllowed(arena, "GET"), + }; + } if (std.mem.eql(u8, trimmed, "/events")) { return switch (method) { .GET => .{ .status = .ok, .stream = true, .body = "" }, diff --git a/app/root.zig b/app/root.zig index 33075ee05..50efe14c7 100644 --- a/app/root.zig +++ b/app/root.zig @@ -112,6 +112,7 @@ pub const control = struct { pub const Server = @import("control/Server.zig"); pub const routes = @import("control/routes.zig"); pub const json = @import("control/json.zig"); + pub const openapi = @import("control/openapi.zig"); /// The socket's name in the config folder. pub const socket_name = "control.sock"; }; diff --git a/tests/integration.zig b/tests/integration.zig index 56ae0cef6..841c6b703 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6859,6 +6859,107 @@ test "control: commands are listed, described and run over the routes" { try std.testing.expectEqualStrings("{\"error\":\"line 200 is past the end\"}", failed.body); } +const OpenApiFixture = struct { + fn byHand(_: *anyopaque, _: *sdk.Command.Call) anyerror!void {} +}; + +test "control: /openapi.json describes each command's body and the declared events" { + const gpa = std.testing.allocator; + const routes = @import("app").control.routes; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var state: u8 = 0; + var plugin = sdk.Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ + .id = "t.place", + .owner = &plugin, + .title = "Place", + .params = PaletteArgs.Params.params, + .runWith = PaletteArgs.Params.bind(ControlFixture.place), + }); + try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off", .run = ControlFixture.nothing }); + // A `runWith` written by hand, declaring no parameters (as the chat plugin's are). + try host.registerCommand(.{ .id = "t.by hand{x}", .owner = &plugin, .title = "By hand", .runWith = OpenApiFixture.byHand }); + // A second of an id: the server runs the first, and the description has one path. + try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off again", .run = ControlFixture.nothing }); + try host.declareEvent(.{ .name = "t.placed", .owner = &plugin, .description = "Something was placed.", .payload = ".{ .line = 4, .side = .left }" }); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const answer = routes.handle(&host, .GET, "/openapi.json", null, "", arena); + try std.testing.expectEqual(std.http.Status.ok, answer.status); + // Parsed as std parses: a repeated key is an error, so a duplicate path would fail here. + const spec = (try std.json.parseFromSliceLeaky(std.json.Value, arena, answer.body, .{})).object; + try std.testing.expectEqualStrings("3.1.0", spec.get("openapi").?.string); + const paths = spec.get("paths").?.object; + for ([_][]const u8{ "/", "/commands", "/events", "/openapi.json", "/commands/t.place", "/commands/t.off", "/commands/t.by%20hand%7Bx%7D" }) |p| { + if (paths.get(p) == null) { + std.debug.print("\nmissing path {s}\n", .{p}); + return error.TestUnexpectedResult; + } + } + try std.testing.expectEqualStrings("Off", paths.get("/commands/t.off").?.object.get("post").?.object.get("summary").?.string); + + // The body: each parameter's type, the required ones listed, nothing else accepted. + const place = paths.get("/commands/t.place").?.object.get("post").?.object; + try std.testing.expectEqualStrings("t.place", place.get("operationId").?.string); + const body = place.get("requestBody").?.object; + try std.testing.expect(body.get("required").?.bool); + const schema = body.get("content").?.object.get("application/json").?.object.get("schema").?.object; + try std.testing.expect(!schema.get("additionalProperties").?.bool); + const props = schema.get("properties").?.object; + const line = props.get("line").?.object; + try std.testing.expectEqualStrings("integer", line.get("type").?.string); + // Bounds the server does not check stay out of the schema, said in words instead. + try std.testing.expect(line.get("minimum") == null and line.get("maximum") == null); + try std.testing.expect(std.mem.indexOf(u8, line.get("description").?.string, "from 1 to") != null); + try std.testing.expectEqualStrings("right", props.get("side").?.object.get("enum").?.array.items[1].string); + try std.testing.expectEqual(@as(i64, 1), props.get("column").?.object.get("default").?.integer); + const required = schema.get("required").?.array.items; + try std.testing.expectEqual(@as(usize, 2), required.len); + try std.testing.expectEqualStrings("line", required[0].string); + const responses = place.get("responses").?.object; + for ([_][]const u8{ "200", "400", "404", "409", "500" }) |code| try std.testing.expect(responses.get(code) != null); + // Any JSON value: the schema `true`, never `[]`, which is not a schema at all. + try std.testing.expect(responses.get("200").?.object.get("content").?.object.get("application/json").?.object.get("schema").?.bool); + + // A command with only `run` takes no body; one with a hand-written `runWith` takes any object. + try std.testing.expect(paths.get("/commands/t.off").?.object.get("post").?.object.get("requestBody") == null); + const by_hand = paths.get("/commands/t.by%20hand%7Bx%7D").?.object.get("post").?.object.get("requestBody").?.object; + const open_schema = by_hand.get("content").?.object.get("application/json").?.object.get("schema").?.object; + try std.testing.expect(open_schema.get("additionalProperties") == null and open_schema.get("properties") == null); + + // The declared event, its example payload as JSON. + const events = paths.get("/events").?.object.get("get").?.object.get("x-fizzy-events").?.array.items; + try std.testing.expectEqual(@as(usize, 1), events.len); + try std.testing.expectEqualStrings("t.placed", events[0].object.get("name").?.string); + try std.testing.expectEqualStrings("left", events[0].object.get("example").?.object.get("side").?.string); + + // Every operationId once; every `$ref` names a schema the description has. + var ids: std.StringHashMapUnmanaged(void) = .empty; + var it = paths.iterator(); + while (it.next()) |path| { + var ops = path.value_ptr.object.iterator(); + while (ops.next()) |op| { + const id = op.value_ptr.object.get("operationId").?.string; + try std.testing.expect(!ids.contains(id)); + try ids.put(arena, id, {}); + } + } + const schemas = spec.get("components").?.object.get("schemas").?.object; + var at: usize = 0; + while (std.mem.indexOfPos(u8, answer.body, at, "#/components/schemas/")) |i| { + const name_start = i + "#/components/schemas/".len; + const name_end = std.mem.indexOfScalarPos(u8, answer.body, name_start, '"').?; + try std.testing.expect(schemas.get(answer.body[name_start..name_end]) != null); + at = name_end; + } + // A HEAD is answered without generating it. + try std.testing.expectEqualStrings("", routes.handle(&host, .HEAD, "/openapi.json", null, "", arena).body); +} + const ControlStreamClient = struct { path: []const u8, reply: [4096]u8 = undefined, From e3a71ea6339eb90e72376383fb793db026a214fc Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:48:26 -0500 Subject: [PATCH 21/31] web: a tape link's plugin wait builds again (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `main` doesn't compile, on native or the web: `src/editor/TapeTabs.zig` uses `app.store` (from #514, the tape link's wait for its plugins), but the file only names `@import("app").automation`. Each PR passed on its own, and the two landed together. This names `app` once and takes `automation` from it. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: `zig build` and `zig build check-web` compile again, and `scripts/check-fmt.sh` passes. - [ ] Windows: - [ ] Linux: - [x] Web: `check-web` ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- src/editor/TapeTabs.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig index a81d99220..c70d6e704 100644 --- a/src/editor/TapeTabs.zig +++ b/src/editor/TapeTabs.zig @@ -18,7 +18,8 @@ const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const core = fizzy.core; const sdk = fizzy.sdk; -const automation = @import("app").automation; +const app = @import("app"); +const automation = app.automation; const Editor = @import("Editor.zig"); playing: std.ArrayListUnmanaged(*Playback) = .empty, From 4623dd0050261b5af100a9ae9539b12c6f776880 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:09:22 -0500 Subject: [PATCH 22/31] app: control server on by default, socket in a private folder (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md`, step 4. **Stacked on fizzyedit/fizzy#508** (base `app/control-openapi`). Retarget to `main` once #499 and #508 merge. ## What changes - **On by default** on every native platform. Settings > Control server (`control_server` in `settings.zon`) turns it off and on, followed live once a frame like *Restart when rebuilt*. `FIZZY_CONTROL` is gone. A server that can't start logs why and isn't retried until the setting goes off and on. It never starts in tests. - **A private folder.** The socket is `/control/control.sock`, and `start` makes the folder `0700`. That closes the one finding deferred from #490's review: with a group-writable umask, the socket was briefly group-reachable between `listen` and its `chmod 0600`. The server test checks both modes. - **`fizzy --control-path`** prints the socket's path and exits, before the single-instance lock would hand the launch to a running fizzy. It resolves `--profile` and `FIZZY_PROFILE` with the same code a launch uses (`singleton.resolveArgs`), so `curl --unix-socket "$(fizzy --control-path)" …` reaches the fizzy started the same way. - **`docs/CONTROL.md`**: where it listens and who can reach it, the API and its errors, events, the OpenAPI description, and what's still to come. Linked from `AGENTS.md`. ## Review An independent review pass found 8 issues. Fixed: - **Crash:** std's `UnixAddress.max_len` is 108 on macOS, whose `sockaddr_un` holds 104, so a 105–108-byte socket path panicked in `listen`. With the server now on by default and the new `control/` folder, a deep `--profile` was enough to hit it. Paths are now checked against the real size (`Server.max_path`). A config folder too deep listens in a private folder under `$TMPDIR` (else `$XDG_RUNTIME_DIR`, else `/tmp`), named by a hash of the config folder, and `--control-path` prints that same path. Live: an 86-byte profile ran, listened there (`0700`/`0600`) and answered curl. Written up as a pitfall in `app/AGENTS.md`. - **Latch never reset:** a failed start stuck until restart, because turning the setting off never cleared it. - **Failed `--control-path`:** if it failed, it fell through into a full launch. It now always exits, with 1 and the reason when it can't print. - **Folder chmod followed a symlink:** a link in the folder's place is now refused. - **Socket file left behind** when the server failed after `listen`. - **Fallback mismatch:** `--control-path` now uses the editor's fallback folder (the executable's). - **Docs vs code:** `docs/CONTROL.md` now lists the plain-text statuses (400/413/417/503) apart from the JSON ones, adds 405, and documents the long-path fallback and Windows capture. The 503 now reads "the control server is stopping", since turning the setting off sends it too. - **Windows console (suspect, not changed):** a Windows release is a GUI program with no console, so `--control-path` must be captured there (`$sock = fizzy --control-path`). The doc says so. Untested on Windows. ## SDK impact - [x] None. ## Verified - [x] macOS: - **Default on:** a plain `fizzy --profile` run listens at `control/control.sock`, the folder `drwx------` and the socket `srw-------`. - **Live toggle:** writing `.control_server = false` to `settings.zon` removed the socket live, and `true` brought it back, answering curl. - **`--control-path`** prints the right path for the default config folder, for `--profile`, and for a relative `FIZZY_PROFILE` (made absolute against the cwd). It starts no fizzy. - **Gates:** fmt, build, `test` (589/590, the skip is pre-existing), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - **Not checked by eye:** the new Settings row. It's the same `drawToggle` row as its neighbours. - [x] Windows: the x86_64 cross-compile links. The folder's mode isn't set there, since the config folder is under `%LOCALAPPDATA%`, which only the user can open. - [ ] Linux: CI - [x] Web: `check-web`; the setting does nothing there. ## Follow-ups - Step 5: MCP on the same socket, sessions, and the agent plugin's tools as `agent.*` commands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 + app/AGENTS.md | 3 + app/App.zig | 5 +- app/control/Server.zig | 67 +++++++++++++++++-- app/control/openapi.zig | 2 +- app/root.zig | 11 +++- app/settings/Settings.zig | 5 ++ docs/CONTROL.md | 106 +++++++++++++++++++++++++++++++ src/Entry.zig | 49 ++++++++++++++ src/editor/Editor.zig | 53 +++++++++++----- src/editor/explorer/settings.zig | 14 ++++ tests/integration.zig | 5 +- 12 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 docs/CONTROL.md diff --git a/AGENTS.md b/AGENTS.md index 6ea356df8..73982c2a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,8 @@ access works from what the code and its public docs say. into OS windows (ImGui-style viewports, per-OS dressing, the dvui-dev replay hook) → [`plans/POPOUT_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/POPOUT_WINDOWS_PLAN.md). - Full plugin contract + lifecycle/hook tables → `docs/PLUGINS.md` +- Driving a running fizzy from outside (`curl` on its socket, the commands as the API, events, + OpenAPI) → [`docs/CONTROL.md`](docs/CONTROL.md); code in `app/control/`. - The forks and patches fizzy builds on (dvui-dev, SDL's patches in `backend/patches/sdl`, sdl_zig): what each changes, where each is pinned, how to bump → [`docs/DEPENDENCIES.md`](docs/DEPENDENCIES.md) - Native windows and glass: what Liquid Glass can do (measured), the drag's glass as one native overlay on macOS 26, and fizzy's backend owning its windows while taking SDL's releases → diff --git a/app/AGENTS.md b/app/AGENTS.md index 79dc303f1..d80947e77 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -42,6 +42,9 @@ each directory is. Windows `accept` treats the cancelled wait that closing causes as `unreachable` (a panic), and connecting to the socket's path to wake it reaches whoever listens there now. `Future.cancel` interrupts it on every OS (`control/Server.zig`). +- **std's `UnixAddress.max_len` is 108 on macOS, whose `sockaddr_un` holds 104.** A socket path of + 105 to 108 bytes passes `UnixAddress.init` and panics in `listen` (a stack overflow in + ReleaseFast). Check against `control.Server.max_path`, the real size, before `init`. - **On Windows, a socket test must not read to EOF when it need not.** Its AF_UNIX sometimes never tells a reader that the other side closed, so a test client reading "until the end" hangs about one run in twenty. Read `Content-Length` bytes, as HTTP clients do (`control/Server.zig`'s diff --git a/app/App.zig b/app/App.zig index ec75e5e7a..b68c50d6a 100644 --- a/app/App.zig +++ b/app/App.zig @@ -265,9 +265,8 @@ settings_save_deadline_ns: i128 = 0, /// `SettingsWatcher.start`'s doc comment), torn down first in `deinit`. settings_watcher: ?SettingsWatcher = null, -/// The control server (`app.control`), while it runs: started in `postInit` when `FIZZY_CONTROL=1` -/// (plans/CONTROL_SERVER_PLAN.md, steps 1–3 land dark), answered each frame, stopped at quit. -/// Always void on wasm, which has no socket. +/// The control server (`app.control`), while the `control_server` setting has it run: followed +/// once a frame, answered each frame, stopped at quit. Always void on wasm, which has no socket. control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, /// What relays the app's events to the control server's streams (`control.routes.Relay`). control_relay: if (builtin.target.cpu.arch == .wasm32) void else ControlRelay = if (builtin.target.cpu.arch == .wasm32) {} else .{}, diff --git a/app/control/Server.zig b/app/control/Server.zig index 8e00458f2..7583046bb 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -29,6 +29,23 @@ pub const max_backlog = 8 * 1024 * 1024; /// How long a stream may stay quiet before a keep-alive comment: how a client that went away /// without closing is noticed. pub const keep_alive_ms = 15_000; +/// The longest socket path the OS takes, without its terminating zero. std's +/// `UnixAddress.max_len` says 108 everywhere but Windows, where Darwin's `sockaddr_un` holds 104: +/// a path between them passes `UnixAddress.init` and then overruns the copy in `listen`. +pub const max_path = if (builtin.os.tag == .windows) 107 else @sizeOf(std.posix.sockaddr.un) - @offsetOf(std.posix.sockaddr.un, "path") - 1; + +/// Where an app whose config folder is `config_folder` listens: `control/control.sock` in it, a folder +/// of its own only this user can open (`start` makes it). When that is longer than the OS takes +/// (`max_path`, a deep profile), a folder in `runtime_dir` (`$TMPDIR`, `$XDG_RUNTIME_DIR`, else +/// `/tmp`) named by a hash of the config folder: per config folder still, and just as private. +pub fn socketPath(gpa: std.mem.Allocator, config_folder: []const u8, runtime_dir: ?[]const u8) ![]u8 { + const preferred = try std.fs.path.join(gpa, &.{ config_folder, "control", "control.sock" }); + if (preferred.len <= max_path) return preferred; + gpa.free(preferred); + var name: [32]u8 = undefined; + const folder = try std.fmt.bufPrint(&name, "fizzy-control-{x:0>16}", .{std.hash.Wyhash.hash(0, config_folder)}); + return std.fs.path.join(gpa, &.{ runtime_dir orelse "/tmp", folder, "control.sock" }); +} gpa: std.mem.Allocator, io: Io, @@ -157,7 +174,19 @@ pub fn start(self: *Server) !void { // Windows has AF_UNIX from 10 1803, and std offers it only to a target whose minimum is that // (`std.Io.net.has_unix_sockets`); fizzy's build sets it. if (!Io.net.has_unix_sockets) return error.UnixSocketsUnsupported; + if (self.socket_path.len > max_path) return error.NameTooLong; const addr = try Io.net.UnixAddress.init(self.socket_path); + // Its folder, this user's only: the socket is unreachable to others from the moment it exists, + // before its own permissions are set below. A link in its place is refused, not followed: its + // target may be a folder shared with others, which the mode would then be set on. + if (std.fs.path.dirname(self.socket_path)) |dir| { + try Io.Dir.cwd().createDirPath(self.io, dir); + if (comptime builtin.os.tag != .windows) { + const st = try Io.Dir.cwd().statFile(self.io, dir, .{ .follow_symlinks = false }); + if (st.kind != .directory) return error.NotAFolder; + try Io.Dir.cwd().setFilePermissions(self.io, dir, .fromMode(0o700), .{ .follow_symlinks = false }); + } + } // A socket left by a fizzy that did not quit cleanly, or one a fizzy this one takes over from // still holds: this run's now. The other leaves it alone at its stop (`socket_inode`). Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; @@ -165,6 +194,7 @@ pub fn start(self: *Server) !void { errdefer { self.listener.?.deinit(self.io); self.listener = null; + Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; } if (comptime builtin.os.tag != .windows) { // This user only. The umask usually says so already; a shared machine's may not. @@ -229,7 +259,7 @@ pub fn stop(self: *Server) void { defer self.lock.unlock(self.io); for (self.pending.items) |r| { answered.append(self.gpa, r.conn) catch {}; - r.answer(self, .service_unavailable, "text/plain", "fizzy is closing\n"); + r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n"); } self.pending.clearRetainingCapacity(); } @@ -507,7 +537,8 @@ test "a request reaches the UI thread and its answer goes back" { defer tmp.cleanup(); const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); defer gpa.free(dir); - const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + // In a folder of its own that `start` makes, as the app's is (`control/control.sock`). + const path = try std.fs.path.join(gpa, &.{ dir, "control", "c.sock" }); defer gpa.free(path); var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; @@ -548,6 +579,9 @@ test "a request reaches the UI thread and its answer goes back" { if (builtin.os.tag == .windows) return; const st = try Io.Dir.cwd().statFile(io, path, .{}); try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); + // And its folder: nobody else reaches it, even before the socket's own mode is set. + const folder = try Io.Dir.cwd().statFile(io, std.fs.path.dirname(path).?, .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o700), folder.permissions.toMode() & 0o777); } fn testSocket(gpa: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { @@ -556,7 +590,7 @@ fn testSocket(gpa: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { return std.fs.path.join(gpa, &.{ dir, "c.sock" }); } -test "a request still queued at quit is answered: fizzy is closing" { +test "a request still queued at quit is answered: the server is stopping" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; @@ -582,7 +616,7 @@ test "a request still queued at quit is answered: fizzy is closing" { thread.join(); const reply = client.reply[0..client.reply_len]; try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 503 Service Unavailable\r\n")); - try std.testing.expect(std.mem.endsWith(u8, reply, "fizzy is closing\n")); + try std.testing.expect(std.mem.endsWith(u8, reply, "the control server is stopping\n")); } test "what cannot be served is refused before its body, and never reaches the app" { @@ -617,9 +651,32 @@ test "what cannot be served is refused before its body, and never reaches the ap try std.testing.expectEqual(@as(usize, 0), taken.items.len); } +test "a config folder too deep for a socket path listens in a runtime folder instead" { + const gpa = std.testing.allocator; + const short = try socketPath(gpa, "/home/me/.config/fizzy", "/run/user/1000"); + defer gpa.free(short); + // Joined as the OS joins paths: `\` between them on Windows. + const want = try std.fs.path.join(gpa, &.{ "/home/me/.config/fizzy", "control", "control.sock" }); + defer gpa.free(want); + try std.testing.expectEqualStrings(want, short); + // Between Darwin's 104 and std's 108, the length that crashed `listen`: not used as it is. + const deep = "/" ++ "d" ** (max_path - "/control/control.sock".len + 2); + const fallback = try socketPath(gpa, deep, "/tmp/me"); + defer gpa.free(fallback); + try std.testing.expect(fallback.len <= max_path); + const folder = try std.fs.path.join(gpa, &.{ "/tmp/me", "fizzy-control-" }); + defer gpa.free(folder); + try std.testing.expect(std.mem.startsWith(u8, fallback, folder)); + // Per config folder: another deep one gets another folder. + const other = try socketPath(gpa, deep ++ "x", "/tmp/me"); + defer gpa.free(other); + try std.testing.expect(!std.mem.eql(u8, fallback, other)); +} + test "a socket path the OS cannot hold fails at start, not later" { if (!Io.net.has_unix_sockets) return error.SkipZigTest; - const long = "/" ++ "d" ** (Io.net.UnixAddress.max_len + 8); + // Longer than the OS takes but within std's limit: refused here, not overrun in `listen`. + const long = "/" ++ "d" ** max_path; var server: Server = .{ .gpa = std.testing.allocator, .io = std.testing.io, .socket_path = long, .wake = noWake }; try std.testing.expectError(error.NameTooLong, server.start()); } diff --git a/app/control/openapi.zig b/app/control/openapi.zig index 3159158f2..c3548688a 100644 --- a/app/control/openapi.zig +++ b/app/control/openapi.zig @@ -35,7 +35,7 @@ fn write(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) Error!void { try s.write(.{ .title = AppInfo.current.display_name ++ " control server", .version = AppInfo.current.version, - .description = "The app's commands over HTTP on a Unix socket in its config folder (`control.sock`), " ++ + .description = "The app's commands over HTTP on a Unix socket in its config folder (`control/control.sock`; `fizzy --control-path` prints it), " ++ "readable by this user only: `curl --unix-socket http://localhost/commands`. Bodies are JSON; " ++ "send `Content-Type: application/zon` to send and receive ZON instead.", }); diff --git a/app/root.zig b/app/root.zig index 50efe14c7..8e7e7dfdf 100644 --- a/app/root.zig +++ b/app/root.zig @@ -113,8 +113,15 @@ pub const control = struct { pub const routes = @import("control/routes.zig"); pub const json = @import("control/json.zig"); pub const openapi = @import("control/openapi.zig"); - /// The socket's name in the config folder. - pub const socket_name = "control.sock"; + /// Where the server listens for an app whose config folder is `config_folder` + /// (`Server.socketPath`), with this user's runtime folder for a config folder too deep. + pub fn socketPath(gpa: @import("std").mem.Allocator, config_folder: []const u8) ![]u8 { + const std = @import("std"); + const runtime: ?[]const u8 = if (@import("builtin").os.tag == .windows) null else for ([_][*:0]const u8{ "TMPDIR", "XDG_RUNTIME_DIR" }) |name| { + if (std.c.getenv(name)) |v| if (std.mem.len(v) > 0) break std.mem.span(v); + } else null; + return Server.socketPath(gpa, config_folder, runtime); + } }; /// Quitting the way the app always quits — unsaved documents asked about first — then starting diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index 7f540c268..2d293c4a1 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -113,6 +113,11 @@ black_box: bool = true, /// built to update itself (Velopack), whose binary only changes through its own updater. restart_on_rebuild: bool = !build_opts.velopack_enabled, +/// Whether the app answers HTTP on a Unix socket in its config folder (`control/control.sock`), its +/// commands as the API, so a script or an agent can drive it with `curl` (`app.control`). Only this +/// user can open the socket. Native only. +control_server: bool = true, + /// Canvas zoom/pan control scheme shared by the image viewer, pixi, and any other /// `CanvasWidget` consumer. `auto` picks mouse vs trackpad from `dvui.mouseType()`. input_scheme: InputScheme = .auto, diff --git a/docs/CONTROL.md b/docs/CONTROL.md new file mode 100644 index 000000000..d39007aef --- /dev/null +++ b/docs/CONTROL.md @@ -0,0 +1,106 @@ +# The control server + +A running fizzy answers HTTP on a Unix socket in its config folder. Its API is the commands every +loaded plugin registers, with the parameters each declares, so anything a plugin can do through a +command, a script can do with `curl`, with no agent and no MCP tooling in between. + +```sh +SOCK="$(fizzy --control-path)" +curl --unix-socket "$SOCK" localhost/commands +curl --unix-socket "$SOCK" localhost/commands/fizzy.openFiles -d '{"path":"/abs/notes.md"}' +curl --unix-socket "$SOCK" localhost/commands/text.insert -d '{"text":"hello\n","line":1,"path":"/abs/notes.md"}' +curl -N --unix-socket "$SOCK" 'localhost/events?topics=fizzy.document.*' +``` + +It is on by default on every native platform (Settings > Control server, `control_server` in +`settings.zon`), and absent on the web. The code is `app/control/`; the design and what comes next +are in `plans/CONTROL_SERVER_PLAN.md`. + +## Where it listens + +`/control/control.sock`, the config folder being the platform's (`~/Library/Application +Support/fizzy` on macOS, `$XDG_CONFIG_HOME/fizzy` (else `~/.config/fizzy`) on Linux, `%LOCALAPPDATA%\fizzy` on Windows) or the +profile's (`--profile `, `FIZZY_PROFILE`). A config folder so deep that the path would pass the +OS's limit for a socket (104 bytes on macOS, 108 on Linux) listens in a private folder of its own +under `$TMPDIR` (else `$XDG_RUNTIME_DIR`, else `/tmp`) instead, named by a hash of the config folder. +`fizzy --control-path`, with the same `--profile` or `FIZZY_PROFILE` as the fizzy it means, prints +the path either way and exits (1, saying why, when it cannot), whether a fizzy runs or not. A +Windows release has no console to print to: capture it, `$sock = fizzy --control-path`. + +**Who can use it:** only you. The socket's folder is `0700` and the socket `0600`, so nobody else +on the machine can connect; there is no token. A link in the folder's place is refused rather than +followed. On Windows the default config folder is under `%LOCALAPPDATA%`, which only you can open; a +`--profile` folder elsewhere gets no tighter access than it already has. Nothing listens on the +network. + +Windows has AF_UNIX from Windows 10 1803, and its bundled `curl` takes `--unix-socket`. fizzy's +build sets 1803 as its minimum: std offers Unix sockets only to such a target. An app built on +fizzy with its own Windows target must set it too, or its server logs that it cannot listen. + +## The API + +| Method and path | Answers | +|---|---| +| `GET /` | the app, its version, the SDK it was built against | +| `GET /commands` | every command: id, title, owner, visibility, whether it is enabled now, its params | +| `GET /commands/{id}` | one of them | +| `POST /commands/{id}` | runs it: the body is its arguments, the response its result | +| `GET /events?topics=a,b` | the app's events as Server-Sent Events | +| `GET /openapi.json` | all of this as OpenAPI 3.1, generated from what is registered now | + +**Bodies are JSON**, converted to the ZON commands take at the server's edge: an object of +arguments keyed by the command's parameter names, a string where the parameter is an enumeration +being its tag (`{"side": "right"}`). Results come back as JSON, `null` when a command returns +nothing. Send `Content-Type: application/zon` to send ZON and get ZON back, for an enum deeper than +a top-level argument. + +**Errors are HTTP's.** Those about a command or a route carry `{"error": "…"}` saying why: + +| Status | When | +|---|---| +| 400 | the arguments do not fit the parameters, or the body is not JSON | +| 404 | no such command, or nothing at that path | +| 405 | that method is not answered there | +| 409 | the command is not enabled now (no document open, say) | +| 500 | it ran and failed | + +Those about the request itself, refused before the app sees it, are plain text: + +| Status | When | +|---|---| +| 400 | a GET or HEAD sent a body | +| 413 | the body is over 64 MB | +| 417 | an `Expect` other than `100-continue` | +| 503 | the control server is stopping (fizzy is quitting, or the setting went off) | + +A command runs on the UI thread at the start of the next frame, as a menu item or a keybind would, +so the frame after it shows what it did. A call costs one wake and returns within a frame. + +## Events + +`GET /events` streams every event on the app's bus: fizzy's `fizzy.document.*` (opened, changed, +saved, closed), `fizzy.folder.*`, `fizzy.files.*`, and whatever plugins announce. `?topics=` picks +names, or prefixes ending in `*`; none means all of them. Each is one SSE message: + +``` +event: fizzy.document.saved +data: {"path":"/abs/notes.md"} +``` + +`/openapi.json` lists the events plugins declared, with an example payload each. A stream gets a +keep-alive comment every 15 seconds; one that falls more than 8 MB behind gets nothing more. With +no stream open, events cost the bus nothing extra. + +## The description + +`GET /openapi.json` describes the server as it is now, so it changes as plugins load and reload. +It types what the server enforces: each parameter's type, and an enumeration's choices. A number's +bounds and suggested choices are hints the server does not check, so they are in the parameter's +description instead. Commands whose `runWith` takes arguments it does not declare (written by +hand) take any object. Results are not typed yet: any JSON value. + +## Not yet + +From the plan, in order: MCP on the same socket and sessions (claims and tapes for scripts and +agents), loopback TCP for clients that cannot dial a socket, the chat plugin's conversations as +commands and events, and a second launch forwarding its files over this socket. diff --git a/src/Entry.zig b/src/Entry.zig index 004d7c630..8967fbb88 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -228,6 +228,8 @@ pub fn main(main_init: std.process.Init) !u8 { } }.f, }); + // Before the lock, which would hand this launch to a running fizzy. + if (printControlPath(appAllocator(), main_init)) |code| return code; try singleton.earlyStartup(appAllocator(), main_init); fizzy.core.profile.phase(main_init.io, if (restart.startedByHandover()) "lock deferred (handover)" else "lock"); @@ -547,3 +549,50 @@ fn frameOnce() !dvui.App.Result { } var frame_target: fizzy.core.FrameTarget = .{}; + +/// `fizzy --control-path` (with `--profile` or `FIZZY_PROFILE` as any launch takes them): print where +/// the control server listens, for a script to hand `curl --unix-socket`, and exit: 0 with the path, +/// 1 with why not, never on into a launch. Null when not asked. The profile and config folder are +/// resolved as a launch resolves them (`singleton.resolveArgs`, `configFolder` falling back to the +/// executable's folder), so it is the path a fizzy started the same way listens on, running or not. +/// A Windows release is a GUI program with no console: there, capture it (`$p = fizzy --control-path`). +fn printControlPath(allocator: std.mem.Allocator, main_init: std.process.Init) ?u8 { + if (comptime builtin.target.cpu.arch == .wasm32) return null; + var it = std.process.Args.Iterator.initAllocator(main_init.minimal.args, allocator) catch return null; + defer it.deinit(); + var raw: std.ArrayList([]const u8) = .empty; + defer raw.deinit(allocator); + var asked = false; + while (it.next()) |arg| { + if (std.mem.eql(u8, arg, "--control-path")) asked = true else raw.append(allocator, arg) catch return 1; + } + if (!asked) return null; + const path = controlPath(allocator, main_init, raw.items) catch |err| { + std.log.err("--control-path: {t}", .{err}); + return 1; + }; + defer allocator.free(path); + var buf: [std.fs.max_path_bytes + 1]u8 = undefined; + var out = std.Io.File.stdout().writer(main_init.io, &buf); + out.interface.print("{s}\n", .{path}) catch return 1; + out.interface.flush() catch return 1; + return 0; +} + +fn controlPath(allocator: std.mem.Allocator, main_init: std.process.Init, raw: []const []const u8) ![]u8 { + var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; + const cwd_len = std.process.currentPath(main_init.io, &cwd_buf) catch 0; + const env_profile: ?[]const u8 = if (std.c.getenv(@import("app").profile.env_var)) |v| std.mem.span(v) else null; + const resolved = try singleton.resolveArgs(allocator, cwd_buf[0..cwd_len], raw, env_profile); + defer singleton.freeResolvedArgv(allocator, resolved.argv); + defer if (resolved.profile) |p| allocator.free(p); + var arena: std.heap.ArenaAllocator = .init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const config = resolved.profile orelse config: { + // The editor's fallback (`Editor.init`): the executable's folder, where it runs from. + const exe_dir = try std.process.executableDirPathAlloc(main_init.io, a); + break :config try fizzy.core.paths.configFolder(a, main_init.io, a, fizzy.core.platform.processEnviron(), exe_dir, AppInfo.current.config_dir); + }; + return @import("app").control.socketPath(allocator, config); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 0c454f978..2fe1d91e7 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -141,6 +141,9 @@ share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, plugin_reloads: @import("PluginReloads.zig") = .{}, /// The last minute of input, frame by frame, for a capture (`captures`, `fizzy.capture`). black_box: @import("app").automation.BlackBox = .{}, +/// The control server would not start (`syncControlServer`): not retried until the setting goes +/// off and on again. +control_failed: bool = false, /// The documents a restart kept (`KeptDocuments.loadSession`), until the workbench has reopened /// last session's tabs: each one it asks for opens from here (`openPath`). session: ?KeptDocuments = null, @@ -2286,23 +2289,6 @@ pub fn postInit(editor: *Editor) !void { // `editor.demo` was attached above, at its final address. try editor.app.host.registerService(sdk.services.automation.Api, &editor.demo.service.api, null); - // The control server: the app's commands over HTTP on `/control.sock`, while - // `FIZZY_CONTROL=1` asks for it (plans/CONTROL_SERVER_PLAN.md: dark until step 4). - if (comptime builtin.target.cpu.arch != .wasm32) control: { - const v = std.process.Environ.getAlloc(fizzy.core.platform.processEnviron(), editor.app.gpa, "FIZZY_CONTROL") catch break :control; - defer editor.app.gpa.free(v); - if (!std.mem.eql(u8, v, "1")) break :control; - const path = std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, @import("app").control.socket_name }) catch break :control; - editor.app.control = .{ .gpa = editor.app.gpa, .io = dvui.io, .socket_path = path, .wake = wakeEventLoop }; - editor.app.control.?.start() catch |err| { - dvui.log.warn("control: could not listen on {s}: {s}", .{ path, @errorName(err) }); - editor.app.gpa.free(path); - editor.app.control = null; - break :control; - }; - dvui.log.info("control: listening on {s}", .{path}); - } - // Live external-edit reconciliation for settings.zon + dropped-in plugin discovery (see // R11/R12 in plans/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — // nightwatch retains `&editor.app.settings_watcher.handler`, so `editor` has to already be at @@ -3636,6 +3622,7 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.app.settings.native_menus = parsed.native_menus; editor.app.settings.native_dialogs = parsed.native_dialogs; editor.app.settings.black_box = parsed.black_box; + editor.app.settings.control_server = parsed.control_server; editor.app.settings.input_scheme = parsed.input_scheme; editor.app.settings.plugin_update_mode = parsed.plugin_update_mode; @@ -4176,6 +4163,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (editor.app.settings_watcher) |*w| w.tick(editor.configWatchSink()); // fizzy itself rebuilt: start again as the new build, every document kept. editor.syncExecutableWatcher(); + editor.syncControlServer(); // Unsaved work kept against a crash. editor.checkpoint.tick(editor); if (editor.app.executable_watcher) |*w| if (w.tick() and !restart.requested()) { @@ -5487,6 +5475,37 @@ pub const Placement = struct { beside: bool = false, }; +/// Run the control server while `control_server` is on, and stop it when it goes off. Once a frame; +/// a no-op unless the setting changed. Not in tests, whose editors share no config folder worth a +/// socket. +fn syncControlServer(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32 or builtin.is_test) return; + const control = @import("app").control; + const want = editor.app.settings.control_server; + // Off clears a failed start, so turning it on again tries again. + if (!want) editor.control_failed = false; + if (want == (editor.app.control != null)) return; + if (!want) { + if (editor.app.control) |*c| { + control.routes.stop(c, &editor.app.control_relay, &editor.app.host); + editor.app.gpa.free(c.socket_path); + } + editor.app.control = null; + return; + } + if (editor.control_failed) return; + const path = control.socketPath(editor.app.gpa, editor.app.config_folder) catch return; + editor.app.control = .{ .gpa = editor.app.gpa, .io = dvui.io, .socket_path = path, .wake = wakeEventLoop }; + editor.app.control.?.start() catch |err| { + dvui.log.warn("control: could not listen on {s}: {s}", .{ path, @errorName(err) }); + editor.app.gpa.free(path); + editor.app.control = null; + editor.control_failed = true; + return; + }; + dvui.log.info("control: listening on {s}", .{path}); +} + /// Watch this executable while `restart_on_rebuild` is on, and stop when it goes off. Once a frame; /// a no-op unless the setting changed. The watcher has to be at its final address to start /// (`ExecutableWatcher.start`), which `editor.app` is. diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index b8f03fa36..1ca930b12 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -305,6 +305,16 @@ const window_items = [_]Item{ .draw = drawFloatWindows, .inline_control = true, }, + .{ + .label = "Control server", + .key = "control_server", + .description = "Answer HTTP on a socket in the config folder, the commands as its API, so a " ++ + "script or an agent can drive fizzy with curl. Only you can open the socket; " ++ + "`fizzy --control-path` prints where it is.", + .keywords = "control server http api curl socket automation script agent openapi events", + .draw = drawControlServer, + .inline_control = true, + }, .{ .label = "Restart when rebuilt", .key = "restart_on_rebuild", @@ -676,6 +686,10 @@ fn drawRestartOnRebuild() void { drawToggle(&fizzy.editor().app.settings.restart_on_rebuild, "Restart when rebuilt"); } +fn drawControlServer() void { + drawToggle(&fizzy.editor().app.settings.control_server, "Control server"); +} + fn drawToggle(value: *bool, name: []const u8) void { if (dvui.checkbox(@src(), value, null, .{ .label = .{ .text = name }, diff --git a/tests/integration.zig b/tests/integration.zig index 841c6b703..3ca3196a6 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6881,8 +6881,9 @@ test "control: /openapi.json describes each command's body and the declared even try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off", .run = ControlFixture.nothing }); // A `runWith` written by hand, declaring no parameters (as the chat plugin's are). try host.registerCommand(.{ .id = "t.by hand{x}", .owner = &plugin, .title = "By hand", .runWith = OpenApiFixture.byHand }); - // A second of an id: the server runs the first, and the description has one path. - try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off again", .run = ControlFixture.nothing }); + // Another plugin's command by the same id: held in reserve, the first kept and described once. + var other = sdk.Plugin{ .state = &state, .vtable = &vtable, .id = "u", .display_name = "U" }; + try host.registerCommand(.{ .id = "t.off", .owner = &other, .title = "Off again", .run = ControlFixture.nothing }); try host.declareEvent(.{ .name = "t.placed", .owner = &plugin, .description = "Something was placed.", .payload = ".{ .line = 4, .side = .left }" }); var arena_state: std.heap.ArenaAllocator = .init(gpa); From 99458700a5fb1ddd17d5c82e0a8f383e8a0d25da Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:38:23 -0500 Subject: [PATCH 23/31] sdk: a command may answer in a later frame (#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5a). Follows fizzyedit/fizzy#509 (merged), with `main` merged in: the later answers are now built into `main`'s command slots (`callResolved`, which overrides and `callReplaced` go through). ## What changes **A command may answer in a later frame.** This is for work that outlives the frame: a language server's reply, a build, a picture taken at the end of the next frame. ```zig fn format(state: *anyopaque, args: Args, call: *sdk.Command.Call) anyerror!void { const self: *Plugin = @ptrCast(@alignCast(state)); self.pending = call.later(); // keep the ticket, return with no result self.lsp.requestFormat(args.path); } // …frames later, on the UI thread: host.finishCall(self.pending, .{ .ok = result_zon }); // or .failed = "why" ``` - **Callers that can wait:** `Host.callCommandAwaiting(id, args, arena, sink)` answers `.now` (what came of it) or `.later` (the ticket), and calls `sink.done` once with the outcome when it finishes. - **Callers that can't:** plain `callCommand`, menus, keybinds and the palette are unchanged. A deferred command counts as started, and its answer is dropped. None of the ~36 existing call sites changed. - **Letting go:** `callWanted(ticket)` lets a command stop work nobody wants, and `abandonCall(ticket)` lets a caller stop waiting. - **No waiter hangs:** a plugin unloaded or withdrawn while it owes answers fails them, so every waiter hears something. - **Non-agent consumers:** any long command over the control server, and in this PR fizzy's own **`fizzy.screen`** (what's on screen as data: windows, widgets, roles, names, tags, rects, from the automation service's snapshot, taken over the next frame) and **`fizzy.settled`** (`true` once nothing is in flight, `false` at `timeout_ms`). Both are `internal`, for scripts and tests: `curl … /commands/fizzy.screen`. They're the first half of the plan's step 5a, the automation service as commands. - **The control server holds the request open** until its command answers (`routes.State.waiters`). It keeps the requests it has taken but not answered (`Server.outstanding`), so `stop` answers those with 503 too, after letting go of their calls. - **Docs:** `docs/PLUGINS.md` §3.4 ("Answering later") and `docs/CONTROL.md`. ## Review An independent review pass found 10 issues. Fixed, with tests where they can have one: - **High: tickets mixed up between nested calls.** A command that called another that defers shared that command's ticket, and the caller was handed the wrong one, which could end in use-after-free after a stop. Each `runWith` now takes its ticket before it runs. Tested: nested calls keep distinct tickets, and each answer reaches only its own waiter. - **High: deadlock.** `answerStream`'s out-of-memory path re-took the server lock. - **High: early answers lost.** An answer given inside `runWith` (a cache hit) was dropped, leaving the caller waiting for good. It now reaches the caller at once. Tested. - **Leak:** a call nobody waits for no longer keeps an entry, and `abandonCall` removes its entry. - **Dangling sink:** a sink names its plugin (`CallSink.owner`) and is let go of, never called, when that plugin goes. Tested. - **Spin:** `fizzy.screen` re-asks for its snapshot each frame and gives up after 120 frames, instead of refreshing forever. A first version of this fix dropped the refresh and took 1 s; restored. It's 40 ms now, and the app goes idle after. - **Hang at stop:** `stop` moves out what it owes without allocating, so running out of memory can't leave a request unanswered. - **Docs:** a caller that can't wait sees `.ok` with no result and goes on at once. - **Not changed:** - A client that hangs up while its command is outstanding isn't noticed until the answer comes or the server stops. Its connection task waits uncancelably. Watching the socket for a hang-up is a follow-up. - `finishCall` is UI-thread only, as documented. A worker posts back first. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0x70dba3df7455c34f`), `sdk_version` untouched, PR labelled `sdk`. Additive for plugin authors: `ticket`, `deferred`, `failed` and `later()` on `Command.Call`; `callCommandAwaiting`, `finishCall`, `callWanted`, `abandonCall` and `CallSink` (with an `owner`) on `Host`. Existing plugins need only a rebuild at the next SDK release. - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - **sdk test:** a waiter hears the answer once; a second `finishCall` is ignored; plain `callCommand` drops the answer; an abandoned call's answer goes nowhere; a withdrawn plugin fails what it owes. - **Integration test over a real socket:** a POST to a deferred command stays open across frames, then gets `200 {"done":true}` when `finishCall` comes. A second one still waiting at `routes.stop` gets 503, and its call is abandoned. - **Live:** curl on `fizzy.settled` gave `true` in 2 ms. `fizzy.screen` gave the screen as JSON (windows, rail tabs, the open document) in 42 ms, two frames. - **Gates:** fmt, build, `test` (590/591, the skip is pre-existing), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: the x86_64 cross-compile links. - [ ] Linux: CI - [x] Web: `check-web`. ## Follow-ups - **5b:** MCP on the control socket: `tools/list` from the commands (inputs from the OpenAPI schemas), `tools/call` through `callCommandAwaiting`. - **5c (fizzyedit/agent):** its tools (`run`, `act`, `build`, `profile`, `tape`, `capture`) as `agent.*` commands answering later, sessions with claims and tapes, and `fizzy-mcp` shrinking to a stdio proxy. - `fizzy.capture`-style pictures (the automation service's `capture`) as a command, with how a picture travels over JSON settled first. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/App.zig | 7 +- app/control/Server.zig | 46 +++++-- app/control/routes.zig | 105 ++++++++++++--- docs/CONTROL.md | 9 +- docs/PLUGINS.md | 9 ++ sdk/src/Command.zig | 20 +++ sdk/src/Host.zig | 279 ++++++++++++++++++++++++++++++++++++++-- sdk/src/version.zig | 2 +- src/editor/Editor.zig | 65 +++++++++- src/editor/Keybinds.zig | 33 ++++- tests/integration.zig | 106 ++++++++++++++- 11 files changed, 632 insertions(+), 49 deletions(-) diff --git a/app/App.zig b/app/App.zig index b68c50d6a..078b051bf 100644 --- a/app/App.zig +++ b/app/App.zig @@ -45,7 +45,7 @@ const Host = sdk.Host; const PluginLoader = @import("root.zig").store.Loader; const SettingsWatcher = @import("watch/SettingsWatcher.zig"); const ControlServer = @import("control/Server.zig"); -const ControlRelay = @import("control/routes.zig").Relay; +const ControlState = @import("control/routes.zig").State; const ExecutableWatcher = @import("watch/ExecutableWatcher.zig"); const FolderWatcher = @import("watch/FolderWatcher.zig"); const Keymap = @import("keymap/Keymap.zig"); @@ -268,8 +268,9 @@ settings_watcher: ?SettingsWatcher = null, /// The control server (`app.control`), while the `control_server` setting has it run: followed /// once a frame, answered each frame, stopped at quit. Always void on wasm, which has no socket. control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, -/// What relays the app's events to the control server's streams (`control.routes.Relay`). -control_relay: if (builtin.target.cpu.arch == .wasm32) void else ControlRelay = if (builtin.target.cpu.arch == .wasm32) {} else .{}, +/// What the control server's routes keep between frames: the relay of the app's events to its +/// streams, and the requests whose commands answer later (`control.routes.State`). +control_state: if (builtin.target.cpu.arch == .wasm32) void else ControlState = if (builtin.target.cpu.arch == .wasm32) {} else .{}, /// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts /// the app into it, keeping every document. Null while the setting is off, and wherever diff --git a/app/control/Server.zig b/app/control/Server.zig index 7583046bb..8bcd151d2 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -68,6 +68,9 @@ socket_inode: ?Io.File.INode = null, /// Requests waiting for the UI thread, under `lock`. lock: Io.Mutex = .init, pending: std.ArrayListUnmanaged(*Request) = .empty, +/// Requests the UI thread took and has not answered yet (a command answering in a later frame), +/// under `lock`: `stop` answers them too. +outstanding: std.ArrayListUnmanaged(*Request) = .empty, /// Connections streaming events (`publish`), under `lock`. streams: std.ArrayListUnmanaged(*Stream) = .empty, @@ -135,14 +138,17 @@ pub const Request = struct { const topics = topicsOf(self.arena, self.target) catch return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); self.stream = .{ .topics = topics, .conn = self.conn }; - { + const added = added: { server.lock.lockUncancelable(server.io); defer server.lock.unlock(server.io); - server.streams.append(server.gpa, &self.stream) catch - return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + server.streams.append(server.gpa, &self.stream) catch break :added false; self.conn.streaming = true; - } + break :added true; + }; + // Answered outside the lock, which `answer` takes. + if (!added) return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); self.response = .{ .stream = true, .status = .ok }; + server.settle(self); self.done.set(server.io); } @@ -154,6 +160,7 @@ pub const Request = struct { .body = server.gpa.dupe(u8, body) catch null, }; if (self.response.body == null) self.response.status = .internal_server_error; + server.settle(self); self.done.set(server.io); } }; @@ -212,11 +219,23 @@ pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) usize { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); // Out of memory: left queued for the next frame, never dropped unanswered. + self.outstanding.ensureUnusedCapacity(self.gpa, self.pending.items.len) catch return self.streams.items.len; out.appendSlice(self.gpa, self.pending.items) catch return self.streams.items.len; + self.outstanding.appendSliceAssumeCapacity(self.pending.items); self.pending.clearRetainingCapacity(); return self.streams.items.len; } +/// `req` is answered: no longer `outstanding`. +fn settle(self: *Server, req: *Request) void { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + for (self.outstanding.items, 0..) |r, i| if (r == req) { + _ = self.outstanding.swapRemove(i); + return; + }; +} + /// Whether any stream wants the event `name`: the caller skips building a message nobody reads. pub fn wants(self: *Server, name: []const u8) bool { self.lock.lockUncancelable(self.io); @@ -252,17 +271,25 @@ pub fn publish(self: *Server, name: []const u8, json: []const u8) void { pub fn stop(self: *Server) void { self.stopping.store(true, .release); // The connections whose requests this answers ("fizzy is closing"): each writes it and ends. + // Queued, or taken and still to be answered (a command answering later). var answered: std.ArrayListUnmanaged(*Conn) = .empty; defer answered.deinit(self.gpa); + // Moved out whole, not copied: running out of memory here must not drop one unanswered, whose + // task would then wait for good. + var queued: std.ArrayListUnmanaged(*Request) = .empty; + defer queued.deinit(self.gpa); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(self.gpa); { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); - for (self.pending.items) |r| { - answered.append(self.gpa, r.conn) catch {}; - r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n"); - } - self.pending.clearRetainingCapacity(); + std.mem.swap(std.ArrayListUnmanaged(*Request), &queued, &self.pending); + std.mem.swap(std.ArrayListUnmanaged(*Request), &taken, &self.outstanding); } + for ([_][]const *Request{ queued.items, taken.items }) |owed| for (owed) |r| { + answered.append(self.gpa, r.conn) catch {}; + r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n"); + }; if (self.accept_task) |*t| t.cancel(self.io); self.accept_task = null; // No more are accepted, so `conns` holds still. Every other task, reading its next request or @@ -282,6 +309,7 @@ pub fn stop(self: *Server) void { } else |_| {} } self.pending.deinit(self.gpa); + self.outstanding.deinit(self.gpa); self.streams.deinit(self.gpa); } diff --git a/app/control/routes.zig b/app/control/routes.zig index 34c97ba6e..509cc7f9e 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -24,6 +24,8 @@ const Server = @import("Server.zig"); pub const Answer = struct { /// Stream the app's events to this connection instead (`GET /events`). stream: bool = false, + /// The command answers in a later frame (`Command.Call.later`): its waiter answers then. + later: bool = false, status: std.http.Status, /// Static: a string literal. content_type: []const u8 = "application/json", @@ -34,35 +36,89 @@ const zon_type = "application/zon"; /// Answer what waits on `server`. On the UI thread, once a frame, before plugins' `beginFrame`, so /// a command run here is seen by the frame that follows. -pub fn answerPending(server: *Server, relay: *Relay, host: *sdk.Host) void { +pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { var taken: std.ArrayListUnmanaged(*Server.Request) = .empty; defer taken.deinit(server.gpa); var streaming = server.take(&taken) > 0; for (taken.items) |req| { var arena: std.heap.ArenaAllocator = .init(server.gpa); defer arena.deinit(); - const a = handle(host, req.method, req.path(), req.content_type, req.body, arena.allocator()); + // Ready for a command that answers later; let go of when it answers now. + const waiter = server.gpa.create(Waiter) catch { + req.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + continue; + }; + waiter.* = .{ .server = server, .state = state, .host = host, .req = req, .zon = isZon(req.content_type) }; + const a = route(host, req.method, req.path(), req.content_type, req.body, arena.allocator(), waiter) catch Answer{ + .status = .internal_server_error, + .content_type = "text/plain", + .body = "out of memory\n", + }; + if (a.later) { + state.waiters.append(server.gpa, waiter) catch { + host.abandonCall(waiter.ticket); + req.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + server.gpa.destroy(waiter); + }; + continue; + } + server.gpa.destroy(waiter); if (a.stream) { req.answerStream(server); streaming = true; } else req.answer(server, a.status, a.content_type, a.body); } - relay.follow(host, server, streaming); + state.follow(host, server, streaming); } -/// Stop relaying and stop `server`. On the UI thread, at quit: after it, no event reaches the -/// stopped server. -pub fn stop(server: *Server, relay: *Relay, host: *sdk.Host) void { - relay.follow(host, server, false); +/// Stop relaying and stop `server`. On the UI thread, at quit or when the setting goes off: the +/// commands still to answer are let go of, their requests answered by `server.stop`, and no event +/// reaches the stopped server. +pub fn stop(server: *Server, state: *State, host: *sdk.Host) void { + for (state.waiters.items) |w| { + host.abandonCall(w.ticket); + server.gpa.destroy(w); + } + state.waiters.clearAndFree(server.gpa); + state.follow(host, server, false); server.stop(); } -/// The subscription that relays the app's events to the server's streams, owned beside the server -/// (`App.control_relay`). Its plugin is a pseudo-plugin, as fizzy's own commands have +/// A request whose command answers in a later frame: answered by the host's call to `done` +/// (`Host.finishCall`), or by `server.stop` if that comes first (`stop`). +const Waiter = struct { + server: *Server, + state: *State, + host: *sdk.Host, + req: *Server.Request, + zon: bool, + ticket: u32 = 0, + + fn done(ctx: *anyopaque, outcome: sdk.Host.CommandOutcome) void { + const self: *Waiter = @ptrCast(@alignCast(ctx)); + var arena: std.heap.ArenaAllocator = .init(self.server.gpa); + defer arena.deinit(); + const a = outcomeAnswer(arena.allocator(), self.zon, "", outcome) catch Answer{ + .status = .internal_server_error, + .content_type = "text/plain", + .body = "out of memory\n", + }; + self.req.answer(self.server, a.status, a.content_type, a.body); + for (self.state.waiters.items, 0..) |w, i| if (w == self) { + _ = self.state.waiters.swapRemove(i); + break; + }; + self.server.gpa.destroy(self); + } +}; + +/// What the routes keep between frames, owned beside the server (`App.control_state`): the requests +/// whose commands answer later, and the subscription that relays the app's events to the server's +/// streams. Its plugin is a pseudo-plugin, as fizzy's own commands have /// (`fizzy_plugin` in fizzy's `Keybinds.zig`): never in `host.plugins`, so no hook fires on it. It /// listens only while a stream is open, so with none an emitted event costs the bus its one atomic /// load, as before. -pub const Relay = struct { +pub const State = struct { plugin: sdk.Plugin = .{ .state = undefined, // the server, set when it starts listening .vtable = &relay_vtable, @@ -70,10 +126,12 @@ pub const Relay = struct { .display_name = "Control server", }, on: bool = false, + /// Requests whose commands answer in a later frame (`Waiter`). UI thread. + waiters: std.ArrayListUnmanaged(*Waiter) = .empty, const relay_vtable: sdk.Plugin.VTable = .{}; - fn follow(self: *Relay, host: *sdk.Host, server: *Server, on: bool) void { + fn follow(self: *State, host: *sdk.Host, server: *Server, on: bool) void { if (on == self.on) return; if (on) { self.plugin.state = server; @@ -105,14 +163,14 @@ fn relayEvent(state: *anyopaque, event: sdk.events.Event) void { /// Answer one request. Everything returned lives in `arena`. pub fn handle(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) Answer { - return route(host, method, path, content_type, body, arena) catch .{ + return route(host, method, path, content_type, body, arena, null) catch .{ .status = .internal_server_error, .content_type = "text/plain", .body = "out of memory\n", }; } -fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { +fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator, waiter: ?*Waiter) error{OutOfMemory}!Answer { const trimmed = std.mem.trimEnd(u8, path, "/"); if (trimmed.len == 0) { if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); @@ -151,14 +209,14 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})); return switch (method) { .GET, .HEAD => ok(arena, try describe(c, arena)), - .POST => call(host, c, content_type, body, arena), + .POST => call(host, c, content_type, body, arena, waiter), else => notAllowed(arena, "GET, POST"), }; } return failure(arena, .not_found, try std.fmt.allocPrint(arena, "nothing at {s}", .{path})); } -fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { +fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator, waiter: ?*Waiter) error{OutOfMemory}!Answer { const as_zon = isZon(content_type); const args = if (as_zon) body else args: { var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; @@ -169,7 +227,20 @@ fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: else => failure(arena, .bad_request, "the body is not JSON (send ZON as application/zon)"), }; }; - switch (host.callCommand(c.id, args, arena)) { + const sink: ?sdk.Host.CallSink = if (waiter) |w| .{ .ctx = w, .done = Waiter.done } else null; + switch (host.callCommandAwaiting(c.id, args, arena, sink)) { + .now => |outcome| return outcomeAnswer(arena, as_zon, c.id, outcome), + .later => |ticket| { + // With nobody to wait (`handle`), it has started: say so rather than hold an answer. + const w = waiter orelse return .{ .status = .accepted, .body = "null" }; + w.ticket = ticket; + return .{ .status = .ok, .later = true, .body = "" }; + }, + } +} + +fn outcomeAnswer(arena: std.mem.Allocator, as_zon: bool, id: []const u8, outcome: sdk.Host.CommandOutcome) error{OutOfMemory}!Answer { + switch (outcome) { .ok => |result| { if (as_zon) return .{ .status = .ok, .content_type = zon_type, .body = result orelse "null" }; const out = json.jsonFromZon(arena, result) catch |err| return switch (err) { @@ -179,7 +250,7 @@ fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: }; return .{ .status = .ok, .body = out }; }, - .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{c.id})), + .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})), .disabled => return failure(arena, .conflict, "the command is not enabled now"), .bad_args => |msg| return failure(arena, .bad_request, msg), .failed => |msg| return failure(arena, .internal_server_error, msg), diff --git a/docs/CONTROL.md b/docs/CONTROL.md index d39007aef..91dce1a39 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -74,7 +74,14 @@ Those about the request itself, refused before the app sees it, are plain text: | 503 | the control server is stopping (fizzy is quitting, or the setting went off) | A command runs on the UI thread at the start of the next frame, as a menu item or a keybind would, -so the frame after it shows what it did. A call costs one wake and returns within a frame. +so the frame after it shows what it did. A call costs one wake and returns within a frame, unless +the command answers later (`Command.Call.later`: a build, a reply from a language server): then the +request stays open until it does. Two of fizzy's own are like that, for scripts and tests: + +```sh +curl --unix-socket "$SOCK" localhost/commands/fizzy.settled -d '{"timeout_ms":5000}' # true once nothing is in flight +curl --unix-socket "$SOCK" localhost/commands/fizzy.screen # what is on screen: windows, widgets, rects +``` ## Events diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index ef2f78328..98a5be6dc 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -954,6 +954,15 @@ fn goToLine(state: *anyopaque, args: GoToLine.Args, call: *sdk.Command.Call) any (a list for an enum or bool, checked text otherwise — `sdk.Command.answer`). - **Failing.** Return `call.fail("…", .{})`. Your error's *name* cannot be read on fizzy's side (errors are numbered per compilation), so the message is how the reason gets there. +- **Answering later.** A command whose work outlives the frame (a language server's reply, a + build, a picture taken at the end of the next frame) calls `const ticket = call.later();`, keeps + the ticket, and returns from `runWith` with no result. Once it is done, on the UI thread, it calls + `host.finishCall(ticket, .{ .ok = result_zon })` (or `.failed = "why"`). A caller that can wait + (`host.callCommandAwaiting`, the control server, which holds its HTTP request open) hears the + answer then. One that cannot (a menu, a keybind, a tape, plain `callCommand`) has only started it: + it sees `.ok` with no result and goes on at once, and the answer is dropped. You may answer before + `runWith` returns (a cache hit); the caller then gets it at once. `host.callWanted(ticket)` says whether anyone still waits, so work nobody wants + can stop. If your plugin goes away owing answers, its waiters hear that it failed. - **`run` and `runWith`.** A command needs at least one; `registerCommand` refuses one with neither. Keep `run` alongside `runWith` only if invoking with no arguments should do something of its own (open your own dialog, say) rather than ask. diff --git a/sdk/src/Command.zig b/sdk/src/Command.zig index 756cc10e7..4b7024fdf 100644 --- a/sdk/src/Command.zig +++ b/sdk/src/Command.zig @@ -111,6 +111,26 @@ pub const Call = struct { /// stroke by stroke): for how long, in ms as written (`core.motion`). A recorded session /// gives its step that long, so a replay goes from one to the next without a pause. showing_ms: u32 = 0, + /// This call's ticket, set by the host: what `later` hands back. + ticket: u32 = 0, + /// Set by `later`: the answer comes in a later frame, through `Host.finishCall`. + deferred: bool = false, + /// Set when a deferred call was answered as failed before `runWith` returned (`Host.finishCall` + /// on the running call): `message` says why. + failed: bool = false, + + /// Answer in a later frame instead of now: for a command whose work outlives the frame (a + /// language server's reply, a build, a picture taken at the end of the next frame). Keep the + /// ticket, return from `runWith` without a result, and call `Host.finishCall(ticket, …)` once + /// it is done, on the UI thread (a worker posts back to the UI thread first). Answering before + /// `runWith` returns (a cache hit) is fine: the caller gets it now. A caller that can wait + /// (`Host.callCommandAwaiting`, the control server) hears the answer then; one that cannot (a + /// menu, a keybind, a tape) has only started it, and the answer is dropped. + /// `Host.callWanted(ticket)` says whether anyone still waits: stop when nobody does. + pub fn later(self: *Call) u32 { + self.deferred = true; + return self.ticket; + } /// Return `value` (anything `std.zon.stringify` can write) as the command's result. pub fn returns(self: *Call, value: anytype) error{OutOfMemory}!void { diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 0658b4f91..b8b92a033 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -314,6 +314,12 @@ step_authors: std.AutoHashMapUnmanaged(u64, DocAuthors) = .empty, /// tests assert it stays 0. unattributed_steps: u64 = 0, next_claim_id: u32 = 1, +/// Commands answering in a later frame (`Command.Call.later`), until `finishCall`. UI thread. +calls_pending: std.ArrayListUnmanaged(PendingCall) = .empty, +next_call_ticket: u32 = 1, +/// The `runWith` call running now, innermost (`callResolved`): `finishCall` on its ticket answers +/// it before it returns. +running_call: ?*Command.Call = null, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, /// Who listens for which events (`subscribe`), and what plugins say they announce @@ -389,6 +395,7 @@ pub fn deinit(self: *Host) void { self.command_slots.deinit(self.allocator); self.language_support.deinit(self.allocator); self.subscriptions.deinit(self.allocator); + self.calls_pending.deinit(self.allocator); self.event_declarations.deinit(self.allocator); for (self.event_queue.items) |e| freeQueued(self.allocator, e); self.event_queue.deinit(self.allocator); @@ -952,6 +959,7 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { removeOwned(accounts.Provider, &self.account_providers, plugin); removeOwned(NativeMenuItem, &self.native_menu_items, plugin); self.removeCommandsOf(plugin); + self.failCallsOf(plugin); { var i: usize = 0; while (i < self.claims.items.len) { @@ -1004,6 +1012,7 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { pub fn withdrawPlugin(self: *Host, plugin: *Plugin) void { removeOwned(ServiceEntry, &self.services, plugin); self.removeCommandsOf(plugin); + self.failCallsOf(plugin); removeOwned(LanguageSupport, &self.language_support, plugin); self.removeEventsOf(plugin); } @@ -2021,6 +2030,31 @@ pub fn runCommand(self: *Host, id: []const u8) !void { } } +/// A command answering in a later frame, and who waits for it. +pub const PendingCall = struct { + ticket: u32, + owner: *Plugin, + /// Who waits. Kept only while someone does (a call nobody waits for keeps nothing). + sink: CallSink, +}; + +/// Who hears a later answer (`callCommandAwaiting`): `done` is called once, on the UI thread, with +/// what came of it. What `outcome` holds is borrowed for the call. +pub const CallSink = struct { + ctx: *anyopaque, + done: *const fn (ctx: *anyopaque, outcome: CommandOutcome) void, + /// The plugin whose image holds `done` and `ctx`, when a plugin waits: its sinks are let go of + /// when it goes, never called into an image that is gone. Null for the app. + owner: ?*Plugin = null, +}; + +/// What `callCommandAwaiting` answers: what came of the command now, or the ticket of its answer +/// to come, which the sink hears. +pub const AwaitedOutcome = union(enum) { + now: CommandOutcome, + later: u32, +}; + /// What came of `callCommand`. Results and messages live in the arena the caller passed. pub const CommandOutcome = union(enum) { /// It ran. Its result as ZON, or null when it returns nothing. @@ -2128,10 +2162,21 @@ fn freeQueued(allocator: std.mem.Allocator, e: events.Queued) void { } pub fn callCommand(self: *Host, id: []const u8, args: []const u8, results: std.mem.Allocator) CommandOutcome { + return switch (self.callCommandAwaiting(id, args, results, null)) { + .now => |out| out, + // Started; nobody here waits for the answer. + .later => .{ .ok = null }, + }; +} + +/// `callCommand` for a caller that can wait: a command answering in a later frame +/// (`Command.Call.later`) gives its ticket, and `sink` hears the answer once it comes +/// (`finishCall`). A null sink drops it, as `callCommand` does. +pub fn callCommandAwaiting(self: *Host, id: []const u8, args: []const u8, results: std.mem.Allocator, sink: ?CallSink) AwaitedOutcome { // A copy, not the registry's entry: the body may register, replace or unregister commands. - const c = (self.command(id) orelse return .unknown).*; - if (self.inProgress(id)) return .{ .failed = "it called itself; an override reaches what it replaced with callReplaced" }; - return self.callResolved(c, args, results, false); + const c = (self.command(id) orelse return .{ .now = .unknown }).*; + if (self.inProgress(id)) return .{ .now = .{ .failed = "it called itself; an override reaches what it replaced with callReplaced" } }; + return self.callResolved(c, args, results, false, sink); } /// From inside `caller`'s override of `id` (`Command.replaces`): call what it replaced, the next @@ -2142,7 +2187,10 @@ pub fn callReplaced(self: *Host, caller: *Plugin, id: []const u8, args: []const const slot = self.command_slots.get(id) orelse return .unknown; const i = slot.overrideOf(caller) orelse return .unknown; const below = if (i > 0) slot.overrides.items[i - 1] else if (slot.bases.items.len > 0) slot.bases.items[0] else return .unknown; - return self.callResolved(below, args, results, true); + return switch (self.callResolved(below, args, results, true, null)) { + .now => |out| out, + .later => .{ .ok = null }, + }; } /// Whether an override of `id` is running, or what one reached through `callReplaced`: run by id @@ -2153,31 +2201,105 @@ fn inProgress(self: *Host, id: []const u8) bool { } /// `replaced`: reached through `callReplaced`, so running inside an override (`inProgress`). -fn callResolved(self: *Host, c: Command, args: []const u8, results: std.mem.Allocator, replaced: bool) CommandOutcome { - const owner = c.owner orelse return .unknown; +fn callResolved(self: *Host, c: Command, args: []const u8, results: std.mem.Allocator, replaced: bool, sink: ?CallSink) AwaitedOutcome { + const owner = c.owner orelse return .{ .now = .unknown }; if (c.isEnabled) |enabled| { - if (!enabled(owner.state)) return .disabled; + if (!enabled(owner.state)) return .{ .now = .disabled }; } const prof = core.profile.begin(owner.id, c.id); defer prof.end(); const run_with = c.runWith orelse { const trimmed = std.mem.trim(u8, args, " \t\r\n"); - if (trimmed.len > 0 and !std.mem.eql(u8, trimmed, ".{}")) return .{ .bad_args = "this command takes no arguments" }; + if (trimmed.len > 0 and !std.mem.eql(u8, trimmed, ".{}")) return .{ .now = .{ .bad_args = "this command takes no arguments" } }; const outer = self.running(c, replaced); defer self.ran(c, outer, replaced); - c.run.?(owner.state) catch |err| return .{ .failed = owner.errorName(err) }; - return .{ .ok = null }; + c.run.?(owner.state) catch |err| return .{ .now = .{ .failed = owner.errorName(err) } }; + return .{ .now = .{ .ok = null } }; }; self.last_showing_ms = 0; - var call: Command.Call = .{ .args = args, .arena = results }; + // Its own ticket, taken before it runs: a command it calls that defers takes the next. + const ticket = self.next_call_ticket; + self.next_call_ticket +%= 1; + if (self.next_call_ticket == 0) self.next_call_ticket = 1; + var call: Command.Call = .{ .args = args, .arena = results, .ticket = ticket }; const outer = self.running(c, replaced); defer self.ran(c, outer, replaced); + const outer_call = self.running_call; + self.running_call = &call; + defer self.running_call = outer_call; run_with(owner.state, &call) catch |err| { const msg = call.message orelse owner.errorName(err); - return if (call.bad_args) .{ .bad_args = msg } else .{ .failed = msg }; + return .{ .now = if (call.bad_args) .{ .bad_args = msg } else .{ .failed = msg } }; }; self.last_showing_ms = call.showing_ms; - return .{ .ok = call.result }; + if (call.failed) return .{ .now = if (call.bad_args) .{ .bad_args = call.message orelse "bad arguments" } else .{ .failed = call.message orelse "failed" } }; + if (!call.deferred) return .{ .now = .{ .ok = call.result } }; + // Nobody waits: started, with nothing kept for an answer nobody hears (`callWanted` is false). + const s = sink orelse return .{ .later = ticket }; + // Out of memory: answered now as failed, rather than owed to a waiter that never hears. + self.calls_pending.append(self.allocator, .{ .ticket = ticket, .owner = owner, .sink = s }) catch + return .{ .now = .{ .failed = "out of memory" } }; + return .{ .later = ticket }; +} + +/// Answer the command whose ticket is `ticket` (`Command.Call.later`), on the UI thread: `.ok` +/// with its result as ZON (or null), `.failed` or `.bad_args` with why. Whoever waits hears it +/// now; nobody, and it is dropped. A ticket already answered or unknown is ignored. +pub fn finishCall(self: *Host, ticket: u32, outcome: CommandOutcome) void { + // The call still running (`runWith` answering what it deferred, before returning): its caller + // gets it now, copied, since `outcome` is the command's to free. + if (self.running_call) |call| if (call.ticket == ticket and call.deferred) { + call.deferred = false; + switch (outcome) { + .ok => |r| call.result = if (r) |text| call.arena.dupe(u8, text) catch null else null, + .bad_args, .failed => |m| { + call.failed = true; + call.bad_args = outcome == .bad_args; + call.message = call.arena.dupe(u8, m) catch "failed"; + }, + .unknown, .disabled => { + call.failed = true; + call.message = @tagName(outcome); + }, + } + return; + }; + for (self.calls_pending.items, 0..) |p, i| { + if (p.ticket != ticket) continue; + _ = self.calls_pending.swapRemove(i); + p.sink.done(p.sink.ctx, outcome); + return; + } +} + +/// Whether anyone still waits for `ticket`'s answer: a command can stop work nobody wants. +pub fn callWanted(self: *const Host, ticket: u32) bool { + for (self.calls_pending.items) |p| if (p.ticket == ticket) return true; + return false; +} + +/// Stop waiting for `ticket`'s answer (the caller went away): nothing is kept for it, and the +/// command, asking `callWanted`, can stop. +pub fn abandonCall(self: *Host, ticket: u32) void { + for (self.calls_pending.items, 0..) |p, i| if (p.ticket == ticket) { + _ = self.calls_pending.swapRemove(i); + return; + }; +} + +/// A plugin going away: the calls it still owes are answered as failed, so nobody waits for good, +/// and the waits it holds are let go of, so nothing calls into its image once it is gone. +fn failCallsOf(self: *Host, plugin: *Plugin) void { + var i: usize = 0; + while (i < self.calls_pending.items.len) { + const p = self.calls_pending.items[i]; + if (p.owner != plugin and p.sink.owner != plugin) { + i += 1; + continue; + } + _ = self.calls_pending.swapRemove(i); + if (p.sink.owner != plugin) p.sink.done(p.sink.ctx, .{ .failed = "the plugin that was answering went away" }); + } } // ---- language support ------------------------------------------------------ @@ -3574,3 +3696,134 @@ test "an agent's edit says whose in the activity; the person's stays plain" { host.endAct(outer); try std.testing.expectEqualStrings("Claude · Draw", host.activity.items[1].title); } + +const LaterFixture = struct { + ticket: u32 = 0, + fn start(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *LaterFixture = @ptrCast(@alignCast(state)); + self.ticket = call.later(); + } + const Heard = struct { + count: u32 = 0, + result: ?[]const u8 = null, + failed: ?[]const u8 = null, + fn done(ctx: *anyopaque, outcome: CommandOutcome) void { + const self: *Heard = @ptrCast(@alignCast(ctx)); + self.count += 1; + switch (outcome) { + .ok => |r| self.result = r, + .failed => |m| self.failed = m, + else => {}, + } + } + }; +}; + +test "a command answering later: its waiter hears it once, a caller that cannot wait drops it" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var fixture: LaterFixture = .{}; + var plugin = Plugin{ .state = &fixture, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = LaterFixture.start }); + var results: std.heap.ArenaAllocator = .init(testing.allocator); + defer results.deinit(); + + // A caller that waits gets the ticket, and hears the answer when it comes, once. + var heard: LaterFixture.Heard = .{}; + const out = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &heard, .done = LaterFixture.Heard.done }); + try testing.expectEqual(fixture.ticket, out.later); + try testing.expect(host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = ".{ .n = 1 }" }); + try testing.expectEqual(@as(u32, 1), heard.count); + try testing.expectEqualStrings(".{ .n = 1 }", heard.result.?); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 1), heard.count); + try testing.expect(!host.callWanted(fixture.ticket)); + + // A caller that cannot wait has started it; its answer is dropped. + try testing.expectEqual(@as(?[]const u8, null), host.callCommand("t.slow", "", results.allocator()).ok); + try testing.expect(!host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); + + // Abandoned: the answer goes nowhere. + var gone: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &gone, .done = LaterFixture.Heard.done }); + host.abandonCall(fixture.ticket); + try testing.expect(!host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 0), gone.count); + + // The plugin goes away owing an answer: its waiter hears that it failed. + var owed: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &owed, .done = LaterFixture.Heard.done }); + host.withdrawPlugin(&plugin); + try testing.expectEqual(@as(u32, 1), owed.count); + try testing.expect(owed.failed != null); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); +} + +const NestedFixture = struct { + host: *Host, + inner_ticket: u32 = 0, + outer_ticket: u32 = 0, + fn inner(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + self.inner_ticket = call.later(); + } + fn outer(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + // A command that, while it runs, calls another that defers, then defers itself. + _ = self.host.callCommand("t.inner", "", call.arena); + self.outer_ticket = call.later(); + } + fn cached(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + // Deferred, then answered before returning: a cache hit. + const t = call.later(); + self.host.finishCall(t, .{ .ok = ".{ .cached = true }" }); + } +}; + +test "later answers: nested calls keep their own tickets, an early answer is the caller's now, a gone waiter is never called" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var fixture: NestedFixture = .{ .host = &host }; + var plugin = Plugin{ .state = &fixture, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.inner", .owner = &plugin, .title = "Inner", .runWith = NestedFixture.inner }); + try host.registerCommand(.{ .id = "t.outer", .owner = &plugin, .title = "Outer", .runWith = NestedFixture.outer }); + try host.registerCommand(.{ .id = "t.cached", .owner = &plugin, .title = "Cached", .runWith = NestedFixture.cached }); + var results: std.heap.ArenaAllocator = .init(testing.allocator); + defer results.deinit(); + + var heard: LaterFixture.Heard = .{}; + const sink: CallSink = .{ .ctx = &heard, .done = LaterFixture.Heard.done }; + const out = host.callCommandAwaiting("t.outer", "", results.allocator(), sink); + try testing.expectEqual(fixture.outer_ticket, out.later); + try testing.expect(fixture.inner_ticket != fixture.outer_ticket); + // The inner one, nobody waiting, keeps nothing; the outer one is the waiter's alone. + try testing.expect(!host.callWanted(fixture.inner_ticket)); + host.finishCall(fixture.inner_ticket, .{ .ok = ".{ .inner = true }" }); + try testing.expectEqual(@as(u32, 0), heard.count); + host.finishCall(fixture.outer_ticket, .{ .ok = ".{ .outer = true }" }); + try testing.expectEqualStrings(".{ .outer = true }", heard.result.?); + + // Answered before it returned: the caller has it now, and nothing is left pending. + const now = host.callCommandAwaiting("t.cached", "", results.allocator(), sink); + try testing.expectEqualStrings(".{ .cached = true }", now.now.ok.?); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); + + // A plugin waiting on another's command goes away: its sink is let go of, never called. + var waiter_vtable = Plugin.VTable{}; + _ = &waiter_vtable; + var waiter_state: u8 = 0; + var waiter = Plugin{ .state = &waiter_state, .vtable = &waiter_vtable, .id = "w", .display_name = "W" }; + var unheard: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.inner", "", results.allocator(), .{ .ctx = &unheard, .done = LaterFixture.Heard.done, .owner = &waiter }); + host.withdrawPlugin(&waiter); + try testing.expect(!host.callWanted(fixture.inner_ticket)); + host.finishCall(fixture.inner_ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 0), unheard.count); +} diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 5ed391deb..a9aac1872 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xcd68cc1a32bd51c9; +pub const recorded_sdk_shape_fingerprint: u64 = 0x70dba3df7455c34f; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 2fe1d91e7..584701d37 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -141,6 +141,10 @@ share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, plugin_reloads: @import("PluginReloads.zig") = .{}, /// The last minute of input, frame by frame, for a capture (`captures`, `fizzy.capture`). black_box: @import("app").automation.BlackBox = .{}, +/// `fizzy.screen` calls waiting for their snapshot (`answerLaterCommands`). +later_screens: std.ArrayListUnmanaged(struct { ticket: u32, snapshot: sdk.services.automation.Api.SnapshotTicket, frames: u32 = 0 }) = .empty, +/// `fizzy.settled` calls waiting for nothing to be in flight, or their deadline. +later_settles: std.ArrayListUnmanaged(struct { ticket: u32, deadline_ns: i96 }) = .empty, /// The control server would not start (`syncControlServer`): not retried until the setting goes /// off and on again. control_failed: bool = false, @@ -4262,8 +4266,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.host.deliverEvents(); // Requests from the control server, so the frame that follows sees what they ran. if (comptime builtin.target.cpu.arch != .wasm32) { - if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.control_relay, &editor.app.host); + if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.control_state, &editor.app.host); } + // What fizzy's own commands answer a frame or more later (`fizzy.screen`, `fizzy.settled`). + editor.answerLaterCommands(); // A seek replays a tape's commands where nobody watches: not shown as activity. editor.app.host.activity_muted = editor.demo.player.state == .seeking; editor.showClaimed(); @@ -5475,6 +5481,57 @@ pub const Placement = struct { beside: bool = false, }; +/// Answer fizzy's own commands that answer a frame or more later (`fizzy.screen`, `fizzy.settled`), +/// once what they wait for has come; drop those nobody waits for any more. Once a frame. +fn answerLaterCommands(editor: *Editor) void { + const host = &editor.app.host; + const api = editor.demo.service.api; + var i: usize = 0; + while (i < editor.later_screens.items.len) { + const w = &editor.later_screens.items[i]; + if (!host.callWanted(w.ticket)) { + _ = editor.later_screens.swapRemove(i); + continue; + } + if (api.snapshotText(w.snapshot)) |text| { + const ticket = w.ticket; + _ = editor.later_screens.swapRemove(i); + host.finishCall(ticket, .{ .ok = text }); + continue; + } + // Not taken yet. Asked again, should the last asking have come to nothing; a snapshot that never comes is given up on, not waited + // for frame after frame. + w.frames += 1; + if (w.frames > 120) { + const ticket = w.ticket; + _ = editor.later_screens.swapRemove(i); + host.finishCall(ticket, .{ .failed = "no snapshot came in 120 frames" }); + continue; + } + w.snapshot = api.snapshot(); + // And a frame to take it in: asking again does not wake a sleeping app by itself. + host.refresh(); + i += 1; + } + if (editor.later_settles.items.len == 0) return; + const settled = api.settled(); + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + i = 0; + while (i < editor.later_settles.items.len) { + const w = editor.later_settles.items[i]; + if (!host.callWanted(w.ticket)) { + _ = editor.later_settles.swapRemove(i); + continue; + } + if (!settled and now < w.deadline_ns) { + i += 1; + continue; + } + _ = editor.later_settles.swapRemove(i); + host.finishCall(w.ticket, .{ .ok = if (settled) "true" else "false" }); + } +} + /// Run the control server while `control_server` is on, and stop it when it goes off. Once a frame; /// a no-op unless the setting changed. Not in tests, whose editors share no config folder worth a /// socket. @@ -5487,7 +5544,7 @@ fn syncControlServer(editor: *Editor) void { if (want == (editor.app.control != null)) return; if (!want) { if (editor.app.control) |*c| { - control.routes.stop(c, &editor.app.control_relay, &editor.app.host); + control.routes.stop(c, &editor.app.control_state, &editor.app.host); editor.app.gpa.free(c.socket_path); } editor.app.control = null; @@ -6536,11 +6593,13 @@ pub fn deinit(editor: *Editor) !void { // command would reach goes. if (comptime builtin.target.cpu.arch != .wasm32) { if (editor.app.control) |*c| { - @import("app").control.routes.stop(c, &editor.app.control_relay, &editor.app.host); + @import("app").control.routes.stop(c, &editor.app.control_state, &editor.app.host); editor.app.gpa.free(c.socket_path); editor.app.control = null; } } + editor.later_screens.deinit(editor.app.gpa); + editor.later_settles.deinit(editor.app.gpa); if (editor.document_watcher) |*w| { w.stop(); editor.document_watcher = null; diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 7b54f2fcb..9d66f6dfc 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -129,8 +129,10 @@ const FizzyCommand = struct { title: []const u8, /// dvui keybind name this action's default key comes from, or null when it has no default. bind: ?[]const u8, - run: *const fn (state: *anyopaque) anyerror!void, + /// Null for a command only a program calls (`runWith`, `visibility = .internal`). + run: ?*const fn (state: *anyopaque) anyerror!void = null, isEnabled: ?*const fn (state: *anyopaque) bool = null, + visibility: sdk.Command.Visibility = .external, /// What `runWith` takes (`sdk.Command.Params`). `run` stays what a menu row or a key does — /// with no arguments, so Open Files still asks with its dialog — and `runWith` is the same /// action told exactly what to act on: by a keybind with arguments, a tape, an agent. @@ -182,6 +184,10 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.tracks.export", .title = "Tracks: Export as a Tape…", .bind = null, .run = cmdTracks, .params = TracksExport.params, .runWith = TracksExport.bind(cmdTracksExport), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, + // For programs, answered a frame or more later (`Command.Call.later`): a test or a script on the + // control server reads the screen as text, and waits for a load to land before it looks. + .{ .id = "fizzy.screen", .title = "What Is on Screen", .bind = null, .params = ScreenArgs.params, .runWith = ScreenArgs.bind(cmdScreen), .visibility = .internal }, + .{ .id = "fizzy.settled", .title = "Wait Until Settled", .bind = null, .params = SettledArgs.params, .runWith = SettledArgs.bind(cmdSettled), .visibility = .internal }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; /// Demo automation (`Editor.Demo`): one command per bundled demo, and the transport for whichever @@ -638,6 +644,30 @@ fn cmdCaptureWith(state: *anyopaque, args: CaptureArgs.Args, call: *sdk.Command. try call.returns(written); } +const ScreenArgs = sdk.Command.Params(struct {}); + +/// What is on screen, as ZON (`automation` service `snapshot`): each window, then each widget's +/// role, name, tag, rect and parent. Taken over the next frame, so answered then +/// (`Editor.answerLaterCommands`). +fn cmdScreen(state: *anyopaque, _: ScreenArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + try editor.later_screens.append(editor.app.gpa, .{ .ticket = call.later(), .snapshot = editor.demo.service.api.snapshot() }); +} + +const SettledArgs = sdk.Command.Params(struct { + timeout_ms: sdk.Command.Arg(u32, .{ .description = "How long to wait for nothing to be in flight before answering false.", .min = 0, .max = 600_000 }) = .init(5000), +}); + +/// `true` once nothing is in flight (no tape playing, nothing loading, nothing animating, as the +/// `automation` service sees it), at once when that is so now; `false` when `timeout_ms` passes +/// first. It asks for no frames of its own, which would keep the app from ever going quiet. +fn cmdSettled(state: *anyopaque, args: SettledArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + if (editor.demo.service.api.settled()) return call.returns(true); + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + try editor.later_settles.append(editor.app.gpa, .{ .ticket = call.later(), .deadline_ns = now + @as(i96, args.timeout_ms) * std.time.ns_per_ms }); +} + const TracksArgs = sdk.Command.Params(struct {}); const TracksRecord = sdk.Command.Params(struct { on: sdk.Command.Arg(bool, .{ .description = "Record documents' tracks, or stop. Open documents start a track now." }), @@ -789,6 +819,7 @@ pub fn registerCommands(editor: *Editor) !void { .icon = c.icon, .params = c.params, .runWith = c.runWith, + .visibility = c.visibility, }); } } diff --git a/tests/integration.zig b/tests/integration.zig index 3ca3196a6..f4e20dc7e 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6961,6 +6961,110 @@ test "control: /openapi.json describes each command's body and the declared even try std.testing.expectEqualStrings("", routes.handle(&host, .HEAD, "/openapi.json", null, "", arena).body); } +/// Sends one request and reads the answer by its `Content-Length`, as an HTTP client does. +const ControlOneShot = struct { + path: []const u8, + request: []const u8, + reply: [2048]u8 = undefined, + len: usize = 0, + fn run(self: *ControlOneShot) void { + const io = std.testing.io; + const addr = std.Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(io) catch return; + defer stream.close(io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(io, &wbuf); + w.interface.writeAll(self.request) catch return; + w.interface.flush() catch return; + var rbuf: [256]u8 = undefined; + var r = stream.reader(io, &rbuf); + while (self.len < self.reply.len) { + const n = r.interface.readSliceShort(self.reply[self.len..][0..1]) catch return; + if (n == 0) return; + self.len += 1; + if (std.mem.endsWith(u8, self.reply[0..self.len], "\r\n\r\n")) break; + } + const key = "content-length: "; + const i = std.ascii.indexOfIgnoreCase(self.reply[0..self.len], key) orelse return; + const end = std.mem.indexOfPos(u8, self.reply[0..self.len], i, "\r\n") orelse return; + const want = std.fmt.parseInt(usize, self.reply[i + key.len .. end], 10) catch return; + r.interface.readSliceAll(self.reply[self.len..][0..want]) catch return; + self.len += want; + } +}; + +const SlowCommand = struct { + ticket: u32 = 0, + fn start(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { + const self: *SlowCommand = @ptrCast(@alignCast(state)); + self.ticket = call.later(); + } +}; + +test "control: a command that answers in a later frame holds its request open until it does" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + const control = @import("app").control; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var slow: SlowCommand = .{}; + var plugin = sdk.Plugin{ .state = &slow, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = SlowCommand.start }); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + const Wake = struct { + fn none() void {} + }; + var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; + try server.start(); + var state: control.routes.State = .{}; + errdefer control.routes.stop(&server, &state, &host); + + const post = "POST /commands/t.slow HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + var client: ControlOneShot = .{ .path = path, .request = post }; + const thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&client}); + // Frames, until the command has started and its request waits on it. + var frames: usize = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + // More frames: still nothing to answer. + for (0..5) |_| control.routes.answerPending(&server, &state, &host); + try std.testing.expectEqual(@as(usize, 0), client.len); + // Done, in a later frame: the request is answered with what it returned. + host.finishCall(slow.ticket, .{ .ok = ".{ .done = true }" }); + thread.join(); + try std.testing.expectEqual(@as(usize, 0), state.waiters.items.len); + const reply = client.reply[0..client.len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\n{\"done\":true}")); + + // One still waiting when the server stops: answered as stopping, its call let go of. + var late: ControlOneShot = .{ .path = path, .request = post }; + const late_thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&late}); + frames = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + const ticket = slow.ticket; + control.routes.stop(&server, &state, &host); + late_thread.join(); + try std.testing.expect(std.mem.startsWith(u8, late.reply[0..late.len], "HTTP/1.1 503 ")); + try std.testing.expect(!host.callWanted(ticket)); + // Its answer, when it comes, goes nowhere. + host.finishCall(ticket, .{ .ok = null }); +} + const ControlStreamClient = struct { path: []const u8, reply: [4096]u8 = undefined, @@ -6999,7 +7103,7 @@ test "control: the app's events reach a stream, and the bus listens only while o }; var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; try server.start(); - var relay: control.routes.Relay = .{}; + var relay: control.routes.State = .{}; try std.testing.expect(!host.listening.load(.acquire)); var client: ControlStreamClient = .{ .path = path }; From 46874b57a263eced15680b4cfd5a9569cda33eb4 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:00:59 -0500 Subject: [PATCH 24/31] ci: retry a dependency fetch that failed on DNS or a server error (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `scripts/ci-retry.sh` now retries a dependency fetch whose DNS lookup failed. On #549 (Zig 0.17), `Tests (Linux)` failed twice, about 15 minutes apart, fetching codeberg's `translate-c` (a `git+https` dependency under dvui): ``` error: unable to discover remote git server capabilities: NameServerFailure ``` The script matched only `TemporaryNameServerFailure`, so it printed "not a fetch failure — not retrying" and failed on the first attempt. Codeberg resolved fine from a laptop at the same time, and other runs with a warm cache never fetched it. The pattern is now `NameServerFailure`, which covers both, plus the git fetch's own wording, `unable to discover remote git server capabilities`. It also retries a server error (`bad HTTP response code: '5xx`): an SDL fetch on #549 failed with `'500 Internal Server Error'` and was not retried. A 4xx, a failing test or a compile error still fails on its first attempt. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] `bash -n scripts/ci-retry.sh` passes. - [x] The new patterns match the exact lines from both failed logs, and a `404` does not match. - [ ] Not seen retrying in CI: that needs the DNS failure to happen again. ## Follow-ups - `zig/0.17` needs this too once `main` is merged forward into it; #549's runs go through that branch's copy of the script. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- scripts/ci-retry.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci-retry.sh b/scripts/ci-retry.sh index 08287cb14..78c991c4a 100755 --- a/scripts/ci-retry.sh +++ b/scripts/ci-retry.sh @@ -23,7 +23,7 @@ while :; do status="${PIPESTATUS[0]}" [ "$status" -eq 0 ] && exit 0 - if ! grep -qE 'HttpConnectionClosing|invalid HTTP response|ConnectionResetByPeer|ConnectionTimedOut|TemporaryNameServerFailure|unable to fetch|TlsInitializationFailed|UnexpectedEndOfStream' "$log"; then + if ! grep -qE 'HttpConnectionClosing|invalid HTTP response|ConnectionResetByPeer|ConnectionTimedOut|NameServerFailure|unable to discover remote git server capabilities|unable to fetch|bad HTTP response code: .5|TlsInitializationFailed|UnexpectedEndOfStream' "$log"; then echo "::notice::not a fetch failure — not retrying" exit "$status" fi From de25ee5bf004bd475d07952c3a89cf2a928d917f Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:16:52 -0500 Subject: [PATCH 25/31] app: MCP on the control socket (#541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5b). Follows fizzyedit/fizzy#538 (merged), with `main` merged in. ## What changes `POST /mcp` on the control socket speaks the Model Context Protocol's Streamable HTTP transport: one JSON-RPC message per request, answered as JSON. An agent reaches the same commands curl does. - **The tools are the commands.** - `tools/list` reads the registry (the runnable first of each id), each tool with a `title`, a description naming its plugin, and an `inputSchema`. - The `inputSchema` is the body schema `/openapi.json` gives the command, now one function both use (`openapi.bodySchema`): declared params exactly; any object for a hand-written `runWith`; nothing for a `run`-only command. - **`tools/call`** runs the command as `POST /commands/{id}` would: the same JSON→ZON edge, with enum strings becoming tags. It goes through `callCommandAwaiting`, so a command that answers later (#538) answers its call then. The JSON-RPC id is kept on the waiter. - **Results and errors:** - A result is `{content:[{type:"text", text:}], isError}`. - A command that fails, refuses its arguments or isn't enabled is a tool result with `isError: true`. - An unknown tool is `-32602`, an unknown method `-32601`, bad JSON `-32700`, and a batch `-32600` (batching left the protocol in 2025-06-18). - **`initialize`** answers protocol `2025-06-18`, or `2025-03-26` when the client asks for it, with the `tools` capability and instructions that point at `fizzy.screen` and `fizzy.settled`. `ping` is answered, and notifications get `202 Accepted`. - **Not yet:** a server-sent stream (`GET /mcp` answers 405), `Mcp-Session-Id`, resources and prompts. - **Reaching it:** MCP clients that can only dial a URL or speak stdio need a proxy for now. That's step 5c (`fizzy-mcp` shrinking to a stdio proxy onto this socket) and step 6 (loopback TCP). - `/mcp` is listed in `/openapi.json` and documented in `docs/CONTROL.md`. ## Review An independent review pass found nothing that would make the TypeScript or Python SDK clients, or the Inspector, fail against `/mcp`. Fixed from its findings: - **`instructions`:** it named fizzy-only commands from framework code (`app/`), so an app built on fizzy would be told about tools it doesn't have, and it claimed tools run "from the palette". Now `fizzy.screen` and `fizzy.settled` are named only when registered. - **Client-sent responses** are taken with 202, as the transport says, rather than refused. - **Ids** echo exactly (numbers kept as written), and ids that aren't strings or numbers get `-32600`. - **Non-UTF-8 results** become `isError` results instead of a broken body. - **`arguments: null`** counts as none. Left open: - **Batches:** 2025-03-26 asked for batch support, which 2025-06-18 dropped. No client sends them, so it's a deliberate gap, noted in the code. - **Dotted tool names in Claude Code:** dots are allowed by the spec, but the Anthropic API's tool names allow none. Whether Claude Code maps them gets checked in 5c, through the proxy, before this is relied on. ## SDK impact - [x] None (beyond #538's, below it). ## Verified - [x] macOS: - **Integration test, without a socket:** `initialize` negotiates the asked-for version; the notification gets 202; `tools/list` gives the tools with typed input schemas; `tools/call` returns the result, refused arguments give `isError: true`, and an unknown method gives `-32601`. - **Integration test, over a real socket:** a tool whose command answers later holds the HTTP request open, then answers with the JSON-RPC result carrying the original id. - **Live, with curl against fizzy:** `initialize`, then `notifications/initialized` (202), then `tools/list` (54 tools, `text.insert`'s schema typed), then `tools/call` on `fizzy.settled` (`true`), `fizzy.screen` (answered a frame later), `text.goToLine` (its result); plus `-32602` for an unknown tool, `-32601` for `resources/list`, and `-32700` for garbage. - **Gates:** fmt, build, `test` (590/591, the skip is pre-existing), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: the x86_64 cross-compile links. - [ ] Linux: CI - [x] Web: `check-web`. ## Follow-ups - **5c (fizzyedit/agent):** its waiting tools (`run`, `act`, `build`, `profile`, `tape`, `capture`) as `agent.*` commands answering later, sessions with claims and tapes, and `fizzy-mcp` as a stdio⇄socket proxy, so Claude Code and the chat plugin use this endpoint. - Notifications on a `GET /mcp` stream (`notifications/tools/list_changed` when plugins load), from the same event relay `/events` uses. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/control/mcp.zig | 186 ++++++++++++++++++++++++++++++++++++++++ app/control/openapi.zig | 60 ++++++++----- app/control/routes.zig | 44 ++++++++-- docs/CONTROL.md | 17 ++++ tests/integration.zig | 76 ++++++++++++++++ 5 files changed, 352 insertions(+), 31 deletions(-) create mode 100644 app/control/mcp.zig diff --git a/app/control/mcp.zig b/app/control/mcp.zig new file mode 100644 index 000000000..229c255bf --- /dev/null +++ b/app/control/mcp.zig @@ -0,0 +1,186 @@ +//! MCP on the control socket: `POST /mcp` speaks the Model Context Protocol's Streamable HTTP +//! transport, a JSON-RPC message per request answered as JSON, so an agent reaches the same +//! commands curl does (plans/CONTROL_SERVER_PLAN.md, step 5). Its tools are the app's commands: +//! `tools/list` from the registry, each tool's input schema the one `/openapi.json` gives the +//! command's body (`openapi.bodySchema`), and `tools/call` through `Host.callCommandAwaiting`, so a +//! command that answers in a later frame answers its call then. +//! +//! What it does not do yet: server-sent notifications (no `GET /mcp` stream: the tool list is +//! read when asked), sessions (`Mcp-Session-Id`), resources and prompts. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const AppInfo = @import("../AppInfo.zig"); +const json = @import("json.zig"); +const openapi = @import("openapi.zig"); + +const Stringify = std.json.Stringify; + +/// The protocol versions this answers, newest first: an `initialize` asking for another gets the +/// newest, as the protocol says. 2025-03-26 asked servers to take JSON-RPC batches, which 2025-06-18 +/// dropped: none are taken here (no client sends them), a deliberate gap. +const versions = [_][]const u8{ "2025-06-18", "2025-03-26" }; + +/// What to send back for one message. +pub const Reply = union(enum) { + /// A JSON-RPC response, as the body. + now: []const u8, + /// A notification: answered `202 Accepted`, no body. + none, + /// A tool whose command answers later: `ticket` names it, and `id` is the request's id as JSON + /// (in the arena), for `result` to answer with once it comes. + later: struct { ticket: u32, id: []const u8 }, +}; + +/// Answer one JSON-RPC message, `body`. Everything returned lives in `arena`. `sink` hears a +/// command that answers later; without one, such a call is answered as started. +pub fn handle(host: *sdk.Host, body: []const u8, arena: std.mem.Allocator, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply { + // Numbers kept as written, so an id comes back exactly as it was sent. + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{ .parse_numbers = false }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .{ .now = try failure(arena, "null", -32700, "the body is not JSON") }, + }; + if (value != .object) return .{ .now = try failure(arena, "null", -32600, "one JSON-RPC message per request: an object, not a batch") }; + const msg = value.object; + const method = if (msg.get("method")) |m| if (m == .string) m.string else null else null; + const id_value = msg.get("id"); + // No id: a notification. No method: a response to a request of ours. Neither is answered. + if (id_value == null or method == null) return .none; + const id = switch (id_value.?) { + .string, .integer, .number_string => try std.json.Stringify.valueAlloc(arena, id_value.?, .{}), + else => return .{ .now = try failure(arena, "null", -32600, "an id is a string or a number") }, + }; + const name = method.?; + const params: ?std.json.ObjectMap = if (msg.get("params")) |p| if (p == .object) p.object else null else null; + + if (std.mem.eql(u8, name, "initialize")) return .{ .now = try initialize(host, arena, id, params) }; + if (std.mem.eql(u8, name, "ping")) return .{ .now = try result(arena, id, "{}") }; + if (std.mem.eql(u8, name, "tools/list")) return .{ .now = try listTools(host, arena, id) }; + if (std.mem.eql(u8, name, "tools/call")) return callTool(host, arena, id, params, sink); + return .{ .now = try failure(arena, id, -32601, try std.fmt.allocPrint(arena, "no method {s}", .{name})) }; +} + +/// A tool call's answer once its command answers later (`Reply.later`). +pub fn outcomeReply(arena: std.mem.Allocator, id: []const u8, outcome: sdk.Host.CommandOutcome) error{OutOfMemory}![]const u8 { + return switch (outcome) { + .ok => |zon| { + // The result as JSON text, which reads as it is; ZON JSON cannot say goes as it is. + const text = json.jsonFromZon(arena, zon) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.NotUtf8 => return toolResult(arena, id, "the result holds text that is not UTF-8", true), + // Not ZON: as it is, which JSON can carry while it is UTF-8. + else => if (std.unicode.utf8ValidateSlice(zon orelse "")) zon orelse "null" else return toolResult(arena, id, "the result is not ZON", true), + }; + return toolResult(arena, id, text, false); + }, + .unknown => failure(arena, id, -32602, "no such tool"), + .disabled => toolResult(arena, id, "the command is not enabled now", true), + .bad_args => |m| toolResult(arena, id, m, true), + .failed => |m| toolResult(arena, id, m, true), + }; +} + +fn initialize(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap) error{OutOfMemory}![]const u8 { + const asked = if (params) |p| if (p.get("protocolVersion")) |v| if (v == .string) v.string else "" else "" else ""; + const version = for (versions) |v| { + if (std.mem.eql(u8, v, asked)) break v; + } else versions[0]; + const body = try Stringify.valueAlloc(arena, .{ + .protocolVersion = version, + .capabilities = .{ .tools = .{ .listChanged = false } }, + .serverInfo = .{ .name = AppInfo.current.name, .title = AppInfo.current.display_name, .version = AppInfo.current.version }, + .instructions = try instructions(host, arena), + }, .{}); + return result(arena, id, body); +} + +/// What a model is told on connecting: what the tools are, and the app's own screen and settled +/// commands when it has them (fizzy does; an app built on fizzy may not). +fn instructions(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + const w = &out.writer; + w.writeAll("Each tool is one of " ++ AppInfo.current.display_name ++ "'s commands, the same ones its menus, " ++ + "keybindings and plugins run. A command acting on a document takes its path, or acts on the active one.") catch return error.OutOfMemory; + for ([_][2][]const u8{ + .{ "fizzy.screen", " fizzy.screen says what is on screen." }, + .{ "fizzy.settled", " fizzy.settled waits until nothing is loading or animating." }, + }) |t| if (host.command(t[0]) != null) w.writeAll(t[1]) catch return error.OutOfMemory; + return out.written(); +} + +fn listTools(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8) error{OutOfMemory}![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + var s: Stringify = .{ .writer = &out.writer }; + writeTools(&s, host, arena) catch return error.OutOfMemory; + return result(arena, id, out.written()); +} + +fn writeTools(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) openapi.Error!void { + try s.beginObject(); + try s.objectField("tools"); + try s.beginArray(); + for (host.commands.items, 0..) |*c, i| { + // Runnable, and the first of its id: the one `callCommand` runs. + if (c.owner == null) continue; + const first = for (host.commands.items[0..i]) |*o| { + if (std.mem.eql(u8, o.id, c.id)) break false; + } else true; + if (!first) continue; + try s.beginObject(); + try s.objectField("name"); + try s.write(c.id); + try s.objectField("title"); + try s.write(c.title); + try s.objectField("description"); + try s.write(try std.fmt.allocPrint(arena, "{s} ({s}'s command{s})", .{ + c.title, + c.owner.?.id, + if (c.visibility == .internal) ", for programs" else "", + })); + try s.objectField("inputSchema"); + try openapi.bodySchema(s, c, arena); + try s.endObject(); + } + try s.endArray(); + try s.endObject(); +} + +fn callTool(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply { + const p = params orelse return .{ .now = try failure(arena, id, -32602, "tools/call takes .name and .arguments") }; + const name = if (p.get("name")) |n| if (n == .string) n.string else null else null; + const tool = name orelse return .{ .now = try failure(arena, id, -32602, "tools/call needs a name") }; + const c = host.command(tool) orelse return .{ .now = try failure(arena, id, -32602, try std.fmt.allocPrint(arena, "no tool {s}", .{tool})) }; + // The arguments, through the same JSON-to-ZON edge as a REST call (`json.zig`). + // Absent and null are none. + const arguments = if (p.get("arguments")) |a| if (a == .null) "{}" else try Stringify.valueAlloc(arena, a, .{}) else "{}"; + var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; + for (c.params) |param| if (param.kind == .enumeration) try enum_keys.append(arena, param.key); + const args = json.zonFromJson(arena, arguments, enum_keys.items) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .{ .now = try toolResult(arena, id, "the arguments are an object keyed by the tool's input schema", true) }, + }; + return switch (host.callCommandAwaiting(c.id, args, arena, sink)) { + .now => |outcome| .{ .now = try outcomeReply(arena, id, outcome) }, + .later => |ticket| if (sink != null) + .{ .later = .{ .ticket = ticket, .id = id } } + else + .{ .now = try toolResult(arena, id, "started; its answer comes later", false) }, + }; +} + +fn toolResult(arena: std.mem.Allocator, id: []const u8, text: []const u8, is_error: bool) error{OutOfMemory}![]const u8 { + const body = try Stringify.valueAlloc(arena, .{ + .content = .{.{ .type = "text", .text = text }}, + .isError = is_error, + }, .{}); + return result(arena, id, body); +} + +/// `{"jsonrpc":"2.0","id":,"result":}`: `id` and `body` already JSON. +fn result(arena: std.mem.Allocator, id: []const u8, body: []const u8) error{OutOfMemory}![]const u8 { + return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"result\":{s}}}", .{ id, body }); +} + +fn failure(arena: std.mem.Allocator, id: []const u8, code: i32, message: []const u8) error{OutOfMemory}![]const u8 { + const err = try Stringify.valueAlloc(arena, .{ .code = code, .message = message }, .{}); + return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"error\":{s}}}", .{ id, err }); +} diff --git a/app/control/openapi.zig b/app/control/openapi.zig index c3548688a..d15e3a345 100644 --- a/app/control/openapi.zig +++ b/app/control/openapi.zig @@ -16,7 +16,7 @@ const AppInfo = @import("../AppInfo.zig"); const json = @import("json.zig"); const Stringify = std.json.Stringify; -const Error = Stringify.Error || error{OutOfMemory}; +pub const Error = Stringify.Error || error{OutOfMemory}; /// The whole description as JSON, in `arena`. pub fn generate(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]u8 { @@ -62,6 +62,16 @@ fn write(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) Error!void { .summary = "This description, generated from what is registered now.", .responses = .{ .@"200" = jsonResponse("The description.", true) }, } }); + try s.objectField("/mcp"); + try s.write(.{ .post = .{ + .operationId = "control.mcp", + .summary = "The Model Context Protocol (Streamable HTTP): a JSON-RPC message, its tools the commands.", + .requestBody = .{ .required = true, .content = .{ .@"application/json" = .{ .schema = .{ .type = "object" } } } }, + .responses = .{ + .@"200" = jsonResponse("The JSON-RPC response.", .{ .type = "object" }), + .@"202" = .{ .description = "A notification, taken." }, + }, + } }); try eventsPath(s, host, arena); // The first of an id is the one the server runs (`Host.command`): a later one is not a path // of its own, which would repeat a key and an `operationId`. A command no plugin owns cannot @@ -194,27 +204,7 @@ fn commandPath(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) E try s.objectField("application/json"); try s.beginObject(); try s.objectField("schema"); - try s.beginObject(); - try s.objectField("type"); - try s.write("object"); - // Parameters declared: exactly those (`Command.Params` refuses others). None declared, as a - // `runWith` written by hand: whatever it reads, which the description cannot know. - if (c.params.len > 0) { - try s.objectField("additionalProperties"); - try s.write(false); - try s.objectField("properties"); - try s.beginObject(); - for (c.params) |p| { - try s.objectField(p.key); - try paramSchema(s, arena, p); - } - try s.endObject(); - try s.objectField("required"); - try s.beginArray(); - for (c.params) |p| if (p.required) try s.write(p.key); - try s.endArray(); - } - try s.endObject(); + try bodySchema(s, c, arena); try s.endObject(); try s.endObject(); try s.endObject(); @@ -231,6 +221,32 @@ fn commandPath(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) E try s.endObject(); } +/// The JSON Schema of what `c` takes as arguments: exactly its declared parameters +/// (`Command.Params` refuses others); any object for a `runWith` written by hand, which declares +/// none and reads whatever it reads; nothing for a command with only `run`. Shared by this +/// description's request bodies and MCP's tool inputs (`mcp.zig`). +pub fn bodySchema(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) Error!void { + try s.beginObject(); + try s.objectField("type"); + try s.write("object"); + if (c.params.len > 0 or c.runWith == null) { + try s.objectField("additionalProperties"); + try s.write(false); + try s.objectField("properties"); + try s.beginObject(); + for (c.params) |p| { + try s.objectField(p.key); + try paramSchema(s, arena, p); + } + try s.endObject(); + try s.objectField("required"); + try s.beginArray(); + for (c.params) |p| if (p.required) try s.write(p.key); + try s.endArray(); + } + try s.endObject(); +} + fn paramSchema(s: *Stringify, arena: std.mem.Allocator, p: sdk.Command.Param) Error!void { try s.beginObject(); try s.objectField("title"); diff --git a/app/control/routes.zig b/app/control/routes.zig index 509cc7f9e..687f7f460 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -7,6 +7,7 @@ //! GET /commands/{id} one of them //! POST /commands/{id} run it: the body is its arguments, the response its result //! GET /openapi.json all of this as OpenAPI 3.1, generated from what is registered now +//! POST /mcp the Model Context Protocol, its tools the commands (`mcp.zig`) //! GET /events the app's events as Server-Sent Events, `?topics=a,b` to choose //! (a name, or a prefix ending in `*`); every event when absent //! @@ -19,6 +20,7 @@ const sdk = @import("fizzy_sdk"); const AppInfo = @import("../AppInfo.zig"); const json = @import("json.zig"); const openapi = @import("openapi.zig"); +const mcp = @import("mcp.zig"); const Server = @import("Server.zig"); pub const Answer = struct { @@ -58,11 +60,11 @@ pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { state.waiters.append(server.gpa, waiter) catch { host.abandonCall(waiter.ticket); req.answer(server, .internal_server_error, "text/plain", "out of memory\n"); - server.gpa.destroy(waiter); + waiter.destroy(); }; continue; } - server.gpa.destroy(waiter); + waiter.destroy(); if (a.stream) { req.answerStream(server); streaming = true; @@ -77,7 +79,7 @@ pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { pub fn stop(server: *Server, state: *State, host: *sdk.Host) void { for (state.waiters.items) |w| { host.abandonCall(w.ticket); - server.gpa.destroy(w); + w.destroy(); } state.waiters.clearAndFree(server.gpa); state.follow(host, server, false); @@ -93,22 +95,29 @@ const Waiter = struct { req: *Server.Request, zon: bool, ticket: u32 = 0, + /// An MCP tool call's JSON-RPC id, as JSON (`gpa`): answered as JSON-RPC, not REST. + mcp_id: ?[]u8 = null, + + fn destroy(self: *Waiter) void { + if (self.mcp_id) |id| self.server.gpa.free(id); + self.server.gpa.destroy(self); + } fn done(ctx: *anyopaque, outcome: sdk.Host.CommandOutcome) void { const self: *Waiter = @ptrCast(@alignCast(ctx)); var arena: std.heap.ArenaAllocator = .init(self.server.gpa); defer arena.deinit(); - const a = outcomeAnswer(arena.allocator(), self.zon, "", outcome) catch Answer{ - .status = .internal_server_error, - .content_type = "text/plain", - .body = "out of memory\n", - }; + const oom: Answer = .{ .status = .internal_server_error, .content_type = "text/plain", .body = "out of memory\n" }; + const a: Answer = if (self.mcp_id) |id| + if (mcp.outcomeReply(arena.allocator(), id, outcome)) |body| .{ .status = .ok, .body = body } else |_| oom + else + outcomeAnswer(arena.allocator(), self.zon, "", outcome) catch oom; self.req.answer(self.server, a.status, a.content_type, a.body); for (self.state.waiters.items, 0..) |w, i| if (w == self) { _ = self.state.waiters.swapRemove(i); break; }; - self.server.gpa.destroy(self); + self.destroy(); } }; @@ -194,6 +203,23 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ else => notAllowed(arena, "GET"), }; } + if (std.mem.eql(u8, trimmed, "/mcp")) { + if (method != .POST) return notAllowed(arena, "POST"); + const sink: ?sdk.Host.CallSink = if (waiter) |w| .{ .ctx = w, .done = Waiter.done } else null; + return switch (try mcp.handle(host, body, arena, sink)) { + .now => |reply| .{ .status = .ok, .body = reply }, + .none => .{ .status = .accepted, .body = "" }, + .later => |l| later: { + const w = waiter.?; + w.ticket = l.ticket; + w.mcp_id = w.server.gpa.dupe(u8, l.id) catch { + host.abandonCall(l.ticket); + return failure(arena, .internal_server_error, "out of memory"); + }; + break :later .{ .status = .ok, .later = true, .body = "" }; + }, + }; + } if (std.mem.eql(u8, trimmed, "/events")) { return switch (method) { .GET => .{ .status = .ok, .stream = true, .body = "" }, diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 91dce1a39..16f6304ec 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -47,6 +47,7 @@ fizzy with its own Windows target must set it too, or its server logs that it ca | `POST /commands/{id}` | runs it: the body is its arguments, the response its result | | `GET /events?topics=a,b` | the app's events as Server-Sent Events | | `GET /openapi.json` | all of this as OpenAPI 3.1, generated from what is registered now | +| `POST /mcp` | the Model Context Protocol, its tools the commands | **Bodies are JSON**, converted to the ZON commands take at the server's edge: an object of arguments keyed by the command's parameter names, a string where the parameter is an enumeration @@ -98,6 +99,22 @@ data: {"path":"/abs/notes.md"} keep-alive comment every 15 seconds; one that falls more than 8 MB behind gets nothing more. With no stream open, events cost the bus nothing extra. +## MCP + +`POST /mcp` speaks the Model Context Protocol's Streamable HTTP transport: one JSON-RPC message +per request, answered as JSON. Its tools are the commands. `tools/list` reads the registry, each +tool's `inputSchema` is the body schema `/openapi.json` gives the command, and `tools/call` runs it +as `POST /commands/{id}` would. A command that answers later answers its call then. + +```sh +curl --unix-socket "$SOCK" localhost/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +curl --unix-socket "$SOCK" localhost/mcp -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"fizzy.screen"}}' +``` + +It answers `initialize`, `ping`, `tools/list` and `tools/call`, and notifications get a `202`. There +is no server-sent stream yet (`GET /mcp`), no `Mcp-Session-Id`, and no resources or prompts. An +MCP client that can only dial a URL or speak stdio reaches it through a proxy for now. + ## The description `GET /openapi.json` describes the server as it is now, so it changes as plugins load and reload. diff --git a/tests/integration.zig b/tests/integration.zig index f4e20dc7e..7903026ce 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7065,6 +7065,82 @@ test "control: a command that answers in a later frame holds its request open un host.finishCall(ticket, .{ .ok = null }); } +test "control: MCP on the socket lists the commands as tools and calls them, now or later" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + const control = @import("app").control; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var slow: SlowCommand = .{}; + var plugin = sdk.Plugin{ .state = &slow, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = SlowCommand.start }); + try host.registerCommand(.{ + .id = "t.place", + .owner = &plugin, + .title = "Place", + .params = PaletteArgs.Params.params, + .runWith = PaletteArgs.Params.bind(ControlFixture.place), + }); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const routes = control.routes; + // Without a socket (`handle`): the replies a client reads. + const init = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\"}}", arena); + try std.testing.expect(std.mem.indexOf(u8, init.body, "\"protocolVersion\":\"2025-03-26\"") != null); + try std.testing.expectEqual(std.http.Status.accepted, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", arena).status); + const list = try std.json.parseFromSliceLeaky(std.json.Value, arena, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}", arena).body, .{}); + const tools = list.object.get("result").?.object.get("tools").?.array.items; + try std.testing.expectEqual(@as(usize, 2), tools.len); + const place = tools[1].object; + try std.testing.expectEqualStrings("t.place", place.get("name").?.string); + try std.testing.expectEqualStrings("integer", place.get("inputSchema").?.object.get("properties").?.object.get("line").?.object.get("type").?.string); + const called = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"t.place\",\"arguments\":{\"line\":4,\"side\":\"right\"}}}", arena); + try std.testing.expect(std.mem.indexOf(u8, called.body, "\"isError\":false") != null); + try std.testing.expect(std.mem.indexOf(u8, called.body, "\\\"note\\\":\\\"placed\\\"") != null); + const refused = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"t.place\",\"arguments\":{\"side\":\"right\"}}}", arena); + try std.testing.expect(std.mem.indexOf(u8, refused.body, "\"isError\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"nope\"}", arena).body, "-32601") != null); + // A response from the client, as a notification is, is taken (202), not refused. + try std.testing.expectEqual(std.http.Status.accepted, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}", arena).status); + // An id comes back as it was sent; one that is neither a string nor a number is refused. + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":12345678901234567890,\"method\":\"ping\"}", arena).body, "\"id\":12345678901234567890,") != null); + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":true,\"method\":\"ping\"}", arena).body, "-32600") != null); + // Told only of commands the app has: no fizzy.screen here. + try std.testing.expect(std.mem.indexOf(u8, init.body, "fizzy.screen") == null); + + // Over the socket, a tool whose command answers later answers its call then. + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + const Wake = struct { + fn none() void {} + }; + var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; + try server.start(); + var state: control.routes.State = .{}; + defer control.routes.stop(&server, &state, &host); + const body = "{\"jsonrpc\":\"2.0\",\"id\":\"later\",\"method\":\"tools/call\",\"params\":{\"name\":\"t.slow\"}}"; + const request = try std.fmt.allocPrint(arena, "POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n{s}", .{ body.len, body }); + var client: ControlOneShot = .{ .path = path, .request = request }; + const thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&client}); + var frames: usize = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + host.finishCall(slow.ticket, .{ .ok = ".{ .done = true }" }); + thread.join(); + try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.len], "{\"jsonrpc\":\"2.0\",\"id\":\"later\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"done\\\":true}\"}],\"isError\":false}}")); +} + const ControlStreamClient = struct { path: []const u8, reply: [4096]u8 = undefined, From 490e5abc1e343fe1de58582d2c1e6f3648121ee4 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:27:10 -0500 Subject: [PATCH 26/31] app: an undo under the person's stroke says to try again (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5, "not during the person's gesture". The maintainer chose the simple form: refuse and say to try again, rather than queue the undo. ## What changes - **`error.PersonBusy`** is the one name an owner's `historyGoTo` may refuse with: the person has a gesture open (a stroke under their hand), and someone else is acting. It is documented on the vtable entry; nothing enforces it. - **`Plugin.historyGoToWhy`** is `historyGoTo` answering why it did not go, the owner's error by name (`errorName`, so it reads right across a dylib), or null when it went. It logs nothing; `historyGoTo` keeps its bool and its log line, built on it. - **`fizzy.undo`/`fizzy.redo`** with a path or `expect` answer *"the person is drawing in /p/coin.pixi; try again when they stop"* on `PersonBusy`, and name any other reason instead of a bare "could not undo". Pixi's side, refusing under the person's stroke or open transform, is fizzyedit/pixi#56. ## SDK impact - [x] None for the fingerprint (`test-sdk-version` passes): a new method on `Plugin` and a doc comment. Plugin authors gain one documented error name. ## Verified - Unit test (`sdk/src/Host.zig`): a stub owner refusing `PersonBusy` reads back as `"PersonBusy"`, and a move that goes reads as null. - Seen on macOS with pixi#52, in a sandbox built from this branch, with two MCP clients at once: - one dragged a slow pencil stroke across a sprite with the pointer; - the other called `fizzy.undo .{ .path }` 1.2 s in, and got *"the person is drawing in …/coin.pixi; try again when they stop"*; - the same call after the stroke ended undid the stroke. - Gates on macOS: `check-fmt`, `zig build test`, `test-integration` (54/54 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- sdk/src/Host.zig | 14 ++++++++++++++ sdk/src/Plugin.zig | 21 ++++++++++++++------- src/editor/Keybinds.zig | 6 +++++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index b8b92a033..564f18815 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -3827,3 +3827,17 @@ test "later answers: nested calls keep their own tickets, an early answer is the host.finishCall(fixture.inner_ticket, .{ .ok = null }); try testing.expectEqual(@as(u32, 0), unheard.count); } + +test "historyGoToWhy answers the owner's reason by name, and nothing when it went" { + const S = struct { + fn goTo(_: *anyopaque, _: DocHandle, id: u64) anyerror!void { + if (id == 7) return error.PersonBusy; + } + }; + const vtable = Plugin.VTable{ .historyGoTo = S.goTo }; + var state: u8 = 0; + var plugin = Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" }; + const doc: DocHandle = .{ .ptr = &state, .owner = &plugin, .id = 1 }; + try testing.expectEqualStrings("PersonBusy", plugin.historyGoToWhy(doc, 7).?); + try testing.expect(plugin.historyGoToWhy(doc, 3) == null); +} diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index a0c87a807..0d0a17851 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -237,7 +237,9 @@ pub const VTable = struct { /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. /// `fizzy.undo`/`fizzy.redo` with a `path` or an `expect` go through it too, so a document - /// can be undone without being the active one. + /// can be undone without being the active one. `error.PersonBusy` refuses while the person has + /// a gesture open (a stroke under their hand) and someone else is acting: the caller is told + /// to try again once it ends. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it @@ -635,12 +637,17 @@ pub fn applyStep(self: Plugin, doc: DocHandle, effect: []const u8, motion: []con /// Go to step `id` of `doc`'s history (`historyGoTo`). Returns whether it went. pub fn historyGoTo(self: Plugin, doc: DocHandle, id: u64) bool { - const f = self.vtable.historyGoTo orelse return false; - f(self.state, doc, id) catch |err| { - std.log.err("{s}: historyGoTo failed: {s}", .{ self.id, self.errorName(err) }); - return false; - }; - return true; + const why = self.historyGoToWhy(doc, id) orelse return true; + std.log.err("{s}: historyGoTo failed: {s}", .{ self.id, why }); + return false; +} + +/// `historyGoTo`, answering why it did not go (the owner's error, by name: `PersonBusy`), or null +/// when it went. Logs nothing: the caller says it. +pub fn historyGoToWhy(self: Plugin, doc: DocHandle, id: u64) ?[:0]const u8 { + const f = self.vtable.historyGoTo orelse return "NoHistory"; + f(self.state, doc, id) catch |err| return self.errorName(err); + return null; } /// `doc`'s state for a demo's snapshot (`captureDocumentState`). Null when the owner has no diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 9d66f6dfc..f8855f7c2 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -472,7 +472,11 @@ fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.C "step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward", .{ args.expect, @tagName(action), t.step.id, t.step.title, by }, ); - if (!doc.owner.historyGoTo(doc, t.to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), t.step.id }); + if (doc.owner.historyGoToWhy(doc, t.to)) |why| { + if (std.mem.eql(u8, why, "PersonBusy")) + return call.fail("the person is drawing in {s}; try again when they stop", .{doc.owner.documentPath(doc)}); + return call.fail("{s} could not {s} step {d}: {s}", .{ doc.owner.id, @tagName(action), t.step.id, why }); + } try call.returns(.{ .step = t.step.id, .title = t.step.title, .by = by }); } /// Set while `tick` is running a command because of a live key event. That event is never From 485e770e7c05a3a172e9c01659cf2cfa00b94be9 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:35:02 -0500 Subject: [PATCH 27/31] app: record documents' history by default, with a setting to turn it off (#507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3's last part: tracks on by default, "in a PR of its own once a running fizzy's numbers meet the targets (a setting replaces the switch)". ## What changes - **A setting replaces the switch.** Settings › History › **Record document history** (`record_history`), on by default. - **Applied when it changes**, each frame's top, before the tracks' own work. A watched hand-edit of `settings.zon` applies too. - **`FIZZY_TRACKS`** still overrides it, as fizzy's other switches do. - **`fizzy.tracks.record`**, an agent's or a test's say, holds until the setting next changes. - **Off means forgotten.** Turning recording off now drops what was recorded (`setTracking` ends every track), so documents keep only their undo history, as the plan's "Privacy" section says. Nothing of a track is ever written unless the person exports. - **Docs:** `app/Tracks.zig` and `docs/AUTOMATION.md` no longer say tracks are behind a switch. ## Measured against the plan's targets macOS, Apple silicon, **ReleaseFast**: | Target | Measured | |---|---| | Recording, any frame: nothing per frame | `bench-tracks`: a frame's top with nothing asked, 0.93 ns | | Recording, while asleep: no timers, no wakes | idle with recording on and off (`fizzy_profile`, 4–5 s windows, Output panel showing): 1 frame a window either way, the profiler's own request | | A pixi stroke: < 20 µs on the UI thread | `tracks.step` at most 7 µs over three pencil strokes and four undo/redos in a running fizzy (devtools stats, every scope over 13 s, 405 frames); `bench-tracks` 0.13–3.4 µs a step with its share of checkpoints | | Checkpoint, 512×512 × 8 layers: < 2 ms | `bench-tracks`: an 8 MB document 0.85 ms, 1 MB 0.11 ms | | Undo or redo: the same frame | `pixi.undo` at most 0.04 ms, `fizzy.undo` 0.1 ms with recording on; the worst frame over the run was 5.2 ms | Not measured here: Windows and Ubuntu (VMs not run), the web, and an 8-hour session's memory (plan step 11's subject). **Turning recording back on** captures each open document's base once (a frame's work per document, measured above as a checkpoint), then it is quiet again. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 561/561 tests, **every test now running with recording on**, the bundled tour and demo tapes included. - **A new integration test:** the setting is on by default. Turning it off ends recording, unhooks the recorder and forgets every track; turning it on gives every open document a track again. - **In a running ReleaseFast fizzy** (its own profile, no `FIZZY_TRACKS`): `fizzy.tracks` reported recording on and a track per open document. Typing and pixi strokes recorded, measured as above. - [ ] Windows, Linux: not run (CI builds and runs the integration tests on Linux). - [ ] Web: `check-web` builds. ## Follow-ups - **The plan's frame-budget check** (bundled tapes' frame times with recording on matching off within noise, failing the build on a regression) is not added. Timing comparisons in the headless run are noisy on shared CI. The integration run now records throughout, which catches a recording failure but not a slow one. The seam is `bench-replay` growing a recording-on case whose numbers CI keeps, as the plan's "Profiling it" says. - **Seen while measuring, not this change:** - pixi 0.1.47 (the store build) crashed with a null pointer in `File.loadFizzyZip` when fizzy's restart hand-over gave it back an `art.pixi` that the text editor had held (opened as text when pixi was still loading at startup). Malformed bytes should refuse, not crash. The crash report is kept. - A file named on the command line opens with whichever plugin is loaded by then: `art.pixi` opened in the text editor because pixi was still loading. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/HistoryFrames.zig | 4 +++- app/Tracks.zig | 6 +++--- app/settings/Settings.zig | 6 ++++++ docs/AUTOMATION.md | 2 +- src/editor/Editor.zig | 20 ++++++++++++++++++-- src/editor/explorer/settings.zig | 21 +++++++++++++++++++++ tests/integration.zig | 19 +++++++++++++++++++ 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index 84e2b2e89..7571f1e4b 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -77,6 +77,8 @@ pub fn get(self: *const HistoryFrames, top: u64) ?dvui.Texture { } pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void { - for (self.frames.values()) |t| dvui.textureDestroyLater(t); + // Destroyed with a frame, and only in one: let go of at teardown (the app quitting) there is + // no window, and the GPU goes with the process. + if (dvui.current_window != null) for (self.frames.values()) |t| dvui.textureDestroyLater(t); self.frames.deinit(gpa); } diff --git a/app/Tracks.zig b/app/Tracks.zig index 40d0c5e86..b69967d82 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -18,9 +18,9 @@ //! open track's newest checkpoint (`shed`); steps and bases stay, since undo will walk them. //! Taking and diffing a checkpoint happen on the frame that takes it, on every target (the web has //! no threads); were that ever too long, the diff would be spread over the frames after, a slice -//! each, the copy being immutable once taken. Until tracks are on by default they are behind `on` -//! (fizzy: `FIZZY_TRACKS=1`). `zig build bench-tracks` measures what a step, a checkpoint and a -//! replay cost. +//! each, the copy being immutable once taken. They record while `on`: in fizzy by default, as its +//! setting says (`record_history`, `FIZZY_TRACKS` over it). `zig build bench-tracks` measures what +//! a step, a checkpoint and a replay cost. const Tracks = @This(); const std = @import("std"); diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index 2d293c4a1..a32d07b5a 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -108,6 +108,12 @@ native_dialogs: bool = true, /// for. A copy of each frame's events: nanoseconds. black_box: bool = true, +/// Whether each open document's history is recorded as it is made (`app.Tracks`): what lets the +/// transport's strip show it and its export share it as a tape. Kept in memory with the +/// document; nothing is written unless the person exports. Off, documents keep only their undo +/// history, and what was recorded is forgotten. +record_history: bool = true, + /// Whether a rebuild of the app's own executable restarts the app into it, keeping every open /// document (`ExecutableWatcher`). On in a source build, which is what rebuilds; off in a release /// built to update itself (Velopack), whose binary only changes through its own updater. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 6df89c98a..54452114e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -310,7 +310,7 @@ would play a document that never changes. The app applies them through its stage Fizzy applies them: a tape's steps are checked against the plugins loaded (`Demo.stepsCheck`: the owner loaded, writing that step format) and played on the demo stage into the documents its keyframe opened. A document's own history becomes such a tape with `fizzy.tracks.export` (while -tracks record, `FIZZY_TRACKS=1` or `fizzy.tracks.record`): its file as the track began, then its +tracks record: by default, the History setting's "Record document history"): its file as the track began, then its steps, pauses past 2 s played as 0.6 s unless `real_time`. `fizzy.demo.playTape` plays it. A link plays one on the web: `fizzyed.it/app?tape=` fetches the tape, loads each plugin its diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 584701d37..7399fa5fc 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -133,6 +133,8 @@ command_palette: @import("CommandPalette.zig") = .{}, demo: Demo = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). tape_tabs: TapeTabs = .{}, +/// The recording setting as last applied (`tick`); null before the first frame. +record_history_applied: ?bool = null, /// A part of a document's history to export (the strip's export window): at the top of the next /// frame, before anything draws, since the export walks the document's history back and forth. share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, @@ -2061,9 +2063,9 @@ fn storeUninstalled(arena: std.mem.Allocator) []const @import("app").layout.Stat } pub fn postInit(editor: *Editor) !void { - // Documents' tracks, while they are behind a switch (plans/RECORDING_PLAN.md, step 3). + // Documents' tracks (plans/RECORDING_PLAN.md): on as the setting says (`record_history`), + // applied each time it changes (`tick`). editor.app.tracks = .init(editor.app.gpa); - if (Popout.envSwitch("FIZZY_TRACKS") orelse false) editor.setTracking(true); if (comptime build_opts.has_app_layout) { dvui.log.info("layout: app-supplied", .{}); } else { @@ -3120,8 +3122,10 @@ pub fn keepTape(editor: *Editor, arena: std.mem.Allocator, doc: sdk.DocHandle, b } /// Record documents' tracks, or stop. Documents open now get one; those opened after too. +/// Off, what was recorded is forgotten: documents keep only their undo history. pub fn setTracking(editor: *Editor, on: bool) void { const t = &(editor.app.tracks orelse return); + if (!on and t.on) t.deinit(); t.on = on; editor.app.host.recorder = if (on) t.recorder() else null; if (on) for (editor.app.open_files.values()) |doc| t.opened(doc); @@ -3626,6 +3630,7 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.app.settings.native_menus = parsed.native_menus; editor.app.settings.native_dialogs = parsed.native_dialogs; editor.app.settings.black_box = parsed.black_box; + editor.app.settings.record_history = parsed.record_history; editor.app.settings.control_server = parsed.control_server; editor.app.settings.input_scheme = parsed.input_scheme; editor.app.settings.plugin_update_mode = parsed.plugin_update_mode; @@ -4112,6 +4117,15 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.file_table.pump(); // Saves whose encode finished on their worker: on to their write, or done. editor.doc_io.tick(); + // Recording as the setting says, when it changed (`FIZZY_TRACKS` over it): a command that + // turned it on or off (`fizzy.tracks.record`) holds until the setting next changes. + { + const want = Popout.envSwitch("FIZZY_TRACKS") orelse editor.app.settings.record_history; + if (editor.record_history_applied != want) { + editor.record_history_applied = want; + editor.setTracking(want); + } + } // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); if (editor.share_pending) |p| { @@ -6695,6 +6709,8 @@ pub fn deinit(editor: *Editor) !void { // The registry itself goes with `host.deinit` below; these are the app's own strings. var it = editor.doc_surfaces.valueIterator(); while (it.next()) |ds| { + // Its history's pictures, taken while it recorded. + ds.*.frames.deinit(editor.app.gpa); editor.app.gpa.free(ds.*.id); editor.app.gpa.destroy(ds.*); } diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 1ca930b12..7a672410b 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -234,6 +234,23 @@ pub const groups = [_]Group{ }, }, }, + .{ + .title = "History", + .icon = icons.tvg.lucide.history, + .items = &.{ + .{ + .label = "Record document history", + .key = "record_history", + .description = "Keep how each open document is made, step by step, in memory: " ++ + "what lets undo scrub through pictures of it and export share it as a tape. " ++ + "Nothing is written unless you export. Off, documents keep only their undo " ++ + "history, and what was recorded is forgotten.", + .keywords = "history record recording track tape undo scrub export share privacy", + .draw = drawRecordHistory, + .inline_control = true, + }, + }, + }, .{ .title = "Debugging", .icon = icons.tvg.lucide.bug, @@ -668,6 +685,10 @@ fn drawFloatWindows() void { } } +fn drawRecordHistory() void { + drawToggle(&fizzy.editor().app.settings.record_history, "Record document history"); +} + fn drawNativeGlass() void { drawToggle(&fizzy.editor().app.settings.native_glass, "Native glass"); } diff --git a/tests/integration.zig b/tests/integration.zig index 7903026ce..5601e5ae9 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8622,6 +8622,25 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // Recording follows its setting (`record_history`, on by default) each time it changes, a + // command's say (`setTracking`, as the tests above used) holding until then: turned off, + // nothing is recorded and what was is forgotten; turned on, every open document gets a track. + { + const tracks = &editor.app.tracks.?; + try std.testing.expect(editor.app.settings.record_history); + editor.setTracking(true); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(tracks.open.count() > 0); + editor.app.settings.record_history = false; + try dvui.testing.settle(headlessFrame); + try std.testing.expect(!tracks.on and editor.app.host.recorder == null); + try std.testing.expectEqual(@as(usize, 0), tracks.open.count()); + editor.app.settings.record_history = true; + try dvui.testing.settle(headlessFrame); + try std.testing.expect(tracks.on and editor.app.host.recorder != null); + try std.testing.expectEqual(editor.app.open_files.count(), tracks.open.count()); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { From dea512f86de6868ec279485454c3e9344e142bc4 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:41:09 -0500 Subject: [PATCH 28/31] core: tabs show the hand, and a tapped item is left unlit (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #547. Serves #555 (plans#27), whose corner button needs to tell touch from mouse. ## What changes - **Hand cursor:** panel tabs and rail icons (everything drawn through `core.widgets.Tabs`) and document tabs show the hand under a mouse. - **Splits keep their arrow:** within a split's reach of its sash, the split's resize arrow still wins over a tab's hand. - **No hover after a tap:** on a touchscreen, a tapped tab, rail icon, menu row or button no longer stays lit, and its tooltip no longer comes up once the finger lifts. - **macOS trackpad:** a trackpad's fingers no longer reach dvui as `touch0` presses at the finger's position on the pad. They are mouse input only, as they already were through SDL's mouse events. **For plugins:** `dvui.pointerKind()` (`.touch` or `.mouse`) and `dvui.cursorSetPriority(cursor, .normal | .resize | .drag)`, both from dvui-dev. #555 should read `dvui.pointerKind() == .touch`. ## Design: the pointer kind and cursor priority seam Two questions every hover look and cursor needs answered once a frame: *is this pointer a finger or a mouse*, and *does a resize handle claim the cursor here*. **Pointer kind: from the event source, or inferred by fizzy?** - **A. dvui's window says it (chosen).** dvui-dev's `Window` sets `pointer_kind` as the backend adds events: a touch button (`touch0`..`touch9`, from `addEventPointer` or `addEventTouchMotion`) is `.touch`; mouse motion, a mouse button or the wheel is `.mouse`. Read with `dvui.pointerKind()`. The window is the one every plugin dylib and wasm side module already draws into, so they all read the same answer, with no frame key and no module global. dvui also keeps which touch buttons are down: after a touch with none down, the frame's `.position` event goes off every window and to no widget. A lifted finger hovers nothing, so dvui's buttons, menu rows, tooltips and `core.widgets.hovered` all stop showing hover after a tap without a check of their own. The backend decides what a touch is. SDL reports a macOS trackpad's fingers as touches (`SDL_TOUCH_DEVICE_INDIRECT_*`, at the pad's normalized point), and fizzy's backend passed them on as `touch0` presses at that point mapped into the window. Nothing in fizzy or the store plugins read them as anything else: every touch consumer (`CanvasWidget`, `Tabs`, `ContextWidget`, `viz`, pixi, atlas) reads touch buttons as fingers on the screen. So the backend gives only `SDL_TOUCH_DEVICE_DIRECT` fingers a touch button, one per finger (`touch_slots.zig`), and the trackpad stays the mouse it already is. - **B. fizzy infers it** (`core.input`, scanning each frame's events for touch buttons). This is what #542 tried. State in a module is per image (each dylib and wasm side module has its own), a per-frame key needs a frame id that is unique on every backend, and a hover look in a dvui widget (a menu row) would still need dvui changed. Putting it on `Host` instead would add an SDK seam for something dvui already sees. **Cursor: priority in dvui, or ordering in fizzy?** - **A. `dvui.cursorSetPriority(cursor, priority)` (chosen).** The highest asked in a frame wins, and between equals the first. `cursorSet` is `.normal`, as before. A resize handle asks at `.resize` (fizzy's `Split`, float edges; dvui's `PanedWidget`, `GridWidget` columns and floating window edges), and the carried view at `.drag`. A tab's `.hand` can't take a split's arrow, whichever is drawn first, and *which widget takes a press doesn't change*. - **B. The split claims ahead**: it handles the pointer before the region beside it draws. #542 tried this and dropped it, because it took presses from scrollbars near a sash and took the cursor during other widgets' captures. A tab checking how close it is to a split instead would tie the tab to the layout. **For #555 (plans#27):** the read is `dvui.pointerKind() == .touch`. It replaces the corner's `_chooser_touch` bool. A finger that lifted leaves no `.position` hit, so the corner's mouse-hover reveal won't fire after a tap. ### The pieces - **dvui-dev** (`fizzy-dev`, tag `fizzy-sdk-0.2.30-5`, `docs/DEPENDENCIES.md` items 11 and 12): - `e04bccc` Window: the pointer kind, and a lifted finger hovers nothing - `20e56f1` cursorSet: a priority, so a resize handle's cursor wins - **Backend:** `backend/src/touch_slots.zig` is std-only and unit-tested. Each finger on the screen keeps its own touch button from down to up, so two fingers are two buttons on native touchscreens too. Before, every finger was `touch0`, and `FINGER_CANCELED` was dropped. - **Users:** - `Tabs.Tab` asks for `.hand` on its `.position`. - The workbench's document tab asks for `.hand` when hovered. - `Split.grab` and the float's resize edges ask at `.resize`. - `Layout.init`'s view-drag cursor asks at `.drag`. The cursor priority doesn't change routing. A press over the edge of a tab inside a split's reach still goes to the tab, drawn first, as it did before this PR (a test pins this). That mismatch, an arrow shown where the press goes to the tab, already existed. Changing it means changing routing, which #542 showed has costs. It's left as a follow-up. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag The dvui pin moves from `fizzy-sdk-0.2.30-4` to `fizzy-sdk-0.2.30-5`, because `Window` gained `pointer_kind`, `touches_down` and `cursor_priority`. The fingerprint is now `0x35bac8377071cd23`, on top of #538's. For plugin authors: - `dvui.pointerKind()` and `dvui.cursorSetPriority` are new. - `cursorSet` behaves as before. - A plugin's own hover looks lose their hover after a tap with no change on the plugin's side. ## Verified - [x] macOS: - **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build test` (107/107 steps), `zig build test-integration` (54/54 steps, 601/601), `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached`, `scripts/check-examples.sh` (all 8 ok). - **dvui-dev:** `zig build test -Dbackend=testing`, 268/268. New tests: - "a finger that lifts hovers nothing, and a mouse hovers again" - "cursorSetPriority: the higher asked wins, whichever asked first" - **New fizzy tests** (`core/widgets/Tabs.zig`, dvui testing backend, under `fizzy-sizing-tests`): - "a tab shows the hand" - "beside a split, the split's arrow wins over the hand, and a press goes where it did". With `Split` set back to plain `cursorSet`, this one fails with "expected .arrow_w_e, found .hand". - "a tapped tab is left without a hover look" - **Backend unit tests** (`backend-touch-slots-tests`): - "a trackpad's fingers get no slot" - "each finger on the screen keeps its own slot until it lifts" - "an eleventh finger is not seen" - **Not seen on screen:** the hand and the arrow in a running fizzy, and a trackpad in the sandbox. - [ ] Windows: builds in CI only. The direct-touch path is untested on a touchscreen. - [ ] Linux: - [ ] Web: `check-web` only. The phone tap that leaves no hover look needs seeing on a phone. ## Follow-ups - **Ad-hoc touch tracking** in `core/widgets/CanvasWidget.zig` (`last_input_was_touch`) and `core/viz/viz.zig` (`touch_pointer`) could read `dvui.pointerKind()` instead. - **Arrow shown, press to the tab:** inside a split's reach, over the tab drawn before it, the arrow shows but a press goes to the tab. This was already the case before this PR (see above). - **Stale comment:** `app/store/PluginStore.zig` says "last cursorSet call wins". It is the first, so its `.arrow` override doesn't do what the comment says. - **Upstream:** propose both dvui-dev changes to upstream dvui. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/Layout.zig | 8 +- backend/build.zig | 2 + backend/src/SDLBackend.zig | 63 ++++++++---- backend/src/touch_slots.zig | 123 ++++++++++++++++++++++++ core/AGENTS.md | 12 +++ core/sizing_tests.zig | 2 + core/widgets/FloatingWindowWidget.zig | 6 +- core/widgets/Split.zig | 5 +- core/widgets/Tabs.zig | 133 ++++++++++++++++++++++++++ docs/DEPENDENCIES.md | 24 ++++- plugins/workbench/src/Workspace.zig | 1 + sdk/build.zig.zon | 6 +- sdk/src/version.zig | 2 +- 13 files changed, 358 insertions(+), 29 deletions(-) create mode 100644 backend/src/touch_slots.zig diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 4b9cfd225..a202af997 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -240,11 +240,11 @@ pub const slot_keywords: []const []const u8 = &.{"slot"}; // timing, because a sidebar and a document pane travelling at different speeds reads as broken // without ever looking wrong in a screenshot. -/// A frame's layout. Made at the top of the frame, before any place draws — which is why a view -/// being carried asks for its cursor here: dvui gives the cursor to the first to ask, and the -/// text under the pointer asking for a caret comes later. +/// A frame's layout. Made at the top of the frame, before any place draws. A view being carried +/// asks for its cursor at `.drag`, above the caret of the text under the pointer, a tab's hand or +/// a split's arrow. pub fn init(host: *sdk.Host, state: *State, gpa: std.mem.Allocator, arena: std.mem.Allocator) Layout { - if (state.view_drag.active()) dvui.cursorSet(ViewDrag.cursor); + if (state.view_drag.active()) dvui.cursorSetPriority(ViewDrag.cursor, .drag); return .{ .host = host, .state = state, .gpa = gpa, .arena = arena }; } diff --git a/backend/build.zig b/backend/build.zig index a9070c44c..86ad7433e 100644 --- a/backend/build.zig +++ b/backend/build.zig @@ -72,6 +72,8 @@ pub const unit_tests = [_]struct { name: []const u8, root: []const u8 }{ // The backend's health counters: frame-time percentiles, per-frame counts, SDL's log counted // from any thread, a snapshot as ZON. .{ .name = "backend-health-tests", .root = "src/Health.zig" }, + // Which touch button each finger on the screen presses; a trackpad's fingers, none. + .{ .name = "backend-touch-slots-tests", .root = "src/touch_slots.zig" }, }; /// Where the macOS SDK is, for a macOS target that is not the host's own (`-Dtarget=aarch64-macos` diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 925b969ff..87b57fd29 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -16,6 +16,7 @@ const dvui = @import("dvui"); const GpuRenderer = @import("GpuRenderer.zig"); pub const viewport_map = @import("viewport_map.zig"); +const TouchSlots = @import("touch_slots.zig"); /// The OS windows besides the main one an app draws with (`Viewport`). pub const viewports = @import("viewports.zig"); /// `dvui.osWindow` as a window of its own from the one frame (`osWindowFloating`). @@ -68,6 +69,8 @@ begin_hook: ?*const fn (*SDLBackend) void = null, present_hook: ?*const fn (*SDLBackend) void = null, touch_mouse_events: bool = false, +/// The touch button each finger on the screen presses. +touch_slots: TouchSlots = .{}, log_events: bool = false, last_pixel_size: dvui.Size.Physical = .{ .w = 800, .h = 600 }, last_window_size: dvui.Size.Natural = .{ .w = 800, .h = 600 }, @@ -2650,6 +2653,24 @@ fn trackGeometry(self: *SDLBackend) void { self.window_geometry = .{ .x = x, .y = y, .w = w, .h = h }; } +comptime { + std.debug.assert(TouchSlots.mouse_touch_id == c.SDL_MOUSE_TOUCHID); + std.debug.assert(TouchSlots.pen_touch_id == c.SDL_PEN_TOUCHID); +} + +fn touchButton(slot: u4) dvui.enums.Button { + return @enumFromInt(@intFromEnum(dvui.enums.Button.touch0) + @as(u8, slot)); +} + +/// Lift every finger the window will not hear lift: it lost focus, is going to the background or +/// is closing. A held slot would stay held for good, and dvui would keep a finger down. +fn releaseTouches(self: *SDLBackend, win: *dvui.Window) !void { + const freed = self.touch_slots.releaseAll(); + for (0..10) |i| if (freed & (@as(u10, 1) << @intCast(i)) != 0) { + _ = try win.addEventPointer(.{ .button = touchButton(@intCast(i)), .action = .release }); + }; +} + /// Send an SDL_Event to a dvui.Window /// /// Return true if the event is to be handled by a subwindow. @@ -2829,26 +2850,30 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool } return ret; }, - if (sdl3) c.SDL_EVENT_FINGER_DOWN else c.SDL_FINGERDOWN => { - if (self.log_events) { - log.debug("event FINGERDOWN {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + // A finger on the screen presses the touch button of its slot; a trackpad's, a pen's and + // SDL's mouse touches have none (`touch_slots.zig`). + c.SDL_EVENT_FINGER_DOWN => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERDOWN {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const device = TouchSlots.deviceOf(f.touchID, c.SDL_GetTouchDeviceType(f.touchID) == c.SDL_TOUCH_DEVICE_DIRECT); + const slot = self.touch_slots.down(device, f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .press, .xynorm = .{ .x = f.x, .y = f.y } }); }, - if (sdl3) c.SDL_EVENT_FINGER_UP else c.SDL_FINGERUP => { - if (self.log_events) { - log.debug("event FINGERUP {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + c.SDL_EVENT_FINGER_MOTION => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERMOTION {d} {d} {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y, f.dx, f.dy }); + const slot = self.touch_slots.find(f.touchID, f.fingerID) orelse return false; + return try win.addEventTouchMotion(touchButton(slot), f.x, f.y, f.dx, f.dy); }, - if (sdl3) c.SDL_EVENT_FINGER_MOTION else c.SDL_FINGERMOTION => { - if (self.log_events) { - log.debug("event FINGERMOTION {d} {d} {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy }); - } - - return try win.addEventTouchMotion(.touch0, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy); + c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERUP {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const slot = self.touch_slots.up(f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .release, .xynorm = .{ .x = f.x, .y = f.y } }); + }, + c.SDL_EVENT_WILL_ENTER_BACKGROUND => { + try self.releaseTouches(win); + return false; }, if (sdl3) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_WINDOWEVENT_FOCUS_GAINED => { if (self.log_events) { @@ -2868,6 +2893,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_LOST\n", .{}); } + try self.releaseTouches(win); if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, false); } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { @@ -2908,6 +2934,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("SDL event window close\n", .{}); } + try self.releaseTouches(win); try win.addEventWindow(.{ .action = .close }); return false; }, diff --git a/backend/src/touch_slots.zig b/backend/src/touch_slots.zig new file mode 100644 index 000000000..269744d5e --- /dev/null +++ b/backend/src/touch_slots.zig @@ -0,0 +1,123 @@ +//! Which of dvui's touch buttons (`touch0` to `touch9`) each finger on the screen presses. +//! +//! dvui reads a touch button as a finger on the screen at the event's point: it sets +//! `dvui.pointerKind()` to `.touch`, and a lifted finger hovers nothing. Only a direct touch is +//! that. SDL also reports a trackpad's fingers as touches (on macOS, with a device of type +//! `SDL_TOUCH_DEVICE_INDIRECT_*`), at a point on the pad rather than in the window; the trackpad +//! already moves the mouse and clicks, so those get no slot and reach dvui as nothing. Nothing in +//! fizzy or its plugins read them: they arrived as `touch0` presses at the pad's point, mapped +//! into the window. +//! +//! SDL also registers two touch devices of its own as direct: `SDL_MOUSE_TOUCHID`, for the touches +//! it synthesizes from a mouse, and `SDL_PEN_TOUCHID`, for a pen's (`SDL_HINT_PEN_TOUCH_EVENTS`, +//! on by default). A pen already reaches dvui as the mouse; read as a finger too, a tablet stroke +//! would flip `dvui.pointerKind()` with every press. Neither is a finger (`deviceOf`). +//! +//! Each finger keeps its slot from down to up, so two fingers are two buttons (a pinch), and one +//! lifting releases its own button, not another finger's. A lift the window never hears of (it +//! lost focus, went to the background or closed mid-touch) would hold its slot for good, and +//! dvui would think a finger is still down: the backend releases every held slot then +//! (`releaseAll`). +const std = @import("std"); + +pub const Device = enum { direct, indirect }; + +/// `SDL_MOUSE_TOUCHID` and `SDL_PEN_TOUCHID` (`(SDL_TouchID)-1` and `-2`). +pub const mouse_touch_id: u64 = std.math.maxInt(u64); +pub const pen_touch_id: u64 = std.math.maxInt(u64) - 1; + +/// A touch device as dvui is to see it: a finger on the screen (`direct`, as SDL reports its type) +/// unless it is SDL's own mouse or pen device. +pub fn deviceOf(touch: u64, sdl_direct: bool) Device { + if (touch == mouse_touch_id or touch == pen_touch_id) return .indirect; + return if (sdl_direct) .direct else .indirect; +} + +const Finger = struct { touch: u64, finger: u64 }; + +fingers: [10]?Finger = @splat(null), + +const TouchSlots = @This(); + +/// A finger landed: its slot, or null for a finger dvui is not to see (not on the screen, or an +/// eleventh). +pub fn down(self: *TouchSlots, device: Device, touch: u64, finger: u64) ?u4 { + if (device != .direct) return null; + if (self.find(touch, finger)) |i| return i; + for (&self.fingers, 0..) |*slot, i| if (slot.* == null) { + slot.* = .{ .touch = touch, .finger = finger }; + return @intCast(i); + }; + return null; +} + +/// A finger moved: the slot it landed in, if it was given one. +pub fn find(self: *const TouchSlots, touch: u64, finger: u64) ?u4 { + for (self.fingers, 0..) |slot, i| if (slot) |f| { + if (f.touch == touch and f.finger == finger) return @intCast(i); + }; + return null; +} + +/// A finger lifted (or was cancelled): the slot it frees, if it had one. +pub fn up(self: *TouchSlots, touch: u64, finger: u64) ?u4 { + const i = self.find(touch, finger) orelse return null; + self.fingers[i] = null; + return i; +} + +/// Free every held slot, for a window that will not hear their lifts: a bit per slot freed, from +/// slot 0. +pub fn releaseAll(self: *TouchSlots) u10 { + var freed: u10 = 0; + for (&self.fingers, 0..) |*slot, i| if (slot.* != null) { + slot.* = null; + freed |= @as(u10, 1) << @intCast(i); + }; + return freed; +} + +test "SDL's mouse and pen touch devices are not fingers" { + try std.testing.expectEqual(.indirect, deviceOf(mouse_touch_id, true)); + try std.testing.expectEqual(.indirect, deviceOf(pen_touch_id, true)); + try std.testing.expectEqual(.direct, deviceOf(42, true)); + try std.testing.expectEqual(.indirect, deviceOf(42, false)); +} + +test "lifts the window never hears of free every slot" { + var slots: TouchSlots = .{}; + for (0..10) |i| _ = slots.down(.direct, 1, i); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); + _ = slots.up(1, 3); + try std.testing.expectEqual(@as(u10, 0b11_1111_0111), slots.releaseAll()); + try std.testing.expectEqual(@as(u10, 0), slots.releaseAll()); + // Touch works again after. + try std.testing.expectEqual(0, slots.down(.direct, 1, 99)); +} + +test "a trackpad's fingers get no slot" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(null, slots.down(.indirect, 1, 7)); + try std.testing.expectEqual(null, slots.find(1, 7)); + try std.testing.expectEqual(null, slots.up(1, 7)); +} + +test "each finger on the screen keeps its own slot until it lifts" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(0, slots.down(.direct, 1, 100)); + try std.testing.expectEqual(1, slots.down(.direct, 1, 200)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // The first lifts: its own slot is freed, the second keeps its. + try std.testing.expectEqual(0, slots.up(1, 100)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // A new finger takes the free slot. + try std.testing.expectEqual(0, slots.down(.direct, 1, 300)); + // The same finger id on another device is another finger. + try std.testing.expectEqual(2, slots.down(.direct, 2, 100)); +} + +test "an eleventh finger is not seen" { + var slots: TouchSlots = .{}; + for (0..10) |i| try std.testing.expect(slots.down(.direct, 1, i) != null); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); +} diff --git a/core/AGENTS.md b/core/AGENTS.md index e9d0574f1..d81662b95 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -28,6 +28,18 @@ paths, fuzzy matching, the LSP client, the profiler. selects instead of expanding, until you type. Fizzy's backends set `win.modifiers` from the OS before each mouse event (`backend/src/SDLBackend.zig`, `src/backend/WebBackend.zig`); a new backend must too. +- **Touch or mouse: ask dvui, never infer it.** `dvui.pointerKind()` is the window's answer, + set as the backend adds events, the same in the app, a plugin dylib and a wasm side module. + State kept by scanning events (a global, a per-frame key) differs per module and per frame. + A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a + hover look needs no touch check of its own. The native backend gives only fingers on the screen + touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse. +- **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a + frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a + carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor + only: which widget takes a press is still drawing order, so ask at `.resize` only where the + handle takes the press before what is under it. A float's edges, handled after its children, + stay at `.normal`. - **Very long lists:** dvui clamps every min size at `max_float_safe` (2e6, about 93k rows at 21.5 pt), so the scroll range silently ends early. A widget not drawn on a frame loses its min size, so a row scrolled back into view is zero-height for a frame. A virtualized list must diff --git a/core/sizing_tests.zig b/core/sizing_tests.zig index 4b488c3a7..b5dca3bda 100644 --- a/core/sizing_tests.zig +++ b/core/sizing_tests.zig @@ -12,6 +12,8 @@ test { _ = @import("widgets/Split.zig"); _ = @import("widgets/ContextWidget.zig"); + // A tab's hand beside a split's arrow, and a tapped tab's hover look. + _ = @import("widgets/Tabs.zig"); // The local copies of upstream's docking + blur (see `widgets.zig`): referenced here so they // are analysed — and their own tests run — even before anything in the app draws them. _ = @import("widgets/DockingWidget.zig"); diff --git a/core/widgets/FloatingWindowWidget.zig b/core/widgets/FloatingWindowWidget.zig index d72612aa5..2f088fcc8 100644 --- a/core/widgets/FloatingWindowWidget.zig +++ b/core/widgets/FloatingWindowWidget.zig @@ -813,7 +813,7 @@ pub fn processEventsBefore(self: *FloatingWindowWidget) void { } if (me.action == .position) { e.handle(@src(), self.data()); // don't want any widgets under this to see a hover - dvui.cursorSet(.arrow_nw_se); + dvui.cursorSetPriority(.arrow_nw_se, .resize); continue; } } @@ -890,6 +890,10 @@ pub fn processEventsAfter(self: *FloatingWindowWidget) void { } else { if (self.init_options.resize == .none) continue; } + // At `.normal`: this runs after the children, which take a press here + // first, so the edge's arrow must not outrank their cursors. The corner, + // which takes its press before them (`processEventsBefore`), asks at + // `.resize`. dvui.cursorSet(self.cursorFor(dp)); }, else => {}, diff --git a/core/widgets/Split.zig b/core/widgets/Split.zig index b1c5b5038..5d302410d 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -435,7 +435,10 @@ pub fn grab(self: *Split, container: *dvui.WidgetData) Grab { }; } }, - .position => dvui.cursorSet(cursor), + // Over the edge of a neighbour asking for a cursor of its own (a tab's hand), the + // split's arrow says what a press beside the sash does: it outranks a hover cursor + // whichever was drawn first. The cursor only: which widget takes a press is unchanged. + .position => dvui.cursorSetPriority(cursor, .resize), else => {}, } } diff --git a/core/widgets/Tabs.zig b/core/widgets/Tabs.zig index 1c7d22ce3..56dc86666 100644 --- a/core/widgets/Tabs.zig +++ b/core/widgets/Tabs.zig @@ -204,10 +204,18 @@ pub const Tab = struct { pub fn deinit(self: *Tab) void { _ = self.clicked(); + handCursor(self.box.data()); self.box.deinit(); } }; +/// A tab's cursor, for every tab strip (this one's, and one that draws its own tabs, as the +/// workbench's document tabs): the hand under the pointer, and while the tab is held. A split +/// beside the strip outranks it where their reaches overlap (`Split.grab`). +pub fn handCursor(wd: *dvui.WidgetData) void { + if (dvui.captured(wd.id) or @import("../widgets.zig").hovered(wd)) dvui.cursorSet(.hand); +} + /// One tab, keyed by `key`. `name` is what it is called — the view's title — for a screen reader /// or a script, which a rail of icons does not otherwise show; it must outlive the frame. pub fn tab(self: *Tabs, src: std.builtin.SourceLocation, key: usize, selected: bool, name: []const u8) Tab { @@ -260,3 +268,128 @@ pub fn deinit(self: *Tabs) void { } self.outer.deinit(); } + +// ── Tests ─────────────────────────────────────────────────────────────────────────────────── +// +// Against dvui's testing backend: the cursor and the hover look are decided by dvui's event +// routing (`cursorSetPriority`, the `.position` event a lifted finger leaves off every window). + +const Split = @import("Split.zig"); + +var t_cursor: dvui.enums.Cursor = .arrow; +var t_hovered = false; +var t_tab_captured = false; +var t_split_captured = false; +var t_tab_rect: dvui.Rect.Physical = .{}; +var t_sep_x: f32 = 0; +var t_scale: f32 = 1; + +/// A rail beside a split: the sidebar's icons next to the explorer's edge, where a cell runs the +/// rail's full width up to the split. +fn railFrame() !dvui.App.Result { + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer row.deinit(); + { + var left = dvui.box(@src(), .{ .dir = .vertical }, .{ .min_size_content = .{ .w = 100 }, .expand = .vertical }); + defer left.deinit(); + var strip: Tabs = .init(@src(), .{ .dir = .vertical, .scroll = false, .outer = .{ .expand = .both } }); + defer strip.deinit(); + var t = strip.tab(@src(), 1, false, "one"); + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 20, .h = 40 } }); + _ = t.clicked(); + t_tab_rect = t.box.data().borderRectScale().r; + t_tab_captured = dvui.captured(t.box.data().id); + t_hovered = @import("../widgets.zig").hovered(t.box.data()); + t.deinit(); + } + var divider = Split.init(@src(), .horizontal, 0, null); + { + const srs = divider.box.data().borderRectScale(); + t_sep_x = srs.r.x + srs.r.w / 2; + t_scale = srs.s; + _ = divider.grab(row.data()); + t_split_captured = dvui.captured(divider.box.data().id); + } + divider.deinit(); + { + var right = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer right.deinit(); + } + t_cursor = dvui.currentWindow().cursorRequested(); + return .ok; +} + +test "a tab shows the hand" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y } }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.hand, t_cursor); + try std.testing.expect(t_hovered); + + // Held, it keeps the hand (its hover look gives way to the press). + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expectEqual(.hand, t_cursor); +} + +test "beside a split, the split's arrow wins over the hand, and a press goes where it did" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + // Over the tab's last few points, inside the split's reach (`Split.handle_size` from its + // centre line). + const p: dvui.Point.Physical = .{ .x = t_sep_x - (Split.handle_size - 2) * t_scale, .y = t_tab_rect.center().y }; + try std.testing.expect(t_tab_rect.contains(p)); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.arrow_w_e, t_cursor); + + // The cursor only: the tab, drawn first, still takes the press there, as it did before it + // asked for a cursor at all. + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expect(!t_split_captured); + _ = try cw.addEventMouseButton(.left, .release); + _ = try dvui.testing.step(railFrame); + + // On the sash itself, past the tab, the split takes it. + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_sep_x, .y = p.y } }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_split_captured); + try std.testing.expect(!t_tab_captured); +} + +test "a tapped tab is left without a hover look" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + const p: dvui.Point.Physical = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y }; + const xynorm: dvui.Point = .{ .x = p.x / cw.rect_pixels.w, .y = p.y / cw.rect_pixels.h }; + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.touch, dvui.pointerKind()); + try std.testing.expect(!t_hovered); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(!t_hovered); + + // A mouse over the same tab lights it. + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.mouse, dvui.pointerKind()); + try std.testing.expect(t_hovered); +} diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 66dcdc99b..a99ad043a 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -110,6 +110,26 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack blocks are drawn after draw-last strokes, before or after they closed". +11. **Window: the pointer kind, and a lifted finger hovers nothing** (`e04bccc`). + `dvui.pointerKind()` is `.touch` or `.mouse`: what the last pointer event came from, set as the + backend adds events (a touch button is `.touch`; mouse motion, a mouse button or the wheel is + `.mouse`). It is the window's, so the app and every plugin dylib or wasm side module read the + same answer. The window keeps which touch buttons are down; after a touch with none, the + frame's `.position` event is off every window and targets no widget, so a tapped button, menu + row or tab is not left lit. Fizzy's hover looks and the corner button's tap (#555) read it. + Upstream: worth proposing. Test: "a finger that lifts hovers nothing, and a mouse hovers again". +12. **cursorSet: a priority, so a resize handle's cursor wins** (`20e56f1`). + `dvui.cursorSetPriority(cursor, priority)` keeps the highest asked in a frame (between + equals, the first); `cursorSet` asks at `.normal`. Resize handles ask at `.resize` (dvui's + `PanedWidget`, `GridWidget` columns, a floating window's bottom-right corner; fizzy's + `Split`), a carried thing at `.drag` (fizzy's view drag), so a hover cursor such as a tab's + hand never takes a split's arrow. Which widget takes a press is unchanged. Upstream: worth + proposing. Test: "cursorSetPriority: the higher asked wins, whichever asked first". +13. **FloatingWindowWidget: edges ask for their arrow at normal priority** (`40e664c`). Its + edges are handled after the window's children, which take a press there first, so their arrow + stays at `.normal` and a child under the band keeps both its cursor and its press. Only the + bottom-right corner, handled before the children, asks at `.resize`. Upstream: with 12. + Each of 5 and 6 is also a branch of its own on upstream `main` (`window-draw-retained`, `screens-set`), from which the upstream PR is opened. @@ -121,7 +141,9 @@ moves, so the second pin of one version takes a suffix, as the SDL fork's tags d (never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-1` → `e2c9a85` (item 9 alone, never pinned); `fizzy-sdk-0.2.30-2` → `fdc03d4` (pinned by #540); `fizzy-sdk-0.2.30-3` → `cc60612` (never -pinned); `fizzy-sdk-0.2.30-4` → `44010a3`, what `sdk/build.zig.zon` pins now. +pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` → +`20e56f1` (never pinned on `main`); `fizzy-sdk-0.2.30-6` → `40e664c`, what `sdk/build.zig.zon` +pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends (`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 2de84b1a1..56b2feb2b 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -311,6 +311,7 @@ fn drawTabs(self: *Workspace, region: sdk.Host.Region, tabs: []const *sdk.Surfac // Not under a carried tab: what is under it is where it would go in, not a tab to close. const tab_hovered = carried == null and core.widgets.hovered(hbox.data()); + core.widgets.Tabs.handCursor(hbox.data()); // A tab not on show lifts to the window's hover fill under the pointer. hbox.data().borderAndBackground(.{ .fill_color = if (tab_hovered and !is_selected) diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 20b9c4980..bd572d9fe 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -39,9 +39,9 @@ // `fizzy-sdk-` tag in the fork before the pin lands. `fizzy-dev` itself stays a // working branch and can be rebased freely — the tag, never. .dvui = .{ - // fizzy-sdk-0.2.30-4 - .url = "https://github.com/foxnne/dvui-dev/archive/44010a31274fa49ce56c9cf96dc2706dffd09a30.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmdtlBAF4FZgHirDe3ghMmGMS46WlRhlT0Sx_TmEc", + // fizzy-sdk-0.2.30-6 + .url = "https://github.com/foxnne/dvui-dev/archive/40e664ce21d4b6d2018b13ec36f0567d33e25498.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmSKDBAFEplBpuAqF3iL0FKaES5SrmKYLRWRyyWLJ", //.path = "../../dvui-dev", }, .zf = .{ diff --git a/sdk/src/version.zig b/sdk/src/version.zig index a9aac1872..d04e2fc42 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x70dba3df7455c34f; +pub const recorded_sdk_shape_fingerprint: u64 = 0x35bac8377071cd23; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From b4478bee5dfc15ba690194acecf1093562cec32e Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:11:15 -0500 Subject: [PATCH 29/31] app: a file opened as text moves to its plugin once that plugin loads (#513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No plan issue: a bug found while driving a sandbox. ## What changes `fizzy --profile /work/art.pixi` opened the sprite in the text plugin, as binary garbage, and it stayed there after pixi loaded. Opening the same file later through `fizzy.openFiles` went to pixi. **The cause is not startup ordering.** User plugins load synchronously in `Editor.postInit`, before the first frame drains argv and the single-instance handoff (`singleton.drainPending`), so making those opens wait for user plugins would change nothing. The file goes to text when pixi is *not loaded* at that moment, and nothing revisits it when pixi arrives later. Reproduced here with a Debug fizzy that refuses a ReleaseFast pixi (`AbiOptimizeMismatch`), followed by a compatible build dropped into `plugins/pixi/`, which the watcher then loads (`reconcileFailedPluginBinaries`). The same gap applies to an undecided drop-in loaded from the store, a store install, and an enable. **The fix:** whenever who opens which extension may have changed (`rebuildExtensionOwnerCache`, which every arrival, update, enable, uninstall and File Types edit already calls), the next frame runs `Editor.rehomeFallbackDocuments`. It hands each document the fallback editor holds *only as the fallback* to the plugin that resolves for its extension now. The document is detached and reopened in the **same tab** (`Placement.in_place`: it takes the old tab's slot, shown only if that tab was showing, and focus stays where the user left it). It is left alone when: - it has unsaved changes, - it is held by an `sdk.Claim`, - there is nothing to read again (no file on disk and not on a mount), - or its extension is one the fallback lists in its own `fileTypes`. The pass waits while the file-type question (`FileTypeDefaults`) is up, so that answer decides where the document goes. The dialog's line "files already open stay where they are" now says that files open as plain text only because nothing else could open them move to the user's choice. ## SDK impact - [x] None ## Verified - [x] macOS: in a sandbox profile, launched `fizzy --profile

/work/art.pixi` with a ReleaseFast pixi (refused, so it opens as text). Then I dropped in a Debug pixi built against this tree. The log shows `plugin watcher: 'pixi' loaded …` and then `art.pixi: moving from Text to Pixi, which opens it now`. Screenshot: one `art.pixi` tab, drawn by pixi. - [x] Test: a new block in the headless whole-editor test unregisters the image plugin, opens a `.tga` (the fallback holds it), registers image again, and checks that the document moved to image with its tab taken over and none added. With the re-home disabled it fails (`expected Plugin@…, found Plugin@…`). - Gates, all run locally: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`: pass. - `zig build test`: 99/99 steps. - `zig build test-integration`: 53/53 steps, 561/561 tests. It prints `failed command:` because of warnings: the existing replay warning, plus text's "binary bytes" warning for the test's `.tga`. - `zig build test-sdk-version`: pass. - `zig build check-tests-reached`: pass. - [ ] Windows: - [ ] Linux: integration test only (CI) - [ ] Web: builds; the disk check is skipped there (a web document is re-read only through its mount) ## Follow-ups None needed for this bug. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- src/editor/Editor.zig | 72 +++++++++++++++++++++++++ src/editor/Openings.zig | 2 +- src/editor/dialogs/FileTypeDefaults.zig | 3 +- tests/integration.zig | 46 ++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 7399fa5fc..4db4ddf0d 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -243,6 +243,11 @@ pending_native_menu_item_indices_len: u8 = 0, /// When set, next `tick` runs `warmupDrawingComposites` on the active file (after open or drawing-tool select). pending_composite_warmup: bool = false, +/// Set whenever who opens which extension may have changed (`rebuildExtensionOwnerCache`): the +/// next `tick` moves documents the fallback editor holds only as the fallback to the plugin that +/// owns them now (`rehomeFallbackDocuments`). +rehome_pending: bool = false, + /// When set, the next frame closes the explorer and the bottom panel, as their own close buttons /// would. Set by the page's `?show=`: an embed that opens on one file is about that file, and the /// tree and the panel only take room from it. Done in the frame (`layout.zig`), not from the @@ -1093,6 +1098,7 @@ pub fn rebuildExtensionOwnerCache(editor: *Editor) void { }; } } + editor.rehome_pending = true; } /// **The only function that writes `.extensions` to disk**, and it runs only in direct response @@ -4134,6 +4140,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); + // Once the file-type question, if one is up, has its answer: that answer is who they go to. + if (editor.rehome_pending and !Dialogs.FileTypeDefaults.active(dvui.currentWindow())) { + editor.rehome_pending = false; + editor.rehomeFallbackDocuments(); + } editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -5493,6 +5504,9 @@ pub const Placement = struct { take_slot_of: ?[]const u8 = null, /// `OpenOptions.beside`. beside: bool = false, + /// Take `take_slot_of`'s place without bringing it forward: it shows only if that tab was + /// showing (`rehomeFallbackDocuments`). + in_place: bool = false, }; /// Answer fizzy's own commands that answer a frame or more later (`fizzy.screen`, `fizzy.settled`), @@ -5760,6 +5774,64 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement_in: P return true; } +/// Hand each document the fallback editor holds only as the fallback (its extension is none of +/// the fallback's own) to the plugin that opens that extension now, in the same tab. A file +/// opened before its plugin was there — launched with it while the plugin was refused, before a +/// dropped-in build was loaded, before a store install — is otherwise stuck as text until it is +/// closed and opened again. One with unsaved changes, one someone is working on (`sdk.Claim`), and +/// one with nothing on disk to read again stay as they are; so does one whose extension the +/// fallback offers in its own `fileTypes` (bundled text offers none). +pub fn rehomeFallbackDocuments(editor: *Editor) void { + const fallback = editor.app.host.fallback_editor orelse return; + const gpa = editor.app.gpa; + // Collected first: each move takes a document out of `open_files`. + var ids: std.ArrayListUnmanaged(u64) = .empty; + defer ids.deinit(gpa); + for (editor.app.open_files.values()) |doc| { + if (doc.owner != fallback) continue; + const ext = std.fs.path.extension(doc.owner.documentPath(doc)); + const owner = editor.app.host.pluginForExtension(ext) orelse continue; + if (owner == fallback) continue; + const own = for (fallback.fileTypes()) |e| { + if (std.ascii.eqlIgnoreCase(e, ext)) break true; + } else false; + if (own) continue; + if (doc.owner.isDirty(doc) or editor.app.host.documentClaim(doc.id) != null) continue; + ids.append(gpa, doc.id) catch return; + } + for (ids.items) |id| editor.rehomeDocument(id) catch |err| + dvui.log.warn("could not move a document to the plugin that opens it now: {t}", .{err}); +} + +fn rehomeDocument(editor: *Editor, id: u64) !void { + const doc = editor.app.open_files.get(id) orelse return; + const gpa = editor.app.gpa; + const path = try gpa.dupe(u8, doc.owner.documentPath(doc)); + defer gpa.free(path); + // Read again from where it came from: a mount, or the disk (which the web has none of). + if (!editor.doc_io.owns(path)) { + if (comptime builtin.target.cpu.arch == .wasm32) return; + std.Io.Dir.cwd().access(dvui.io, path, .{}) catch return; + } + const ds = editor.doc_surfaces.get(id) orelse return; + const slot = try gpa.dupe(u8, ds.id); + defer gpa.free(slot); + const grouping = doc.owner.documentGrouping(doc); + const preview = editor.documentIsPreview(id); + dvui.log.info("{s}: moving from {s} to {s}, which opens it now", .{ + std.fs.path.basename(path), + doc.owner.display_name, + (editor.app.host.pluginForExtension(std.fs.path.extension(path)) orelse doc.owner).display_name, + }); + // Out of fizzy with its tab left where it is, for the new owner's placeholder to take. + editor.app.host.emitZon("fizzy.document.closed", .{ .path = path }); + editor.detachFileID(id); + // Whichever open the user asked for last is still the one focused when it lands. + const focus = editor.last_load_request_path; + defer editor.last_load_request_path = focus; + _ = try editor.openPath(path, grouping, .{ .preview = preview, .take_slot_of = slot, .in_place = true }); +} + /// Stop loading `path`, wherever it is loading from. Its document never arrives. pub fn cancelOpen(editor: *Editor, path: []const u8) void { if (editor.loading_jobs.get(path)) |job| job.cancelled.store(true, .monotonic); diff --git a/src/editor/Openings.zig b/src/editor/Openings.zig index 52838a7e3..04991701e 100644 --- a/src/editor/Openings.zig +++ b/src/editor/Openings.zig @@ -124,7 +124,7 @@ pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, if (wb.swapTabId(old, o.surface_id)) |ws| { // A replaced preview is replaced by what was just clicked, so it shows; a restored // slot shows only if it was showing. - if (take_slot_of != null or was_selected) self.show(editor, o, ws); + if ((take_slot_of != null and !placement.in_place) or was_selected) self.show(editor, o, ws); host.refresh(); return; } diff --git a/src/editor/dialogs/FileTypeDefaults.zig b/src/editor/dialogs/FileTypeDefaults.zig index 7e7e043cd..1469f015b 100644 --- a/src/editor/dialogs/FileTypeDefaults.zig +++ b/src/editor/dialogs/FileTypeDefaults.zig @@ -224,7 +224,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool { var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); defer tl.deinit(); tl.addText( - "Whatever you choose is remembered, and files already open stay where they are. " ++ + "Whatever you choose is remembered. Files open as plain text only because nothing " ++ + "else could open them move to your choice; any other open file stays where it is. " ++ "You can change any of this later in Settings › File Types.", .{ .font = body.larger(-1.0) }, ); diff --git a/tests/integration.zig b/tests/integration.zig index 5601e5ae9..cf0f6b3a8 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8961,6 +8961,52 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(editor.workbench.pending_new_file_path == null); } + // A file opened before its plugin is there (launched with it while the plugin was refused, + // before a dropped-in build was loaded) is the fallback's, as text, until the plugin arrives; + // then it moves to it, in the same tab (`Editor.rehomeFallbackDocuments`). + { + const host = &editor.app.host; + const image = host.pluginById("image") orelse return error.TestUnexpectedResult; + host.unregisterPlugin(image); + editor.rebuildExtensionOwnerCache(); + // One white pixel, uncompressed true-colour TGA. + const tga = [_]u8{ 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8, 255, 255, 255, 255 }; + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "project/late.tga", .data = &tga }); + const late = try std.fs.path.join(gpa, &.{ project, "late.tga" }); + defer gpa.free(late); + const ownerOf = struct { + fn ownerOf(e: *fizzy.Editor, path: []const u8) ?*sdk.Plugin { + const d = e.docFromPath(path) orelse return null; + return d.owner; + } + }.ownerOf; + _ = try editor.openFilePath(late, editor.workbench.currentGroupingID()); + for (0..200) |_| { + if (ownerOf(editor, late) != null) break; + _ = try dvui.testing.step(headlessFrame); + } + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(host.fallback_editor, ownerOf(editor, late)); + const docs_before = editor.app.open_files.count(); + + try host.registerPlugin(image); + editor.rebuildExtensionOwnerCache(); + for (0..200) |_| { + if (ownerOf(editor, late) == image) break; + _ = try dvui.testing.step(headlessFrame); + } + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(image, ownerOf(editor, late).?); + try std.testing.expectEqual(docs_before, editor.app.open_files.count()); + // Its tab was taken over, not added beside it: one tab for the file, the image's. + const surface = try sdk.document.surfaceId(host.arena(), image.id, late); + try std.testing.expect(editor.workbench.hasTabAnywhere(surface)); + const text_surface = try sdk.document.surfaceId(host.arena(), host.fallback_editor.?.id, late); + try std.testing.expect(!editor.workbench.hasTabAnywhere(text_surface)); + try editor.closeFileID(editor.docFromPath(late).?.id); + try dvui.testing.settle(headlessFrame); + } + // A quit straight after a pane slid shut (`snapshotClosingPane`): the pane still holds its // picture, and the quit comes after the last frame, when nothing can be freed at a frame's end. { From c7e55b2949634b2f25ac52ac780f7bcac2081ad3 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:33:20 -0500 Subject: [PATCH 30/31] text: going back is a step, so an edit after it loses nothing (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#10 — `plans/HISTORY_TIMELINE_PLAN.md` step 4 ("text, then pixi: going back is a step"), text's half. It is also what `plans/SHARED_HISTORY_PLAN.md` step 6 ("Go back to before Claude's change") waits on. ## What changes Going back over twenty steps to look at something, then typing, used to throw the twenty away. Now the line loses nothing and stays one line: - **`textcore/History.keepGoneBack`**, where `end` and `push` cleared redo, handles an edit that lands after going back: - the steps gone back over go back on the undo stack as they were; - each one's reverse follows, newest first (`Transaction.inverted`); - then the edit lands. The text doesn't change, because the reverses bring it to where you went back to. Dragging the timeline to a step gone back over brings it back like any other. - `Committed.back` says how many steps were gone back over. - Each reverse carries `back_to`, the step the jump went to. - An edit with nothing gone back over is unchanged. - **No branches and no new hook members.** `historyLine`/`historyGoTo` already say everything. A reverse is titled **"Back to Typing"** (its target's own kind, or "the Start"), so a run of them reads as one move. - **Credit for a jump belongs to whoever went back** (SHARED_HISTORY_PLAN, "Going back is the jumper's"). `Document.undo` remembers `Host.acted()`. When an edit later makes the jump steps, they are reported under that author with `actAgain`: the restored steps as `redone`, the reverses as `added`, each with its own `top`. So Claude's next write after the person went back never makes Claude the author of taking back the person's work. - **`Host.acted()`** now falls back to the frame's input author (the person at the keys) when nothing has called `act`, matching `author()`. Before, a person's undo stored "nobody". This is a behaviour change to an SDK method; the fingerprint does not move. ## What it changes elsewhere - The recording test's step sequence for an edit after going back is now `…undone, redone, added, added` instead of `…undone, added`. Replaying the recorded steps into a second document still gives the same text. - **Tracks:** a track whose start was undone past and edited over used to refuse to export, because its start was gone from the history. Its start is now still on the line, so it exports, and the test now asserts that. Starting over (`restarted`, a restore) still loses a track's start, so the refusal path itself is unchanged. ## Not in this PR - **The plan's cap on line length** ("a cap per document drops the oldest steps"). The line now grows by the steps gone back over each time. The seam is `keepGoneBack`: a cap would drop from the bottom of `undo_stack` there. - **Pixi's half,** its own PR next. ## SDK impact - [x] None for the fingerprint (`test-sdk-version` passes). `Host.acted()` returns the input author when there is no explicit `act`. ## Verified - **Unit (textcore):** - an edit after going back two steps: nothing to redo; six steps on the line (three, two returned plus their reverses, the edit); `back_to` set on the reverses; and every point on the line goes to the right text ("ab\n", "ab", "abc", "abcz"); - an edit with nothing gone back over adds only itself. - **Integration** (`headless: the whole editor comes up…`): Claude writes, the person undoes it, Claude writes again. - Claude's first write is still on the line. - The reverse is titled "Back to …" and is the person's (`stepAuthor`). - Claude's new write is Claude's. - Going to the first write shows its text. - **Gates on macOS:** `check-fmt`, `zig build test` (631/632, 1 skipped), `test-integration` (54/54 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- plugins/text/plugin.zig | 17 ++- plugins/text/src/Document.zig | 34 +++++- plugins/text/src/textcore/History.zig | 131 +++++++++++++++++++--- plugins/text/src/textcore/Transaction.zig | 16 +++ sdk/src/Host.zig | 5 +- tests/integration.zig | 53 ++++++++- 6 files changed, 224 insertions(+), 32 deletions(-) diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 5b1ca1eee..3a9110339 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -403,12 +403,23 @@ fn historyLine(_: *anyopaque, handle: DocHandle, arena: std.mem.Allocator) anyer const doc = docFrom(handle) orelse return error.DocumentNotFound; const steps = try doc.history.line(arena); const entries = try arena.alloc(sdk.Plugin.HistoryLine.Entry, steps.len); - for (steps, entries) |st, *e| e.* = .{ .id = st.step, .title = switch (st.kind) { + for (steps, entries) |st, *e| e.* = .{ .id = st.step, .title = kindTitle(st.kind) }; + // A step of a jump back reads as the move it is part of: "Back to Typing". + for (steps, entries) |st, *e| { + const to = st.back_to orelse continue; + const what = for (steps) |o| { + if (o.step == to) break kindTitle(o.kind); + } else "the Start"; + e.title = try std.fmt.allocPrint(arena, "Back to {s}", .{what}); + } + return .{ .version = doc.history.version, .entries = entries, .at = doc.history.undo_stack.items.len }; +} +fn kindTitle(kind: @import("src/textcore/textcore.zig").History.EditKind) []const u8 { + return switch (kind) { .typing_other, .typing_first_space, .typing_consecutive_space => "Typing", .deleting_left, .deleting_right => "Delete", .other => "Edit", - } }; - return .{ .version = doc.history.version, .entries = entries, .at = doc.history.undo_stack.items.len }; + }; } fn historyGoTo(_: *anyopaque, handle: DocHandle, id: u64) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index d1153b4e9..00e432a23 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -168,6 +168,9 @@ preview_split_ratio_user: f32 = 0.5, /// Undo/redo history — see `textcore.History` for the capture + grouping strategy. history: tc.History = .{}, +/// Who last went back in the history (`undo`): the steps an edit after it adds for the jump are +/// theirs (`recordGoneBack`). +went_back_by: sdk.Host.Acting = .{}, /// `history.topOpId()` as of the last successful save; the document is dirty exactly when /// the two differ. Deliberately an id, not `undo.items.len` — a length can return to its /// saved value via undo-then-new-edit while content differs from disk, but an id can't: @@ -550,7 +553,9 @@ pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) pub fn undo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyUndo(gpa, &self.text) orelse return; - if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true); + // Whoever goes back owns the jump, when an edit later makes it steps (`recordGoneBack`). + self.went_back_by = sdk.host().acted(); + if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true, null); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -562,7 +567,7 @@ pub fn undo(self: *Document) void { pub fn redo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyRedo(gpa, &self.text) orelse return; - if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false); + if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false, null); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -579,16 +584,33 @@ fn recordStep(self: *const Document, step: sdk.Step) void { sdk.host().recordStep(self.id, step); } -/// What `history.end` or `pushComplete` did, reported as a step. +/// What `history.end` or `pushComplete` did, reported as a step: after the steps an edit after +/// going back put on the line (`recordGoneBack`). pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { - self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false); + if (c.back > 0) self.recordGoneBack(c.back); + self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false, null); +} + +/// The steps an edit after going back put on the line below it (`tc.History.keepGoneBack`): the +/// `n` gone back over, applied again, then their reverses. Reported as whoever went back, so an +/// agent's next edit never makes it the author of taking back the person's work. +fn recordGoneBack(self: *const Document, n: usize) void { + const host = sdk.host(); + const outer = host.actAgain(self.went_back_by); + defer host.endAct(outer); + // Below the commit: the reverses (1..n), then the steps gone back over (n+1..2n). Oldest first. + var i: usize = 2 * n; + while (i > 0) : (i -= 1) { + const s = self.history.appliedBelowTop(i) orelse continue; + if (i > n) self.recordEdits(.redone, s.step, s.edits, s.sel_after, false, s.step) else self.recordEdits(.added, s.step, s.edits, s.sel_after, false, s.step); + } } /// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` /// the selection they leave. -fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { +fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool, on_top: ?u64) void { // Every step reaches the host, which keeps who made it; its bytes only while it is recorded. - const top: u64 = if (self.history.lastApplied()) |r| r.step else 0; + const top: u64 = on_top orelse if (self.history.lastApplied()) |r| r.step else 0; if (!sdk.host().recording(self.id)) return self.recordStep(.{ .kind = kind, .id = id, .top = top }); const gpa = sdk.allocator(); var out: std.Io.Writer.Allocating = .init(gpa); diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index 5f00dbebb..18e6fffd4 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -113,6 +113,9 @@ const Entry = struct { /// The step's own id, given when it is pushed and kept through merges (unlike `id`): what a /// timeline names it by (`line`, `stepsTo`). step: u64, + /// A step that takes another back, appended when an edit landed after going back + /// (`keepGoneBack`): the step the jump went back to, 0 before the first. + back_to: ?u64 = null, fn deinit(self: *Entry, gpa: Allocator) void { self.txn.deinit(gpa); @@ -196,6 +199,9 @@ pub const Committed = struct { /// Valid until the history next changes. edits: []const Transaction.Edit, sel_after: Range, + /// How many steps had been gone back over (undone) when it landed: they are on the line + /// again, followed by their reverses, just below it (`keepGoneBack`, `goneBack`). + back: usize = 0, }; /// A step as the history holds it: its id, its edits, and the selection either side. Valid until @@ -261,9 +267,9 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Co .other; const kind = classify(removed_total, inserted_total, pos, p.cursor_before, prev_kind); - self.clearRedo(gpa); + const back = self.keepGoneBack(gpa); - const can_merge = may_join and self.group_open and + const can_merge = back == 0 and may_join and self.group_open and self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); @@ -301,7 +307,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Co }; self.next_id += 1; const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; - committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after }; + committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after, .back = back }; } self.group_open = true; self.version += 1; @@ -332,18 +338,60 @@ pub fn pushComplete( /// Take `txn`, already applied, as a step of its own: a recorded step played into the document /// (`Transaction.applyStepTo`). pub fn push(self: *History, gpa: Allocator, txn: Transaction) !Committed { - self.clearRedo(gpa); + const back = self.keepGoneBack(gpa); try self.undo_stack.append(gpa, .{ .txn = txn, .kind = .other, .id = self.next_id, .step = self.next_id }); self.next_id += 1; self.group_open = false; self.version += 1; const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; - return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after }; -} - -fn clearRedo(self: *History, gpa: Allocator) void { + return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after, .back = back }; +} + +/// Going back is a step (`plans/HISTORY_TIMELINE_PLAN.md`): before an edit lands after going +/// back, the steps gone back over (the redo stack) go back on the line as they were, then each +/// one's reverse, newest first. The text is where it was gone back to, as before, but nothing on +/// the line is lost: dragging to a step gone back over brings it back like any other. Answers how +/// many steps were gone back over; the line grew by twice that. Out of memory, they are dropped, +/// as redo always was. +fn keepGoneBack(self: *History, gpa: Allocator) usize { + const gone = self.redo_stack.items; + if (gone.len == 0) return 0; + const back_to: u64 = if (self.lastApplied()) |s| s.step else 0; + kept: { + const reverses = gpa.alloc(Transaction, gone.len) catch break :kept; + defer gpa.free(reverses); + // The redo stack holds the furthest step gone back over first: its reverse comes first. + var made: usize = 0; + for (gone, reverses) |e, *r| { + r.* = e.txn.inverted(gpa) catch break; + made += 1; + } + const room = if (made < gone.len) false else if (self.undo_stack.ensureUnusedCapacity(gpa, gone.len * 2)) true else |_| false; + if (!room) { + for (reverses[0..made]) |*r| r.deinit(gpa); + break :kept; + } + const n = gone.len; + // As they were, the nearest first. + while (self.redo_stack.pop()) |e| self.undo_stack.appendAssumeCapacity(e); + for (reverses) |r| { + self.undo_stack.appendAssumeCapacity(.{ .txn = r, .kind = .other, .id = self.next_id, .step = self.next_id, .back_to = back_to }); + self.next_id += 1; + } + self.version += 1; + return n; + } + std.log.err("History: the steps gone back over were dropped: out of memory", .{}); for (self.redo_stack.items) |*e| e.deinit(gpa); self.redo_stack.clearRetainingCapacity(); + return 0; +} + +/// The `i`th step below the top of the line (0 is the top), as `lastApplied` gives the top: how +/// a caller reads the steps `keepGoneBack` added below a commit. +pub fn appliedBelowTop(self: *const History, i: usize) ?StepRef { + const items = self.undo_stack.items; + return if (i >= items.len) null else .of(&items[items.len - 1 - i]); } // -- replay --------------------------------------------------------------------------------------- @@ -393,15 +441,15 @@ pub fn applyRedo(self: *History, gpa: Allocator, text: *std.ArrayList(u8)) ?Rang // -- the line ------------------------------------------------------------------------------------- /// One step of `line`. -pub const Step = struct { step: u64, kind: EditKind }; +pub const Step = struct { step: u64, kind: EditKind, back_to: ?u64 = null }; /// Every step, oldest first: the undo stack, then the redo stack from the next redo on. The /// first `undo_stack.items.len` are applied. pub fn line(self: History, arena: Allocator) ![]Step { const out = try arena.alloc(Step, self.undo_stack.items.len + self.redo_stack.items.len); - for (self.undo_stack.items, 0..) |e, i| out[i] = .{ .step = e.step, .kind = e.kind }; + for (self.undo_stack.items, 0..) |e, i| out[i] = .{ .step = e.step, .kind = e.kind, .back_to = e.back_to }; const redo = self.redo_stack.items; - for (redo, 0..) |e, i| out[out.len - 1 - i] = .{ .step = e.step, .kind = e.kind }; + for (redo, 0..) |e, i| out[out.len - 1 - i] = .{ .step = e.step, .kind = e.kind, .back_to = e.back_to }; return out; } @@ -634,15 +682,68 @@ test "merging still bumps the id so dirty tracking stays correct" { try t.expect(h.hist.topOpId() != id_after_save); } -test "a new edit after undo clears the redo stack" { +test "an edit after going back keeps what was gone back over, then its reverse" { var h: Harness = .{ .gpa = t.allocator }; defer h.deinit(); + var arena: std.heap.ArenaAllocator = .init(t.allocator); + defer arena.deinit(); + + try h.type_("abc"); // "abc" + try h.backspace(1); // "ab" + try h.type_("\n"); // "ab\n" + const before = try h.hist.line(arena.allocator()); + try t.expectEqual(@as(usize, 3), before.len); - try h.type_("abc"); _ = h.undo(); - try t.expect(h.hist.canRedo()); - try h.type_("z"); + _ = h.undo(); + try t.expectEqualStrings("abc", h.text.items); + h.hist.begin(.collapsed(h.cursor)); + try h.text.insertSlice(h.gpa, h.cursor, "z"); + h.hist.note(h.gpa, h.cursor, "", "z"); + h.cursor += 1; + const c = h.hist.end(h.gpa, .collapsed(h.cursor), true).?; + try t.expectEqualStrings("abcz", h.text.items); + // Nothing to redo, and nothing lost: the two steps gone back over, their reverses, the edit. try t.expect(!h.hist.canRedo()); + try t.expectEqual(@as(usize, 2), c.back); + try t.expect(!c.joined); + const after = try h.hist.line(arena.allocator()); + try t.expectEqual(@as(usize, 6), after.len); + for (before, after[0..3]) |a, b| try t.expectEqual(a.step, b.step); + for (after[3..5]) |r| try t.expectEqual(@as(?u64, before[0].step), r.back_to); + try t.expectEqual(@as(?u64, null), after[5].back_to); + // The commit's reverses sit just below it, the step gone back to's reverse nearest. + try t.expectEqual(after[4].step, h.hist.appliedBelowTop(1).?.step); + + // Every point on the line is still there to go to. + const goTo = struct { + fn f(hh: *Harness, step: u64) void { + const n = hh.hist.stepsTo(step).?; + for (0..@abs(n)) |_| _ = if (n < 0) hh.undo() else hh.redo(); + } + }.f; + goTo(&h, before[2].step); + try t.expectEqualStrings("ab\n", h.text.items); + goTo(&h, after[3].step); + try t.expectEqualStrings("ab", h.text.items); + goTo(&h, before[0].step); + try t.expectEqualStrings("abc", h.text.items); + goTo(&h, after[5].step); + try t.expectEqualStrings("abcz", h.text.items); +} + +test "an edit with nothing gone back over adds only itself" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + try h.type_("ab"); + h.hist.closeGroup(); + h.hist.begin(.collapsed(h.cursor)); + try h.text.insertSlice(h.gpa, h.cursor, "\n"); + h.hist.note(h.gpa, h.cursor, "", "\n"); + h.cursor += 1; + const c = h.hist.end(h.gpa, .collapsed(h.cursor), true).?; + try t.expectEqual(@as(usize, 0), c.back); + try t.expectEqual(@as(usize, 2), h.steps()); } test "undo/redo round-trips a long mixed session" { diff --git a/plugins/text/src/textcore/Transaction.zig b/plugins/text/src/textcore/Transaction.zig index f1e66870f..7d3ef8215 100644 --- a/plugins/text/src/textcore/Transaction.zig +++ b/plugins/text/src/textcore/Transaction.zig @@ -70,6 +70,22 @@ pub fn append( }); } +/// This transaction's undo as a transaction of its own (owned): its edits newest first, each +/// with `removed` and `inserted` swapped, and the selections swapped. Applying it forward is +/// `applyInverse`. +pub fn inverted(self: Transaction, gpa: Allocator) !Transaction { + var out: Transaction = .{ .sel_before = self.sel_after, .sel_after = self.sel_before }; + errdefer out.deinit(gpa); + try out.edits.ensureTotalCapacity(gpa, self.edits.items.len); + var i = self.edits.items.len; + while (i > 0) { + i -= 1; + const e = self.edits.items[i]; + try out.append(gpa, e.pos, e.inserted, e.removed); + } + return out; +} + /// Re-apply (redo): forward, in order. Either the whole transaction lands or the buffer is /// left unchanged — a mid-list failure used to leave a half-applied state that made every /// subsequent undo retry hit `EditOutOfRange`. diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 564f18815..bc15cf853 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -1803,9 +1803,10 @@ pub fn actAgain(self: *Host, stored: Acting) Acting { return outer; } -/// Who is acting now, to store with work done later (`actAgain`). +/// Who is acting now, to store with work done later (`actAgain`): the innermost `act`, else the +/// frame's input author (the person at the keys), as `author` answers. pub fn acted(self: *const Host) Acting { - return self.acting; + return .{ .author = self.acting.author orelse self.input_author, .run = self.acting.run }; } pub fn endAct(self: *Host, outer: Acting) void { diff --git a/tests/integration.zig b/tests/integration.zig index cf0f6b3a8..d52006e42 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7908,6 +7908,46 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings(before, (try read(host, "")).text); } + // Going back is a step, and the jump is whoever went back's: Claude writes, the person + // takes it back, Claude writes again. Claude's first write is still on the line, its + // reverse is the person's, and Claude's new write is Claude's. + { + const front = editor.activeDoc() orelse return error.TestUnexpectedResult; + const claude: sdk.Author = .{ .kind = .agent, .plugin = "test", .session = 2, .label = "Claude" }; + const first = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// first\\n\" }}", .{main_zig}); + defer gpa.free(first); + const second = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// second\\n\" }}", .{main_zig}); + defer gpa.free(second); + const before = try gpa.dupe(u8, (try read(host, "")).text); + defer gpa.free(before); + + var outer = host.act(claude); + try std.testing.expect(host.callCommand("text.write", first, host.arena()) == .ok); + host.endAct(outer); + const claudes = (try editor.stepToTake(front, .undo, host.arena())).step.id; + outer = host.act(.person); + try editor.undo(); + host.endAct(outer); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + outer = host.act(claude); + try std.testing.expect(host.callCommand("text.write", second, host.arena()) == .ok); + host.endAct(outer); + + const hist = front.owner.historyLine(front, host.arena()) orelse return error.TestUnexpectedResult; + const top = hist.entries[hist.at - 1]; + const reverse = hist.entries[hist.at - 2]; + try std.testing.expectEqual(claudes, hist.entries[hist.at - 3].id); + try std.testing.expect(std.mem.startsWith(u8, reverse.title, "Back to ")); + try std.testing.expectEqual(sdk.Author.Kind.person, host.stepAuthor(front.id, reverse.id).kind); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(front.id, top.id).label); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(front.id, claudes).label); + // Claude's first write is still there to go to, and before it the text as it was. + try std.testing.expect(front.owner.historyGoTo(front, claudes)); + try std.testing.expectEqualStrings("// first\n", (try read(host, "")).text); + try std.testing.expect(front.owner.historyGoTo(front, hist.entries[hist.at - 4].id)); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + } + // Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab // closes once it lands. try std.testing.expect(doc.owner.isDirty(doc)); @@ -8177,11 +8217,12 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.undo(); try editor.redo(); try editor.undo(); - // An edit after going back: the step gone back over stays undone. + // An edit after going back: the step gone back over comes back on the line, then its + // reverse, then the edit, so the text is as it was gone back to and nothing is lost. try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"Y\", .line = 1 }", host.arena()) == .ok); try dvui.testing.settle(headlessFrame); try std.testing.expectEqualStrings("Yab", try textOf(host, a)); - try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .added }, rec.kinds.items); + try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .redone, .added, .added }, rec.kinds.items); // Played into a document of its own, forward, in order: the same text. const b = try newDoc(editor, host, a.id); @@ -8191,7 +8232,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("Yab", try textOf(host, b)); // Unrecorded, B reported nothing; and a step that does not fit is refused, B untouched: // the undo takes back an `X` that is not there. - try std.testing.expectEqual(@as(usize, 7), rec.kinds.items.len); + try std.testing.expectEqual(@as(usize, 9), rec.kinds.items.len); // (Through the hook itself: the wrapper logs the refusal as an error, which fails a run.) try std.testing.expectError(error.StepDoesNotApply, b.owner.vtable.applyStep.?(b.owner.state, b, rec.effects.items[3], "", 0)); try std.testing.expectEqualStrings("Yab", try textOf(host, b)); @@ -8592,8 +8633,8 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.rawCloseFileID(doc.id); - // A track whose start is gone from the history (undone past, and edited over) is - // refused, and the document is left as it was. + // A track whose start was undone past and edited over still exports: going back is a + // step, so its start is still on the line. The document is left as it was. editor.setTracking(false); try host.runCommand("fizzy.newFile"); for (0..200) |_| { @@ -8615,7 +8656,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const before_text = try gpa.dupe(u8, try textOf(host, gone_path)); defer gpa.free(before_text); const gone_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ gone_path, out }); - try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) != .ok); + try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) == .ok); try std.testing.expectEqualStrings(before_text, try textOf(host, gone_path)); try editor.rawCloseFileID(gone.id); editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); From 8ae9a0aea9f63a490a0af2c50491d4014fd42769 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:33:39 -0500 Subject: [PATCH 31/31] web: the site's copies of the app serve every demo as a tape (#569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/AUTOMATION_PLAN.md` (the format section's "`zig build demos` for the site, to come"). ## What changes `zig build demos`, part of `zig build web`, writes every tape fizzy hosts into `zig-out/web/demos/`: - each bundled demo (`src/editor/demos/catalog.zig`), built from its script - each hand-written tape in `docs/demos/*.zon`, read and written again All of them are in the deflated binary form, with an `index.json` naming each tape's title, length and whether it is live. web.yml already zips `zig-out/web`, so `fizzyed.it/app/demos/` and `fizzyed.it/testapp/demos/` each serve the tapes built from their own commit. A page on the site embeds `fizzyed.it/app/?demo=demos/hello.tape`, and the tape can't drift from the app that plays it. That's the case for keeping editor tapes here rather than in a repo of their own: steps, anchors and commands are tied to the app version. Sizes: tour 1.7 KB, markdown 0.9 KB, hello 0.5 KB. The tool (`scripts/demos.zig`) reads each tape back from the bytes it wrote and checks it the way the app does: chords in the keymap's spelling. A tape with steps is refused, because only the app knows which plugins' step formats it applies (`Demo.stepsCheck`). That's the seam to widen when a hosted tape needs steps. To build on the host without dvui, the demo scripts now import `tape` directly instead of going through `app`. `wireAppModule` gives its consumer `tape` beside `app`. The rail's Files anchor is now spelled out in the catalog rather than built from `workbench.view_files`, and a new test in `tests/demos.zig` holds the two equal. Only fizzy's own build (`Options.own`) has the step. Fizzy's package leaves out `docs/` and `scripts/`, so on an app built on fizzy it would fail at configure. CI now runs `zig build demos` beside `check-web`, so a script or tape that stops building fails its PR rather than the deploy after it. `web.yml` also redeploys when `docs/demos/**` or the tool changes. Two tapes with the same name, such as a `docs/demos/tour.zon` beside the bundled tour, are refused instead of overwriting each other. ## SDK impact - [x] None ## Verified - [x] macOS: `zig build`, `zig build test`, `zig build test-integration` (54/54 steps; `fizzy-demo-tests` runs both tests, the new anchor one included), `zig build check-tests-reached`, `zig build test-sdk-version`, `scripts/check-fmt.sh`; the tool refusing a second tape named `tour` - [ ] Windows: - [ ] Linux: - [x] Web: `zig build web` puts `demos/{tour,markdown,hello}.tape` and `index.json` beside `index.html`. Playing a fetched `.tape` is the existing `?demo=` path, not yet tried in a browser with these files ## Follow-ups - The website embedding a tape (click to load, so the wasm isn't fetched with the page) is fizzyedit/website's change. - A hosted tape with steps needs the plugins' step formats at build time. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 7 +++ .github/workflows/web.yml | 3 + build/app.zig | 2 +- build/sdk.zig | 7 ++- build/web.zig | 47 +++++++++++++++ docs/AUTOMATION.md | 11 +++- scripts/demos.zig | 104 ++++++++++++++++++++++++++++++++++ src/editor/demos/catalog.zig | 11 +++- src/editor/demos/markdown.zig | 2 +- src/editor/demos/tour.zig | 2 +- tests/demos.zig | 4 ++ 11 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 scripts/demos.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94414be8..75297f3a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,13 @@ jobs: shell: bash run: bash scripts/ci-retry.sh zig build check-web + # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer + # builds or reads back fails here, not on the deploy after merge. + - name: Build the hosted demo tapes + if: matrix.name != 'Windows' + shell: bash + run: bash scripts/ci-retry.sh zig build demos + # Fizzy on dvui's own SDL3 backend (`-Dnative-backend=sdl3`), where any dvui app on the stock # backend stands: the platform pieces compile without fizzy's backend and lose what needs it # (floats' windows, the window skin). It rotted unnoticed while nothing built it. macOS diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index e0cb83ca9..4db3c81b1 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -45,6 +45,9 @@ on: - "build.zig" - "build.zig.zon" - "web/**" + # The tapes served beside the app (`zig-out/web/demos/`). + - "docs/demos/**" + - "scripts/demos.zig" - ".github/workflows/web.yml" release: types: [published] diff --git a/build/app.zig b/build/app.zig index edd439345..4698b9b79 100644 --- a/build/app.zig +++ b/build/app.zig @@ -558,7 +558,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. // --------------------------------------------------------------- - web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, app_icon_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); + web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, app_icon_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs, opts.own != null); const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, app_icon_module, cfg.branding.rc, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; diff --git a/build/sdk.zig b/build/sdk.zig index 0a340532a..05c6aa212 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -115,7 +115,12 @@ pub fn wireAppModule( app_module.addImport("tape", core_module.import_table.get("tape").?); app_module.addImport("replay", core_module.import_table.get("replay").?); app_module.addImport("crash", crashModule(b, target, optimize)); - if (consumer) |c| c.addImport("app", app_module); + if (consumer) |c| { + c.addImport("app", app_module); + // The bundled demos' scripts (`src/editor/demos/`) need only `tape`, so they build on the + // host too (`zig build demos`). + c.addImport("tape", core_module.import_table.get("tape").?); + } return app_module; } diff --git a/build/web.zig b/build/web.zig index 448678cf4..52790322e 100644 --- a/build/web.zig +++ b/build/web.zig @@ -15,6 +15,9 @@ pub fn addSteps( app_plugins: []const sdk.BundledPlugin, web_plugin_deps: []const []const u8, web_plugin_dirs: []const @import("app.zig").WebPluginDir, + /// Fizzy's own build (`Options.own`): the hosted demo tapes come from `docs/demos/` and + /// `scripts/`, which fizzy's package leaves out, so an app built on fizzy has none. + own: bool, ) void { const web_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, @@ -294,6 +297,16 @@ pub fn addSteps( web_install_dir, "oauth-callback.html", ).step); + // The tapes the page can fetch (`?demo=demos/.tape`), built from this commit's scripts. + if (own) { + const install_demos = b.addInstallDirectory(.{ + .source_dir = demoTapes(b), + .install_dir = web_install_dir, + .install_subdir = "demos", + }); + web_step.dependOn(&install_demos.step); + b.step("demos", "Build every hosted demo tape into zig-out/web/demos/").dependOn(&install_demos.step); + } for (web_pages.items) |page| { const web_dir = b.pathJoin(&.{ page.dir, "web" }); var d = b.build_root.handle.openDir(b.graph.io, web_dir, .{ .iterate = true }) catch continue; @@ -354,3 +367,37 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); return b.addWriteFiles().add("index.html", stamped); } + +/// Every tape fizzy hosts, built on the host (`scripts/demos.zig`): the bundled demos from their +/// scripts, which need only `tape`, and each hand-written tape in `docs/demos/`. +fn demoTapes(b: *std.Build) std.Build.LazyPath { + const tape = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("sdk/tape/root.zig"), + }); + const demos = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("src/editor/demos/catalog.zig"), + }); + demos.addImport("tape", tape); + const tool = b.addExecutable(.{ + .name = "demos", + .root_module = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("scripts/demos.zig"), + }), + }); + tool.root_module.addImport("tape", tape); + tool.root_module.addImport("demos", demos); + const run = b.addRunArtifact(tool); + const out = run.addOutputDirectoryArg("demos"); + var dir = b.build_root.handle.openDir(b.graph.io, "docs/demos", .{ .iterate = true }) catch |err| + std.debug.panic("docs/demos: {t}", .{err}); + defer dir.close(b.graph.io); + var it = dir.iterate(); + while (it.next(b.graph.io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".zon")) continue; + run.addFileArg(b.path(b.pathJoin(&.{ "docs/demos", entry.name }))); + } + return out; +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 54452114e..023f9e57b 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -30,6 +30,16 @@ a `Stage`; fizzy's glass overlay and its plugin service are host framework (`app | Desktop | `FIZZY_DEMO=tour fizzy` plays from launch (how a screen recording is made); `FIZZY_DEMO=.zon` (or `.tape`) plays a tape file, a live one through the live driver | | Web | `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`) fetches a tape and plays it. Add `&storage=` to an embed so it keeps its own settings and layout | +**Hosted tapes.** `zig build demos` (part of `zig build web`) writes every tape fizzy hosts into +`zig-out/web/demos/`: the bundled demos built from their scripts, and each hand-written tape in +`docs/demos/`, all in the deflated binary form, with an `index.json` naming each one's title, +length and whether it is live (`scripts/demos.zig`). Each copy of the app on the site serves the +tapes built from its own commit, so a page embeds `fizzyed.it/app/?demo=demos/hello.tape` and +the tape always matches the app that plays it. A new tape for the site is a `.zon` in +`docs/demos/`, and nothing else. A bundled demo plays without fetching anything (`?demo=tour`); +its tape is for a player that is not that build of fizzy. The scripts in `src/editor/demos/` need +only `tape`, so the tool builds them on the host; one that needs more breaks `zig build demos`. + While a demo plays: - **A click, tap, scroll or key pauses it.** A pointer event goes on to do what the person meant; @@ -490,4 +500,3 @@ to keyframes. `src/editor/Demo.zig` and `src/Entry.zig` are the worked example. so the recording is size-independent, and writes the tape in its binary form. - **Quick open over mounts** — the palette's index should come from `core.FileTable`, which already lists mounts; then a demo can show it. -- **A `demos` build step** that writes every bundled demo as a tape, for a site to host. diff --git a/scripts/demos.zig b/scripts/demos.zig new file mode 100644 index 000000000..12000d9f4 --- /dev/null +++ b/scripts/demos.zig @@ -0,0 +1,104 @@ +//! `zig build demos`: every tape fizzy hosts, in the binary form, deflated, for a page to fetch. +//! The bundled demos (`src/editor/demos/catalog.zig`) are built from their scripts, and each +//! hand-written tape (`docs/demos/*.zon`) is read and written again. Beside them, `index.json` +//! says what each one is, for a page that lists them: +//! +//! ```json +//! [{"name":"tour","title":"A Tour of Fizzy","file":"tour.tape","ms":69449,"live":false}, …] +//! ``` +//! +//! `zig build web` puts them in `zig-out/web/demos/`, so each copy of the app on the site serves +//! the tapes built from its own commit (`?demo=demos/tour.tape`). A bundled demo plays without +//! fetching anything (`?demo=tour`); its tape is for a player that is not this build of fizzy. +//! +//! Each tape is read back from the bytes written and must be the tape it was, and is checked as +//! the app checks one (`Tape.Check`): its chords in the keymap's spelling. A tape with steps is +//! refused, since only the app knows which plugins' steps it applies (`Demo.stepsCheck`). +//! +//! Usage: demos ... +const std = @import("std"); +const tape = @import("tape"); +const catalog = @import("demos"); + +const Tape = tape.Tape; + +const check: Tape.Check = .{ .key = struct { + fn ok(spelled: []const u8) bool { + _ = tape.chord.parseKeys(spelled, .other) catch return false; + return true; + } +}.ok }; + +const Row = struct { name: []const u8, title: []const u8, file: []const u8, ms: u32, live: bool }; + +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(arena); + if (args.len < 2) { + std.debug.print("usage: demos ...\n", .{}); + std.process.exit(2); + } + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(io, args[1]); + var out = try cwd.openDir(io, args[1], .{}); + defer out.close(io); + + var rows: std.ArrayList(Row) = .empty; + for (&catalog.entries) |e| { + var script: tape.Script = .init(gpa, e.name, e.title); + script.check = check; + e.build(&script) catch |err| { + script.deinit(); + return fail("demo '{s}' did not build: {t}", .{ e.name, err }); + }; + var owned = try script.finish(); + defer owned.deinit(); + try rows.append(arena, try emit(arena, gpa, io, out, rows.items, e.name, owned.tape, false)); + } + for (args[2..]) |path| { + const bytes = try cwd.readFileAlloc(io, path, arena, .limited(64 << 20)); + var live = false; + var owned = Tape.load(gpa, bytes, check) catch |err| switch (err) { + // A live tape plays on the app as it is, as `Demo.playTape` takes one. + error.NoKeyframeAtStart => blk: { + live = true; + var c = check; + c.live = true; + break :blk Tape.load(gpa, bytes, c) catch |e| return fail("{s}: {t}", .{ path, e }); + }, + else => return fail("{s}: {t}", .{ path, err }), + }; + defer owned.deinit(); + const name = std.fs.path.stem(path); + try rows.append(arena, try emit(arena, gpa, io, out, rows.items, name, owned.tape, live)); + } + + var index: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(rows.items, .{ .whitespace = .indent_2 }, &index.writer); + try index.writer.writeByte('\n'); + try out.writeFile(io, .{ .sub_path = "index.json", .data = index.written() }); +} + +/// Write `t` as `.tape` in `out`, having read it back from those bytes. The row is `arena`'s: +/// the tape goes before the index is written. +fn emit(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, out: std.Io.Dir, rows: []const Row, name: []const u8, t: Tape, live: bool) !Row { + for (rows) |r| if (std.mem.eql(u8, r.name, name)) return fail("two tapes are named '{s}'", .{name}); + var bytes: std.Io.Writer.Allocating = .init(arena); + try tape.binary.write(gpa, t, &bytes.writer, .{ .deflate = true }); + var c = check; + c.live = live; + var back = tape.binary.read(gpa, bytes.written(), c) catch |err| return fail("{s}: its tape does not read back: {t}", .{ name, err }); + defer back.deinit(); + if (back.tape.ops.len != t.ops.len or back.tape.duration() != t.duration()) + return fail("{s}: its tape reads back as another", .{name}); + const file = try std.fmt.allocPrint(arena, "{s}.tape", .{name}); + try out.writeFile(io, .{ .sub_path = file, .data = bytes.written() }); + return .{ .name = try arena.dupe(u8, name), .title = try arena.dupe(u8, t.title), .file = file, .ms = t.duration(), .live = live }; +} + +fn fail(comptime fmt: []const u8, args: anytype) error{DemoFailed} { + std.debug.print("demos: " ++ fmt ++ "\n", args); + return error.DemoFailed; +} diff --git a/src/editor/demos/catalog.zig b/src/editor/demos/catalog.zig index ceb39296c..361ff244b 100644 --- a/src/editor/demos/catalog.zig +++ b/src/editor/demos/catalog.zig @@ -4,8 +4,11 @@ //! Adding one: a file beside this with `pub fn build(s: *Script) !void`, and a row below. It is //! then a command ("Demo: ", `fizzy.demo.<name>`), `FIZZY_DEMO=<name>` natively and //! `?demo=<name>` on the web. What a script can aim at is in `docs/AUTOMATION.md`. +//! +//! They need nothing but `tape`, so `zig build demos` builds them on the host too, into the tapes +//! the site's copies of the app serve beside themselves (`scripts/demos.zig`). const std = @import("std"); -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; pub const Entry = struct { /// Stable id: the command's suffix, the web's `?demo=`, the mount `demo://<name>`. @@ -76,8 +79,10 @@ pub const documents = "region:Main"; pub const palette = "fizzy.palette"; /// The rail's Files icon. A click opens the explorer when it is put away, and puts it away when it -/// is open — on a narrow window, where the explorer is folded rather than closed, too. -pub const files_icon = "fizzy.rail:" ++ @import("workbench").view_files; +/// is open — on a narrow window, where the explorer is folded rather than closed, too. Spelled out +/// rather than built from `workbench.view_files`, which would bring dvui into `zig build demos`; +/// `tests/demos.zig` holds them equal. +pub const files_icon = "fizzy.rail:workbench.files"; /// Open `rel` the way a person who keeps the explorer put away would: open the explorer from the /// rail, click the file, and put the explorer away again so the editor has the room. A demo's diff --git a/src/editor/demos/markdown.zig b/src/editor/demos/markdown.zig index 2be22236a..a449f3e49 100644 --- a/src/editor/demos/markdown.zig +++ b/src/editor/demos/markdown.zig @@ -1,5 +1,5 @@ //! Markdown, previewed as you type: a page written from nothing, its preview following along. -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; const catalog = @import("catalog.zig"); const start = diff --git a/src/editor/demos/tour.zig b/src/editor/demos/tour.zig index faf6e5387..57e76820b 100644 --- a/src/editor/demos/tour.zig +++ b/src/editor/demos/tour.zig @@ -1,6 +1,6 @@ //! A tour of fizzy: the explorer, editing, the command palette and a live markdown preview — a //! minute and a half, in chapters a viewer can jump between. -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; const catalog = @import("catalog.zig"); const readme = diff --git a/tests/demos.zig b/tests/demos.zig index fd82225ad..973bfcb3b 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -64,6 +64,10 @@ const DemoMoment = struct { now: f64, cursor: usize, print: u64 }; const bundled_seek_seed: u64 = 0xf122_de40; const bundled_random_seeks = 16; +test "demo: the rail's Files anchor the scripts spell out is the one fizzy publishes" { + try std.testing.expectEqualStrings("fizzy.rail:" ++ workbench.view_files, fizzy.Editor.Demo.catalog.files_icon); +} + test "demo: every bundled demo plays to the end through fizzy's stage, and a seek lands on what playing reached" { var app: BundledApp = undefined; try app.up();