Skip to content

sdk: a plugin's data folder entry goes with the plugin - #587

Merged
foxnne merged 2 commits into
mainfrom
sdk/plugin-data-retire
Oct 18, 2026
Merged

foxnne merged 2 commits into
mainfrom
sdk/plugin-data-retire

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/work#33

What changes

Follow-up to #560: a plugin's data-folder entry now goes when the plugin does, in any app built on App, not only in fizzy's own editor.

  • Host.retirePlugin(plugin, before_deinit) is the unload path's one call. It drops the plugin's registrations, runs before_deinit if given, runs the plugin's own deinit, and then tells the app through the new optional EditorAPI.pluginRetired(ctx, id).
    • App.pluginRetired stops handing out that plugin's data folder at that moment, so a plugin can write its state out in deinit.
    • Editor.unloadPlugin (unload, disable, reload, uninstall) calls it with rebuildDynamicNativeMenus as before_deinit, keeping main's order: native menus are rebuilt before the plugin's deinit, since they may still name its strings.
    • The id is copied into a stack buffer before deinit; plugin.id is never read after it.
    • This replaces sdk: a plugin has a data folder of its own #560's pluginUnregistered hook, which fired before deinit and needed App.releaseRetiredFolders at the next frame's top. That only worked in an app that calls that function every frame.
  • Host.unregisterPlugin still releases the folder. It tells the app at once, so no caller (app code, an example shape, a test) leaves a live folder behind. The doc points anyone whose plugin writes in deinit to retirePlugin. The integration tests that mean "unload" now use retirePlugin.
  • One entry per folder. The table is keyed by the lowercased id. registerPlugin refuses ids differing only in case, so a retired Graphl and a later graphl share one entry and its timers (tested).
  • Invalid ids are skipped up front. A non-plain id (fizzy.store) never gets an entry. Its first ask logs "not a plain identifier" once per id, from where the ask actually reaches the app.
  • Running out of memory at registration keeps a small set of the ids that missed (plugin_data_pending). Only those get an entry made on their next ask, and each leaves the set when it is made or the plugin is retired. An id that never registered never gets a folder.
  • Docs (docs/PLUGINS.md §3.3): the folder is the plugin's from register through deinit, and unregisterPlugin alone releases it at once.

SDK impact

  • None
  • Core-only or additive: reaches plugins at the next SDK release
  • Fingerprint moved: recorded in sdk/src/version.zig (0xba4664870675e13d → 0x23ccb903d7a4fb41), sdk_version untouched, PR labelled sdk. Host.retirePlugin is new, and EditorAPI.VTable.pluginUnregistered became pluginRetired.
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS: scripts/check-fmt.sh, zig build, zig build test, zig build test-integration, zig build check-web, zig build test-sdk-version, zig build check-tests-reached, scripts/check-examples.sh. The headless whole-editor test now checks:
    • ../escape and an unregistered nobody get no folder and no entry, and Text is text's one entry;
    • a plugin retired with retirePlugin still gets its folder inside its own deinit, and null after;
    • casetest, registered after CaseTest is retired, gets the same entry and path;
    • a plugin only unregistered (not retired) has its folder released;
    • dotted.id gets null.
  • Windows:
  • Linux:
  • Web: builds (check-web)

Follow-ups

  • The case-clash safety fixes are their own PR, store/case-clash.

🤖 Generated with Claude Code

Host.retirePlugin is the runtime unload path's one call: unregister, the
plugin's deinit, then EditorAPI.pluginRetired, where the app stops
handing out the plugin's data folder. A plugin can write its state out
in deinit, and every app built on App gets this, not only one that runs
releaseRetiredFolders. Entries are keyed by the exact id; only the folder
name is lowercased. A registration that ran out of memory sets one flag,
and the entry is made on the plugin's next ask, replacing the table of
eight missed ids. Fingerprint recorded.

