Skip to content

sdk: a plugin has a data folder of its own - #560

Merged
foxnne merged 3 commits into
mainfrom
sdk/plugin-data-folder
Oct 18, 2026
Merged

foxnne merged 3 commits into
mainfrom
sdk/plugin-data-folder

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

On Zig 0.17: main (with #575 and the SDK 0.2.30 release, #580) is merged in, and every gate passes on the 0.17 toolchain.

Part of #530

What changes

A plugin gets a folder of its own for what it keeps between runs that is not a setting (recents, an index, a cache):

pub fn pluginDataFolder(self: *Host, plugin: *const Plugin) ?[]const u8
  • <config>/plugin-data/<id>, the id in lowercase. It follows the config folder, so a FIZZY_PROFILE sandbox gets its own.
  • Plugin ids are unique ignoring ASCII case, enforced before the disk is touched.
    • Host.pluginById stays exact, as do isPluginDisabled/Undecided/AutoUpdate, because everything keyed by a loaded id needs exact matching. registerPlugin refuses Graphl beside graphl (error.DuplicatePluginId), logging both names, through the new Host.pluginByIdIgnoringCase.
    • App.pluginCaseClash(id) checks for a loaded plugin, or a folder already in plugins/, whose id is id in another case. It runs before anything keyed by id touches the disk:
      • the store's install/update path (PluginStore.startDownloadUrl, before plugins/<id> is created or written: on macOS and Windows that folder is the other plugin's), through a new optional PluginManager.VTable.refuseCaseClash, since on main the store talks to the app only through PluginManager;
      • loadUserPluginById;
      • the startup scan.
        A clash becomes a store failure card through App.recordCaseClash: "id 'Graphl' differs only in case from the plugin 'graphl'".
    • Uninstall from a clash card never deletes the loaded plugin's folder. Before deleting plugins/<id>, uninstallPlugin compares it with the loaded plugin's folder by what each is on disk (App.sameFolder, same inode), not by name. On a case-insensitive filesystem they are one folder, so only the card goes. On a case-sensitive one, only that exact-case folder is deleted.
    • The startup scan doesn't let byte order choose. Two plugin folders whose names differ only in case (possible on a case-sensitive filesystem) are one id twice: neither loads, and one card names both and asks for one to be removed. It's the simplest deterministic rule. Which build was meant can't be told from the folders alone, and the declared id would be the same either way once case is folded. A folder whose name case-clashes with a loaded plugin gets the case-clash card, as before.
  • Thread safety without walking the plugin list, symmetric for any app. Two optional EditorAPI.VTable hooks, both on the main thread:
    • pluginRegistered(ctx, id), fired by Host.registerPlugin, where the app sets up the plugin's entry;
    • pluginUnregistered(ctx, id), fired by Host.unregisterPlugin before the plugin's deinit. The app marks the entry leaving: it is still handed out for that deinit, and stops at the next frame's top (App.releaseRetiredFolders, which every app built on App already calls). An app without fizzy's Editor stays correct.
    • pluginDataFolder only reads the table: under the lock, then the folder is made outside it, then registered is checked again under the lock. Entries stay after a plugin goes, so a path a worker holds stays valid.
    • If registration-time setup runs out of memory, the plugin's id is remembered (plugin_data_missed, no allocation) and its entry is made on its first ask. The out-of-memory is logged.
    • One key function (pluginDataKey) and one wasm-aware lock pair (lockPluginData/unlockPluginData) serve both sides.
  • Ids that aren't plain identifiers: not all registered ids pass isValidPluginId; the store page registers as fizzy.store. So registerPlugin doesn't enforce it. Such a plugin gets null from pluginDataFolder, logged once, and the docs say so.
  • No mkdir on every call. The folder is made on the first ask and checked again only on an ask a second or more after the last (App.plugin_data_recheck_ns). The contract, documented: a plugin whose write into a kept path fails with FileNotFound asks again, a second later if it just asked.
  • Failure backoff: failed_at is recorded after createDirPath returns. Asks within 5 s of it return null (App.plugin_data_retry_ns), and each run of failures is logged once. Running out of memory is logged.
  • App.deinit frees what the App owns: the data-folder table and the disabled/undecided/auto-update id lists, moved there from Editor.deinit. Editor.deinit calls it through a defer at its top.
  • Not plugins/<id>, which an update replaces and an uninstall deletes; the App, Host and PLUGINS.md docs agree. Always local, whether or not the open folder is on a mount.
  • Web: as with paletteFolder, the path names no directory. It is the prefix under which core.fs.read/readZ/write/remove keep files in the page's storage; those four calls do the same on the desktop, and std.Io.Dir calls on the path fail on the web.
  • Uninstall leaves it, as it leaves the plugin's settings.zon block. The named seam for removing it too is Editor.uninstallPlugin (see Follow-ups).

