Repository navigation
sdk: a plugin's data folder entry goes with the plugin - #587
Merged
Merged
Conversation
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>
2 of 8 tasks
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
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>
2 of 8 tasks
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
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.
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, runsbefore_deinitif given, runs the plugin's owndeinit, and then tells the app through the new optionalEditorAPI.pluginRetired(ctx, id).App.pluginRetiredstops handing out that plugin's data folder at that moment, so a plugin can write its state out indeinit.Editor.unloadPlugin(unload, disable, reload, uninstall) calls it withrebuildDynamicNativeMenusasbefore_deinit, keeping main's order: native menus are rebuilt before the plugin'sdeinit, since they may still name its strings.deinit;plugin.idis never read after it.pluginUnregisteredhook, which fired beforedeinitand neededApp.releaseRetiredFoldersat the next frame's top. That only worked in an app that calls that function every frame.Host.unregisterPluginstill 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 indeinittoretirePlugin. The integration tests that mean "unload" now useretirePlugin.registerPluginrefuses ids differing only in case, so a retiredGraphland a latergraphlshare one entry and its timers (tested).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.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/PLUGINS.md§3.3): the folder is the plugin's fromregisterthroughdeinit, andunregisterPluginalone releases it at once.SDK impact
sdk/src/version.zig(0xba4664870675e13d→0x23ccb903d7a4fb41),sdk_versionuntouched, PR labelledsdk.Host.retirePluginis new, andEditorAPI.VTable.pluginUnregisteredbecamepluginRetired.sdk_version, lists thesdkPRs since the lastsdk-v*tagVerified
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:../escapeand an unregisterednobodyget no folder and no entry, andTextistext's one entry;retirePluginstill gets its folder inside its owndeinit, and null after;casetest, registered afterCaseTestis retired, gets the same entry and path;dotted.idgets null.check-web)Follow-ups
store/case-clash.🤖 Generated with Claude Code