Part of #530

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 18, 2026
Review of the data folder's retirement. retirePlugin copies the id before
the plugin's deinit and takes a before_deinit step, which fizzy uses to
rebuild its native menus before deinit as before; unregisterPlugin alone
still releases the folder, so no caller leaves a live one. Entries are
keyed by the lowercased id, one per folder; ids that are not plain
identifiers get none and are said once; only registrations that ran out
of memory get an entry made on their next ask.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne marked this pull request as ready for review October 18, 2026 21:51
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of #530

## What changes

#560 landed a rule that refuses a plugin whose id matches another
plugin's except for letter case. That code can lose data. This PR keeps
the rule and fixes it, deciding every case by what the filesystem lists
at the moment of the action, and taking the safe path whenever it can't
tell.

- **Uninstall never deletes another installed plugin's folder, loaded or
not.**
- The bug on `main`: an installed plugin in `plugins/Graphl` is not
loaded, and a build of `graphl` is refused. Uninstalling the refused
build from its card deleted `plugins/graphl`. On macOS and Windows that
path *is* `plugins/Graphl`, so the installed plugin was deleted.
- Now `uninstallPlugin` asks `store.folders.deletable` (new std-only
`app/store/plugin_folders.zig`), which reads `plugins/` fresh. It
deletes only when an entry named exactly `id` exists and no other entry
is `id` in another case. On a case-insensitive filesystem that second
check is what protects the other plugin. It replaces `App.sameFolder`.
- Anything else keeps the folder and only drops the card: no exact
entry, a case variant beside it, a listing that can't be read, or an id
that isn't a plain name.
- A plugin folder that is a symlink (a dev setup) has the link removed,
never what it points to.
- No inode comparison. Zig fills directory-entry inodes on Windows from
a FileIndex that NTFS leaves 0, so an inode rule would never delete
there. The name rule doesn't depend on it.
- **Reads are fresh, and an unreadable listing refuses.**
- There is no session cache any more. Each user action that touches the
disk reads `plugins/` itself: install, update, uninstall, a live load.
- The automatic update pass reads the listing once and passes it through
every update in that pass (`pass_folders` in `applyAllPendingUpdates`).
- If `plugins/` can't be read, the install, update or load is refused as
a possible clash, and uninstall keeps the folder. A `plugins/` that
doesn't exist yet simply holds nothing.
- **A refusal says why, and Uninstall stays reachable.** The store
refuses before anything is written (`refusal`), and builds the job
through `buildJob` with a `refused` reason. That job is failed from the
start and creates no folders.
- On a card the job shows the reason, e.g. "its id differs only in case
from the installed plugin 'Graphl'", with a Dismiss button. The plugin's
own controls, Uninstall among them, stay below it. It doesn't offer a
Retry that would only refuse again.
- In the automatic pass it marks the update offer failed and logs the
reason.
- The `PluginManager.refuseCaseClash` seam from the first round is gone;
the store decides this itself.
- **No truncation.** Clash names are borrowed, or copied whole into a
buffer of `max_name_bytes`. A message too long for its buffer is shown
without the name, never cut short.
- **One card names every variant.** Startup groups the folder names
ignoring case in one pass (a case-insensitive hash map). Every name in a
group of more than one is skipped, and one card lists them all.

## SDK impact

- [x] None. `App`, the store and the loader are app-side, and the
recorded fingerprint is unchanged.
- [ ] 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 (case-insensitive APFS): all eight gates
(`scripts/check-fmt.sh`, `zig build`, `test`, `test-integration`,
`check-web`, `test-sdk-version`, `check-tests-reached`,
`scripts/check-examples.sh`).
  - New unit tests (`fizzy-plugin-folders-tests`):
    - a name in another case clashes and the same name doesn't;
- uninstall deletes only an entry of exactly the name with no variant
beside it;
- a missing entry, a variant, `..`, `a/b` and an unreadable `plugins/`
all keep;
    - an exact-name symlink is `.link`;