Graphl asked for it (#530): today it keeps its recents beside paletteFolder()'s parent.

Boundary: EditorAPI.VTable.pluginDataFolder(ctx, id) and the optional pluginRegistered(ctx, id)/pluginUnregistered(ctx, id), the Host/EditorAPI calls, and Host.pluginByIdIgnoringCase. Docs: docs/PLUGINS.md §3.3.

SDK impact

  • None
  • Core-only or additive: reaches plugins at the next SDK release
  • Fingerprint moved: recorded in sdk/src/version.zig (0xba4664870675e13d after merging main, which released SDK 0.2.30; whichever of this and sdk: a plugin can put a surface away, not only reveal it #566 merges second records the combined value), sdk_version untouched, PR labelled sdk
  • 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 (54/54 steps, 595/595 tests), zig build check-web, zig build test-sdk-version, zig build check-tests-reached, scripts/check-examples.sh. A new sdk unit test covers registerPlugin refusing the same id and the same id in another case. The headless whole-editor test (fresh profile) checks:

    • text's and markdown's folders are <profile>/plugin-data/<id>, exist, are stable across asks, and differ;
    • ../escape gets null and leaves no table entry, and Text finds text's folder;
    • a test-registered CaseTest gets plugin-data/casetest, and casetest is then refused registration;
    • once CaseTest is unregistered its folder is still handed out (for its deinit) until the next frame's top, then not;
    • loadUserPluginById("Text") is refused before opening anything, and its card shows failed with the case reason;
    • uninstalling Text works, text stays loaded, and a file in plugins/text survives (on macOS plugins/Text is that folder);
    • pluginCaseClash("graphl") finds a plugins/Graphl folder.

    Not testable on this case-insensitive Mac: two folders differing only in case (the startup-scan rule), and deleting the exact-case folder on a case-sensitive filesystem.

    • dotted.id gets null;
    • a deleted folder is made again by an ask once its trusted second is wound past;
    • a test-registered blocked plugin with a file in its folder's way gets null, and null again right after the way clears (backoff); with the backoff wound past, the folder is made and the failure cleared.

    The warnings these tests cause are muted with std.testing.log_level. The test's one remaining failed command: comes from its own warning already on main (a tape needing no-such-plugin).

  • Windows:

  • Linux:

  • Web: builds (check-web); not run in a browser

Follow-ups

  • Removing the data folder on uninstall is not built. It would be an option of Editor.uninstallPlugin, probably ask-first in its dialog, since the folder can hold work the user would miss.
  • The other items in sdk: plugin data folder, timed recents, hiding a surface, a home surface #530 (timed recents, a home surface, the scrolls_itself warning) are left for their own PRs. Hiding a surface is the next PR (sdk/hide-surface).

🤖 Generated with Claude Code

@foxnne foxnne added the sdk Moves the plugin boundary's fingerprint; ships at the next SDK release label Oct 18, 2026
@foxnne
foxnne force-pushed the sdk/plugin-data-folder branch 3 times, most recently from 6523b8c to e054ee5 Compare October 18, 2026 19:01
@foxnne
foxnne marked this pull request as ready for review October 18, 2026 19:15
@foxnne
foxnne enabled auto-merge (squash) October 18, 2026 19:15
@foxnne
foxnne disabled auto-merge October 18, 2026 19:15
host.pluginDataFolder(plugin): <config>/plugin-data/<id>, the id in
lowercase, for a registered plugin. Plugin ids are unique ignoring ASCII
case: registerPlugin refuses Graphl beside graphl, and the loaders ask
the new pluginByIdIgnoringCase before they open a build, putting the
clash on its failure card; everything keyed by a loaded id stays exact.
The app sets each plugin's entry up as it registers
(EditorAPI.pluginRegistered, main thread) and marks it gone after the
plugin's deinit, so asks from any thread read only that table under a
lock; the folder is made on the first ask and checked again at most
once a second, and a folder that can't be made is logged once and
retried after a few seconds. App.deinit frees what the App owns. Apart
from plugins/<id>, which an update replaces and an uninstall deletes;
uninstall leaves it, as it leaves settings. On the web, as with
paletteFolder, the path is the prefix core.fs files under in the page's
storage. Fingerprint recorded.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the sdk/plugin-data-folder branch from e054ee5 to 15e558a Compare October 18, 2026 19:31
@foxnne
foxnne enabled auto-merge (squash) October 18, 2026 20:05
foxnne and others added 2 commits October 18, 2026 15:06
Fifth review of the plugin data folder. The store's install and update
path, loadUserPluginById and the startup scan ask App.pluginCaseClash (a
loaded plugin or a plugins/ folder whose id is this one in another case)
before writing or opening anything, and say so on a failure card
(App.recordCaseClash). Uninstall from such a card compares folders by
inode, so it never deletes the loaded plugin's folder on a
case-insensitive filesystem. Two plugin folders differing only in case
load neither, with one card. Host.unregisterPlugin now tells the app
(EditorAPI.pluginUnregistered); the entry is still handed out for the
plugin's deinit and goes at the next frame's top. A registration that
ran out of memory gets its entry on the first ask.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the SDK 0.2.30 release (#580) and the store's PluginManager
seam. The one textual conflict was the recorded SDK shape fingerprint,
re-recorded for the merged boundary. Adapted to main: the store asks the
app through a new optional PluginManager.VTable.refuseCaseClash rather
than reaching App, and App.recordCaseClash reads no build version on the
web, whose loader has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne merged commit 3202493 into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the sdk/plugin-data-folder branch October 18, 2026 20:35
foxnne added a commit that referenced this pull request Oct 18, 2026
Brings in the plugin data folder (#560). The one conflict was the
recorded SDK shape fingerprint, re-recorded for the combined boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 added a commit that referenced this pull request Oct 18, 2026
Part of #530

## 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 #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
- [x] 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

- [x] 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](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