plugins: delete is the second verb, and the list says what it is

Uninstalling a plugin left it under a heading that said "Installed", with a caption saying "not
installed". It never moved, so a successful uninstall read as a failed one — and nothing on screen
said the directory was still sitting there deciding what a reinstall would run.

That mattered more than the wording. Install skips the clone when the directory exists (install.ts
findPlugin, fetch.ts refusing an existing dir) and never pulls, so reinstalling jellyfin on alpha
silently re-applied a checkout from two days earlier. The provisioning code was not on the machine,
jellyfin_accounts was never created, and connecting a server did nothing at all — with no error
anywhere, because every step it did run succeeded.

Two changes, both UI:

- The heading is "On this machine", which is what the list is: every plugin DIRECTORY, installed or
  not. Delete is what moves a plugin back to the marketplace, and now the screen shows why.
- Delete is gated on being uninstalled and is a button rather than a small underlined link. It was
  previously offered in every state, so two clicks separated a running plugin from its tables being
  dropped. Uninstall, then delete — the order the buttons now teach.

Still true and not addressed here: uninstall followed by install reuses the existing directory as it
stands. The detail pane says so now; making install fast-forward a clean checkout is a separate call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:10:37 +00:00
co-authored by Claude Opus 5
parent e37b1a151c
commit d6aef4cb92
2 changed files with 25 additions and 13 deletions
@@ -295,24 +295,32 @@ const Readme = ({ appName }: { appName: string }) => {
* plugin. A modal that says "Are you sure?" tests whether you can click twice, not whether you know what
* you are about to lose.
*
* Offered whether or not the plugin is installed, because the state that needs it most is the one where
* a broken install has already been uninstalled and the directory is still sitting there hiding the
* plugin from the marketplace.
* ── Only once it is uninstalled ──
*
* This used to be offered in every state, including while the plugin was running. Two clicks — one of
* them a confirmation nobody reads the second time — separated a working plugin from its tables being
* dropped. Now deleting is the SECOND of two verbs: uninstall stops it and forgets it, and only then can
* it be deleted. That is also the order the buttons appear in, so the screen teaches the sequence.
*
* Nothing is lost by the gate. The state this exists for — a directory sitting on disk keeping the plugin
* out of the marketplace — is by definition an uninstalled one.
*/
const Purge = ({ plugin, busy, onPurge }: { plugin: PluginItem; busy: boolean; onPurge: () => void }) => {
const [confirming, setConfirming] = useState(false);
// Installed means uninstall first. The button is absent rather than disabled: a disabled control invites
// a hunt for what would enable it, and the answer here is a different button three inches away.
if (plugin.installed) return null;
if (!confirming) {
return (
<div className="mt-8 border-t border-duck-dark/10 pt-4">
<button
type="button"
disabled={busy}
onClick={() => setConfirming(true)}
className="cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-destructive disabled:opacity-40"
>
Delete {plugin.label} and all its data
</button>
<Button tone="danger" disabled={busy} onClick={() => setConfirming(true)}>
Delete {plugin.label}
</Button>
<p className="mt-2 text-xs text-duck-dark/40">
Removes its tables, its data and <code>plugins/{plugin.appName}</code> — and puts it back in the marketplace.
</p>
</div>
);
}
@@ -482,7 +490,7 @@ export const PluginDetail = () => {
<p className="mt-4 text-xs text-duck-dark/40">
{plugin.installed
? 'Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes anything the plugin stored.'
: 'Not installed, but the code is still on this machine — which is why the marketplace does not offer it.'}
: 'Not installed, but the code is still on this machine — which is why the marketplace does not offer it. Installing again reuses this directory as it stands; deleting it below gets you a fresh clone.'}
</p>
<Purge plugin={plugin} busy={busy} onPurge={() => run('purge', plugin.appName)} />
@@ -34,7 +34,11 @@ export const PluginsList = () => {
that: a count would imply a check that has not happened. */}
{plugins.length ? (
<div className="flex items-center justify-between gap-2 border-b border-duck-dark/5 px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Installed</span>
{/* "On this machine", not "Installed" — the list is every plugin DIRECTORY, and an uninstalled
one stays in it. Under the old heading such a row read "Installed · not installed", which is
how a real uninstall got mistaken for a failed one: the plugin never moved, so nothing on
screen said the directory was still there deciding what a reinstall would run. */}
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">On this machine</span>
<button
type="button"
onClick={() => void updateAll()}