- a listing reads folders and links, and a `plugins/` not made yet holds
nothing.
  - The headless whole-editor test covers:
- the case variant `Text` of the loaded `text` is refused before it is
opened, and its card shows failed;
    - uninstalling `Text` leaves a file in `plugins/text`;
- an installed, unloaded `plugins/Graphl` is found fresh by the clash
check;
- uninstalling the refused `graphl` card leaves `plugins/Graphl/keep`
(the data-loss case on `main`, reproduced here);
    - `plugins/solo` is still deleted by uninstall.
  - Not testable here:
- two folders differing only in case (needs a case-sensitive
filesystem);
- the store card's refusal and Dismiss: the store's download path has no
test harness.
- [ ] Windows: the name rule doesn't use inodes.
- [ ] Linux:
- [ ] Web: builds (`check-web`); there is no `plugins/` folder on the
web.

## Follow-ups

- #587 (the data folder's entry going with the plugin) is separate.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne merged commit d9dd022 into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the sdk/plugin-data-retire branch October 18, 2026 22:11
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of #530

Supersedes #566, narrowed to trays and lone floats. #566 grew into
reworking the explorer's state, `Split.close` and a mid-frame reveal,
and kept regressing; this keeps to the one primitive and leaves
everything around it as it is on `main`.

## What changes

A plugin can put its view away, not only show it, so a button can toggle
it:

```zig
pub fn hideSurface(self: *Host, id: []const u8) bool

if (!host.hideSurface(id)) _ = host.revealSurface(id); // a toggle
```

It acts in two cases only, each the way the user's own gesture does:

- **The view is what a tray shows.** A tray is a place the shape
declared `resize` and not `forget_when_empty` (so not a place a split
made), in the main window. If it is open, not folded, and `id` is its
shown view, it shuts with whatever else it holds, through
`Region.close`, as its rail icon or close does. `revealSurface` opens it
again at the size the user left it.
- **The view is alone in a float.** The float closes through
`Layout.closeFloat`, as its close button does, and the view goes home.

Anything else returns false and changes nothing: Main, a split's half
(of a tray too), a plugin's pane, a view behind another tab, a view out
of sight, a float with other views.

How:

