Repository navigation
zig/0.17: merge main (9 commits, to #569) - #574
Merged
Merged
Conversation
…do (#495) 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 <noreply@anthropic.com>
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 <display name>"). - 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 <app>". - `fizzy.about` uses lucide `info` rather than `download`. - `fizzy.restart` is "Restart <display name>". - **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 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 2. Follows #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": "<text>"}`. - **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 <noreply@anthropic.com>
…ft (#535) 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 <noreply@anthropic.com>
…lds (#540) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…lp (#532) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <module>` (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 <noreply@anthropic.com>
#534) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
) 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.<component>`, 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 `<app-name>`. - `--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 <root>/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/<channel>`, 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/<hash>`. - 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" \ Ü<TAB>`, `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 <app>/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 <root>/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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ctures (#500) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 7: "web: play a tape from a link. `?tape=<url>`, loading the tape's owners from the store, the player shape; the site embeds one." ## What changes **`?tape=<url>`** 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 <noreply@anthropic.com>
…oad (#536) 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 `<plugin>.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 (`<plugin>.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 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 3. Follows #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 <noreply@anthropic.com>
## 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 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 4. **Stacked on #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 `<config>/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 <noreply@anthropic.com>
build/package.zig takes main's restructure (#537) with the 0.17 port reapplied: vpk through velopack.addVpkRun, the pack directory through installDirPath, paths through b.root, release_mode on b.graph, user options read as 0.17's map, and pathFromRoot gone. build/app.zig's per-app VERSION read is declared to the configure cache. rewrite.sh re-run; three tests' string repeats in app/control are splatted arrays. dvui-dev moves to zig-0.17 at 8fa9b9c, with main's four fizzy-dev render-order commits ported (#540, #544). The SDK shape fingerprint is re-recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5a). Follows #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 <noreply@anthropic.com>
## 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 <noreply@anthropic.com>
Part of `plans/CONTROL_SERVER_PLAN.md`, step 5 (5b). Follows #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:<result as JSON>}], 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…off (#507) 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…#513) No plan issue: a bug found while driving a sandbox. ## What changes `fizzy --profile <dir> <dir>/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 <p> <p>/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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
dvui-dev moves to its zig-0.17 head 1b29a16, which carries the windowing work's 59c45df and #563's three dvui commits (pointer kind, cursor priority, float edges), ported by fizzy-e6. build/web.zig lists docs/demos through b.root and declares the listing. rewrite.sh re-run. The SDK shape fingerprint is re-recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 tasks done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 of 4 tasks
foxnne
added a commit
that referenced
this pull request
Oct 18, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): fizzy, its backend and its bundled plugins move to Zig 0.17. Every piece was reviewed as its own PR into `zig/0.17`, and this PR lands that branch. `main` is already merged in (through #555), so it carries no conflicts. ## What changes **The move to 0.17** (`minimum_zig_version = "0.17.0"` everywhere), as reviewed in: - #437: the switch, scaffolded with marked stand-ins - #438: the build's custom steps become helper programs - #448: release packaging on 0.17, with Velopack 1.2.161 (+ #452's zon arenas) - #488: settings and command schemas read 0.17's `@typeInfo`, with a test that the SDK fingerprint hashes the same facts as on 0.16 - #489: per-pointer capture through `captureMouseGet` - #494: the Windows traps in `docs/PITFALLS.md` - #496: `blitData` without an `extern struct` `@bitCast` - #498: `fizzy.plugin.installBeside`, for a plugin's helper program - #505: the 0.16 inflate workaround goes; the docs say 0.17 - #561: review fixes. A settings payload with pointers is a compile error, and build reads go through `b.root`. - #573: the plugin install helper compiles once, in Debug - the catch-ups with `main` (#486, #503, #558, #574, and the last merge here), each with `scripts/zig-0.17/rewrite.sh` re-run. That script is deleted in the last commit. Most of the diff is mechanical: `zig fmt`'s upgrades (`@backingInt`, `std.lang`, …) and one-answer renames. The hand migrations are the build (configure/make split, no custom steps), the zon arenas, `@typeInfo`, translate-c for `@cImport`, and the `@bitCast` audit. A high-effort review of the whole `main`…`zig/0.17` diff found five things; the three correctness ones are fixed in #561, and one efficiency one in #573. **Also on the branch, merged there by the maintainer:** the windowing work. - #480–#483: plain dvui apps on macOS - #492, #501, #504, #510: OS windows for floating windows, full screen, pointer routing - #546: window materials **Forks:** dvui-dev at its `zig-0.17` head `1b29a16`, which carries fizzy-dev's patches on upstream's 0.17 `main`, including #540, #544 and #563's dvui commits. Also assetpack, md4zig, sdl_zig and zig-objc on their `zig-0.17` branches, and velopack-zig `3d553b7` (tagged `fizzy-zig0.17`). ## Measured macOS arm64, under load from other sessions, each step alternated between the two. 0.16 is `main` #509; 0.17 is `zig/0.17` with #561. | | Zig 0.16 | Zig 0.17 | |---|---|---| | App, cold Debug build | 94.4 s | 78.6 s | | App, warm no-op rebuild | 0.7 s | 0.4 s | | `test-integration` (compile + run) | 108.9 s | 90.4 s | | App, ReleaseFast build | 142.8 s | 131.5 s | | Image plugin, cold standalone build | 11.6 s | 14.0 s (with #573) | | Startup, Debug (median of 5) | 565 ms | 466 ms | | Startup, ReleaseFast (median of 5) | 175 ms | 177 ms | | Text, ReleaseFast, µs/frame: idle / mid / scroll / typing | 305 / 424 / 435 / 403 | 240 / 306 / 286 / 290 | | Markdown, ReleaseFast, 4 docs, median of 5, µs/frame: idle top / mid / scroll / resize | 1177 / 1735 / 2104 / 1319 | 1270 / 1699 / 1833 / 1192 | The one slowdown is markdown idle at the top of a document, +8%. The rest of the plugin build's gap is dvui's own translate-c on 0.17. ## The plan's gate - **ZLS:** no 0.17 release yet (latest 0.16.0). zigtools/zls#3270 is on ZLS's master. Completions across `fizzy_sdk` from a plugin are not checked. - **Minimum OS:** 0.17 needs Darwin 15+ (OS X 10.11); fizzy's macOS minimum is 11.0, so no change. Windows 10+ and Linux 5.10+ are not stated by fizzy. - **Debug leak reports:** still print (`DebugAllocator`, deprecated, unchanged). The move to `SafeAllocator` is a follow-up with its own Debug frame-time check. - **Velopack:** packaging proven by a test release on macOS (signed and notarized), Windows and Linux. An update in place was not tested. - **Every target builds:** in CI. Windows and Linux were not run by hand on the VMs. ## SDK impact - [x] Fingerprint moved (`0x5f9c818a6972efd4`, recorded in `sdk/src/version.zig`); `sdk_version` untouched. `sdk: release 0.2.30` follows this at once. Plugins need Zig 0.17 to build against it: there's no overlap window. - Plugin authors see `minimum_zig_version = "0.17.0"` and `fizzy.plugin.installBeside`. A settings payload holding pointers other than a string is now a compile error. `docs/PLUGINS.md` says 0.17.0. ## Right after this merges 1. fizzyedit/example-app#8 and fizzyedit/example-plugin#13 merge. Then a one-line PR points `scripts/check-examples.sh` back at `main` (it pins the examples' `zig-0.17` branches until then, so `main`'s CI is never red). 2. `sdk: release 0.2.30`. 3. The store plugins' 0.17 PRs repin and release: fizzyedit/pixi#51, zig#15, atlas#16, drive#13, ghostty#14, agent#23, chat#16. ## Verified - [x] macOS (arm64, Zig 0.17.0), on this tree: - `scripts/check-fmt.sh` - `zig build`: 71/71 - `zig build test`: 109/109 steps, 637/638 (1 skipped) - `zig build check-tests-reached`: 998 tests in 122 files - `zig build check-web`: 42/42 - `zig build test-sdk-version`: 35/35 - `zig build test-integration`: 78/78 steps, 608/608 - [x] All seven store plugins built against `zig/0.17`'s `sdk/` and loaded together in a 0.17 fizzy (Debug, sandbox profile). - [ ] Windows, Linux, the example repos: CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne
added a commit
that referenced
this pull request
Oct 18, 2026
Part of the multi-window plan (fizzyedit/plans#26). **What changes:** `docs/DEPENDENCIES.md` lists dvui-dev `zig-0.17`'s patches since patch 10: - 11: front-to-back blocks and deferred queues (`ca8d4f8`, `8fa9b9c`); - 12: a floating window shown in an OS window draws no background of its own (`59c45df`); - 13: the pointer's kind and the cursor's priority (`48496b7`, `186a87f`, `1b29a16`). This PR began as the repin to 59c45df and then 1b29a16. #574 pinned the same commit and landed first, so the pin and fingerprint are gone from here. `zig/0.17` has since landed on `main` (#575); `main` is merged in, and only the docs remain. ## SDK impact - [x] None (docs only). ## Verified - [x] `jj diff --from main@origin` is `docs/DEPENDENCIES.md` alone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of fizzyedit/plans#5 (
plans/ZIG_0_17_PLAN.md, step 2): catchingzig/0.17up withmainagain (9 commits, through #569), the last before the switch.Please merge by fast-forward, not squash
#558 was squashed, so
zig/0.17had lostmainas an ancestor. A merge, and the switch PR itself, would then have gone back to #490 as the base and conflicted on every line the rewrite changed. This PR is two commits:record #558's merge of main as a parent:zig/0.17plus zig/0.17: merge main (22 commits, to #509) #558's own merge commit (44849f2, whose second parent ismainapp: control server on by default, socket in a private folder #509). It keepszig/0.17's tree exactly (empty diff).merge main (9 commits, to #569): with app: control server on by default, socket in a private folder #509 as the base, only two files conflicted, the dvui pin and the fingerprint.A squash would undo step 1, and the switch PR would conflict.
What changed beyond the merge
zig-0.17head1b29a16. It carries the windowing work's59c45df(a floating window in an OS window draws no background of its own) and core: tabs show the hand, and a tapped item is left unlit #563's three dvui commits (pointer kind, cursor priority, float edges at normal priority), which fizzy-e6 ported ontozig-0.17. dvui's tests on 0.17: 271/271.build/web.zig: web: play a tape from a link, loading the plugins it needs #514's demo listing goes throughb.rootand declares the listing to the configure cache, so adding a demo reconfigures.0x5f9c818a6972efd4(sdk: a command may answer in a later frame #538's SDK change, and the new dvui).rewrite.shre-run.main's commits in this merge:SDK impact
sdk/src/version.zig,sdk_versionuntouched.Verified
scripts/check-fmt.shzig build: 71/71zig build test: 109/109 steps, 637/638 (1 skipped)zig build check-tests-reached: 998 tests in 122 fileszig build check-web: 42/42zig build test-sdk-version: 35/35zig build test-integration: 78/78 steps, 608/608plugins/imagestandalone: 43/43🤖 Generated with Claude Code