Skip to content

sdk: a document's bytes for a checkpoint, without a save's bookkeeping - #606

Merged
foxnne merged 5 commits into
mainfrom
sdk/document-bytes
Oct 19, 2026
Merged

foxnne merged 5 commits into
mainfrom
sdk/document-bytes

Conversation

@foxnne

@foxnne foxnne commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #601 and based on its branch, so the diff is this change alone; GitHub retargets it to main when #601 merges. It takes up the follow-ups that PR lists.

What changes

For plugin authors: a new optional Plugin.VTable.documentBytes(state, doc, path, allocator) → []u8. It returns the bytes a save to path would write, for a copy that is not a save, with none of a save's bookkeeping or side effects: no format on save, no edit remembered for documentWritten, no undo stop. fizzy takes these bytes:

  • for the crash checkpoint (KeptDocuments.capture from Checkpoint, every few seconds of typing)
  • for a restart or a plugin reload carrying unsaved work
  • for a tape's file (Tracks.fileAt)
  • for a fizzy.document.changed listener that asks for the unsaved text

An owner without the hook keeps working as before: Plugin.documentBytes falls back to documentSnapshot + encodeSnapshot, bookkeeping included.

Saves keep the snapshot: DocumentIo.save, and the web download (Editor.downloadDocument), which now calls the new Plugin.saveBytes (both halves of a save at once, followed by documentWritten).

Why: documentSnapshot is "the storage-agnostic half of saving". It runs at a save's start, so it formats on save and records which edit the bytes stand for. Every non-save copy did the same. In the text plugin that meant:

  1. With format_on_save on (off by default), each checkpoint ran the formatter on a dirty document while the person was typing in it.
  2. A checkpoint taken while a save's write was in flight overwrote serialized_op_id. If an edit came in between, the write landed and documentWritten marked the document clean at an edit newer than the file on disk.

Implemented in:

  • text: the hook returns the text as it stands.
  • archive: it had the same in-flight bug through serialized_generation, so it gets the hook too (doc.pack, with no generation recorded).

Document.savedBytes's comment now says only a save takes it. docs/PLUGINS.md names the hook, and its storage-agnostic section now names the snapshot pair, which is what a save uses.