- `Layout.hideTarget(id)` is the one predicate. It finds the region
showing `id` the way `reveal` does, and says what a hide would do
(`Hide.tray` / `Hide.float`) or null. `Layout.hide` applies it at once
(between frames).
- **During a frame** (a button in a view's draw), the shape and the
floats are walking the lists a hide changes. `Layout.hideLater` answers
with the same `hideTarget` and queues the id; `Layout.applyHides` does
the queued hides once the shape, floats, drag overlay and picker have
drawn, each asking `hideTarget` again. `revealSurface` is unchanged
(immediate), so a hide asked first in a frame lands after a reveal asked
later in it: a toggling caller makes one call a frame. Documented.
- The registry entry of a declared place now carries its `resize` (one
line in `Region.init`'s registration), which is how `hideTarget` knows a
tray.
- `Editor` wires the vtable member. A hide that shut the sidebar then
calls the explorer's existing `Explorer.close`, so its `closed` flag
(Toggle Explorer, its menu title) agrees; nothing in `Explorer` changes.
- **A test fix this needs.** `markdown preview: skipping off-screen
blocks lays the document out identically` lays out `docs/PLUGINS.md`
itself and compared two f32 totals near 41000 pt to an absolute 0.01,
where one rounding step is 0.004. The section added here tipped it
(41430.58 vs 41430.59, locally and on CI's Linux run); it now compares
relatively, at 1e-6.
- The `extents_changed` check in fizzy's frame moved below the picker
and the applied hides, so a change either makes is saved too.

Contract: `docs/PLUGINS.md` §3.1.2, "Showing and putting away a view".

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0x1dd3a67d781503a7`, combined with main's after #587), `sdk_version`
untouched, PR labelled `sdk`. New: `EditorAPI.VTable.hideSurface`,
`EditorAPI.hideSurface`, `Host.hideSurface`.
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS, on the merge with main: all gates pass
(`scripts/check-fmt.sh`, `zig build`, `test`, `test-integration`,
`check-web`, `test-sdk-version`, `check-tests-reached`,
`scripts/check-examples.sh`). New integration tests on the
`ManyPanelCase` harness (whose frame now applies hides at its end, as
fizzy's does):
- a tray's view shuts the tray; a second hide and an unknown id are
false; reveal reopens it; hide again shuts it (the toggle);
- a view in Main, and in a place split off Main, is false and nothing
changes;
  - a view in a tray the user split is false and nothing changes;
  - a view alone in a float closes the float and goes home to the panel;
- a view in a float with another is false for both views, and the float
stays;
- a floating view asking from its own draw: the answer is true, the
float is still open while it draws, and it is closing by the frame's
end, then gone.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`)

## Follow-ups

Deliberately left out, each a case `hideSurface` returns false for
today:

- views in a split tree of a tray (a panel the user split): which half
shuts, and whether the tray does;
- views in a float with other views (send one home, keep the float);
- folded trays (a sidebar folded away on a narrow window), which count
as out of sight;
- `revealSurface` reopening the sidebar leaves `Explorer.closed` as it
was (as on `main`); worth one source of truth for the explorer's state
in its own PR.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne mentioned this pull request Oct 19, 2026
2 of 8 tasks
foxnne added a commit that referenced this pull request Oct 19, 2026
## What changes

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

**Why now:** the SDK's shape has moved several times since 0.2.30. Store
plugins and apps built on fizzy, Graphl among them, only get the new
calls once they're released. Most of them come from Graphl's report.

## 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.30`:**
- **#560: a plugin's own data folder.** `host.pluginDataFolder(plugin)`
gives `<config>/plugin-data/<id>`, made on first ask. It's the plugin's
own from `register` through `deinit`, and stays on disk after uninstall.
- **#587: the data folder goes with the plugin.** `Host.retirePlugin`
unregisters a plugin, runs its `deinit`, then tells the app, so a plugin
can still write out state in `deinit`. On `EditorAPI`,
`pluginUnregistered` became `pluginRetired`; that vtable is implemented
by the app, not by plugins.
- **#593: put a view away.** `host.hideSurface(id)` shuts the tray whose
shown view is `id`, or closes a float that holds only `id`. Anything
else returns false. Mid-frame it acts after the frame has drawn. A
toggle is `if (!host.hideSurface(id)) _ = host.revealSurface(id);`.
- **#603: times on recent folders.** `host.recentFolderOpened(i)`
returns when recent folder `i` was opened, in Unix seconds, or null if
unknown. `recentFolderAt` is unchanged.
- **#599: a home page.** `sdk.keywords.ide.home` is new. While the
workbench's pane is empty, it shows a surface keyworded `home` in place
of its own page. The fingerprint didn't move; a plugin on an older SDK
can write `&.{"home"}`.
- **#581: undo is announced.** Undoing someone else's step is announced
as `fizzy.history.undone`, with a payload type `events.StepTaken`.
Additive; the fingerprint didn't move.
- **#609: history restore.** An optional `restoreHistory` hook and
`sdk.Plugin.RestoredHistory`. Plugins without the hook are unaffected.

**Core change plugins pick up at this release** (no boundary change):
#550, a replayed backdrop captures inside a front-to-back block. (`app/`
changes since 0.2.30, such as #570 and #602, 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; only `sdk_version`
changed.
- [ ] Windows:
- [ ] Linux: CI
- [ ] Web:

## Follow-ups

- Repin and release the store plugins (pixi, zig, ghostty, drive, atlas;
then agent and chat) when `sdk-repin` opens their PRs.
- Make 0.2.31 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.31`.

🤖 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