Repository navigation
sdk: a plugin has a data folder of its own - #560
Merged
Merged
Conversation
2 of 8 tasks
foxnne
force-pushed
the
sdk/plugin-data-folder
branch
3 times, most recently
from
October 18, 2026 19:01
6523b8c to
e054ee5
Compare
foxnne
marked this pull request as ready for review
October 18, 2026 19:15
foxnne
enabled auto-merge (squash)
October 18, 2026 19:15
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
force-pushed
the
sdk/plugin-data-folder
branch
from
October 18, 2026 19:31
e054ee5 to
15e558a
Compare
foxnne
enabled auto-merge (squash)
October 18, 2026 20:05
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
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>
This was referenced Oct 18, 2026
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
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>
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.
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):
<config>/plugin-data/<id>, the id in lowercase. It follows the config folder, so aFIZZY_PROFILEsandbox gets its own.Host.pluginByIdstays exact, as doisPluginDisabled/Undecided/AutoUpdate, because everything keyed by a loaded id needs exact matching.registerPluginrefusesGraphlbesidegraphl(error.DuplicatePluginId), logging both names, through the newHost.pluginByIdIgnoringCase.App.pluginCaseClash(id)checks for a loaded plugin, or a folder already inplugins/, whose id isidin another case. It runs before anything keyed byidtouches the disk:PluginStore.startDownloadUrl, beforeplugins/<id>is created or written: on macOS and Windows that folder is the other plugin's), through a new optionalPluginManager.VTable.refuseCaseClash, since on main the store talks to the app only throughPluginManager;loadUserPluginById;A clash becomes a store failure card through
App.recordCaseClash: "id 'Graphl' differs only in case from the plugin 'graphl'".plugins/<id>,uninstallPlugincompares 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.EditorAPI.VTablehooks, both on the main thread:pluginRegistered(ctx, id), fired byHost.registerPlugin, where the app sets up the plugin's entry;pluginUnregistered(ctx, id), fired byHost.unregisterPluginbefore the plugin'sdeinit. The app marks the entry leaving: it is still handed out for thatdeinit, and stops at the next frame's top (App.releaseRetiredFolders, which every app built onAppalready calls). An app without fizzy'sEditorstays correct.pluginDataFolderonly reads the table: under the lock, then the folder is made outside it, thenregisteredis checked again under the lock. Entries stay after a plugin goes, so a path a worker holds stays valid.plugin_data_missed, no allocation) and its entry is made on its first ask. The out-of-memory is logged.pluginDataKey) and one wasm-aware lock pair (lockPluginData/unlockPluginData) serve both sides.isValidPluginId; the store page registers asfizzy.store. SoregisterPlugindoesn't enforce it. Such a plugin gets null frompluginDataFolder, logged once, and the docs say so.mkdiron 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 withFileNotFoundasks again, a second later if it just asked.failed_atis recorded aftercreateDirPathreturns. 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.deinitfrees what the App owns: the data-folder table and the disabled/undecided/auto-update id lists, moved there fromEditor.deinit.Editor.deinitcalls it through adeferat its top.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.paletteFolder, the path names no directory. It is the prefix under whichcore.fs.read/readZ/write/removekeep files in the page's storage; those four calls do the same on the desktop, andstd.Io.Dircalls on the path fail on the web.settings.zonblock. The named seam for removing it too isEditor.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 optionalpluginRegistered(ctx, id)/pluginUnregistered(ctx, id), theHost/EditorAPIcalls, andHost.pluginByIdIgnoringCase. Docs:docs/PLUGINS.md§3.3.SDK impact
sdk/src/version.zig(0xba4664870675e13dafter 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_versionuntouched, PR labelledsdksdk_version, lists thesdkPRs since the lastsdk-v*tagVerified
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 newsdkunit test coversregisterPluginrefusing the same id and the same id in another case. The headless whole-editor test (fresh profile) checks:text's andmarkdown's folders are<profile>/plugin-data/<id>, exist, are stable across asks, and differ;../escapegets null and leaves no table entry, andTextfindstext's folder;CaseTestgetsplugin-data/casetest, andcasetestis then refused registration;CaseTestis unregistered its folder is still handed out (for itsdeinit) until the next frame's top, then not;loadUserPluginById("Text")is refused before opening anything, and its card shows failed with the case reason;Textworks,textstays loaded, and a file inplugins/textsurvives (on macOSplugins/Textis that folder);pluginCaseClash("graphl")finds aplugins/Graphlfolder.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.idgets null;blockedplugin 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 remainingfailed command:comes from its own warning already onmain(a tape needingno-such-plugin).Windows:
Linux:
Web: builds (
check-web); not run in a browserFollow-ups
Editor.uninstallPlugin, probably ask-first in its dialog, since the folder can hold work the user would miss.scrolls_itselfwarning) are left for their own PRs. Hiding a surface is the next PR (sdk/hide-surface).🤖 Generated with Claude Code