Tests (tests/integration.zig, the tour test's "A document's steps" block, after #601's save check):

  • Format on save on, with a stand-in formatter: a KeptDocuments.capture keeps the text as typed and leaves the document unformatted. A real save (saveBytes) still formats, which shows the formatter was live.
  • A checkpoint while a save is in flight: take a save's snapshot at "a", type "b", take a checkpoint, then land the write (documentWritten). The document is still dirty.

Each check fails without the text plugin's hook: the first with formatted where as typed was expected, the second with the document clean.

SDK impact

  • 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

A new optional vtable field, null by default. An existing plugin builds unchanged and gets the fallback, which behaves as before.

pixi (fizzyedit/pixi) implements documentSnapshot and has both problems in its own form:

  • File.saveSnapshot sets editor.saving_bookmark = history.bookmark, so a checkpoint during a save moves the bookmark that written cleans to.
  • It also calls finishReveal(), so a checkpoint ends a reveal in progress.

pixi would need a documentBytes that builds the same Snapshot content without those two lines, encodes it in place (File.encode), and destroys it. The .pixi zip encode then runs on the UI thread at each checkpoint, as it already does through the fallback today.

Verified

Follow-ups

  • pixi's documentBytes is written and builds against this branch's SDK, with pixi's zig build test passing. It's a local change for the pixi repo, pushed at the repin that brings this SDK, since SDK 0.2.30 has no such field. It splits saveSnapshot into the save's bookkeeping around a takeSnapshot that changes nothing. A drawing still being revealed is completed in the copy, as captureState does; a flat image is the one case that still finishes the reveal, because its composite is read back from the GPU.
  • The checkpoint still encodes on the UI thread for every owner. If a large document shows up in a frame, the seam is to snapshot through the hook and encode on DocumentIo's worker.

🤖 Generated with Claude Code

foxnne and others added 2 commits October 18, 2026 18:42
The crash checkpoint (`Checkpoint`) captures every dirty document every few
seconds of typing, and for its unsaved bytes asks the owner for the bytes a
save would write (`documentBytes`). The text plugin's `savedBytes` closed the
undo group there, as if it were the save, so a run of typing was cut into a
new undo step each time a checkpoint landed in it. It is also what made the
tour test's step check flaky: on a slow runner a checkpoint fell between the
two keystrokes it types, and the second came as `.added`, not `.joined`.

The save's undo stop now comes when its write lands (`written`), which only a
real save reports, and only while the written state is still the top step.
The test takes a checkpoint between its keystrokes itself, so it holds
whatever a frame takes, and checks that a save still stops the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host copied a document for things that are not saves (the crash checkpoint, a
restart or plugin reload, a tape's file, a fizzy.document.changed listener) through
the save's own snapshot, so each copy did what a save's start does. A new optional
Plugin hook, documentBytes, gives the bytes a save would write with none of that;
Plugin.documentBytes uses it and falls back to the snapshot pair. Saves keep the
snapshot: DocumentIo, and the web download through the new Plugin.saveBytes.

The text and archive plugins implement it. In text, a checkpoint no longer runs
format on save, and a checkpoint during a save's write no longer moves the edit that
write cleans up to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne added the sdk Moves the plugin boundary's fingerprint; ships at the next SDK release label Oct 19, 2026
@foxnne
foxnne marked this pull request as ready for review October 19, 2026 03:23
Base automatically changed from text/checkpoint-keeps-typing-run to main October 19, 2026 03:29
@foxnne
foxnne enabled auto-merge (squash) October 19, 2026 03:30
@foxnne
foxnne merged commit f77a46e into main Oct 19, 2026
11 checks passed
@foxnne
foxnne deleted the sdk/document-bytes branch October 19, 2026 03:48
@foxnne foxnne mentioned this pull request Oct 19, 2026
3 of 7 tasks
foxnne added a commit that referenced this pull request Oct 19, 2026
## What changes

`sdk_version` goes from 0.2.31 to 0.2.32. Merging this tags
`sdk-v0.2.32` and publishes its tarball. `sdk-repin` then asks each
store plugin to repin.

**Why now:** the SDK's shape has moved three times since 0.2.31, so
store plugins built for 0.2.31 don't load in a fizzy built from `main`.
Pixi's history trim (reading fizzy's history limit) needs #620's call.

## SDK impact

- [ ] 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`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**`sdk` PRs since `sdk-v0.2.31`:**
- **#608: plugins ship demos.**
- `host.registerDemo(.{ .name, .title, .tape })` in `register`; the host
copies them and drops them on unregister.
- The `demos` service (`sdk.services.demos`, version 1) lists every demo
fizzy can play and plays one by name.
  - `sdk.Demo` is new. Nothing existing changed shape.
- **#606: a document's bytes without a save.**
- New optional `Plugin.VTable.documentBytes(state, doc, path,
allocator)`: the bytes a save would write, with none of a save's
bookkeeping (no format on save, no undo stop). Fizzy uses them for crash
checkpoints, a restart's carried work, a tape's file and
`fizzy.document.changed` listeners.
- An owner without it falls back to `documentSnapshot` +
`encodeSnapshot`, as before.
- **#620: the history limit is fizzy's.** `host.historyLimit()` (a new
`EditorAPI` member) is how many steps a document's undo history keeps:
fizzy's "Undo steps per document" setting, 0 for no limit. Each plugin
that owns a history trims its own.

**Also reaching plugins at this release** (no boundary change):
- **#616:** `Host.Activity` gains an optional `agent`, the author index
of an agent's edit.
- **#589:** the dvui pin moves to the zig-0.17 branch's floating-window
background hook (`docs/DEPENDENCIES.md`, patch 14).
- **#614:** glass panels in `core/dialogs.zig` get a drop's rim.

(`app/` changes since 0.2.31 are not compiled into plugins.)

## Verified

- [x] macOS: `scripts/check-fmt.sh` and `zig build test-sdk-version`
pass. The fingerprint is main's recorded value (`0x3d7fca8d3d211e73`);
only `sdk_version` changed.
- [x] Store plugins built against this `sdk/` (`--fork`, Zig 0.17,
macOS, sandbox profile): pixi, zig, atlas, drive and ghostty, each from
its `main`, all build.
- [ ] Linux: CI

## Follow-ups

- Repin and release the store plugins (pixi, zig, ghostty, drive, atlas;
then agent and chat, by hand) when `sdk-repin` opens their PRs.
- Make 0.2.32 live on fizzyed.it/app once the repinned plugins are in
the store: `gh workflow run web.yml --ref main -f go_live=0.2.32`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk Moves the plugin boundary's fingerprint; ships at the next SDK release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant