plugins panel keyed (414 -> 383)

AvailableDetail was an implicit-return arrow, so it got a body to hold the hook.
This commit is contained in:
2026-08-23 14:53:55 +00:00
parent 7899539d4b
commit d0904b2c84
4 changed files with 132 additions and 69 deletions
@@ -16,6 +16,7 @@ import {
type PluginItem,
type PluginVerb,
} from './usePlugins';
import { useTranslation } from '../../locales';
// The right panel: one plugin, and its verbs — install, enable, disable, update, delete.
//
@@ -80,11 +81,12 @@ const Parts = ({ has }: { has: PluginItem['has'] }) => {
* "Requires: nothing" row is noise on every other plugin's page.
*/
const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
const { t, tUi } = useTranslation();
if (!deps.items.length) return null;
return (
<div className="mt-4 border-t border-duck-dark/10 pt-4">
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Needs on this machine</p>
<p className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">{t('plugins.needsOnThisMachine')}</p>
<ul className="mt-2 space-y-2">
{deps.items.map((item) => (
<li key={item.binary} className="flex gap-2 text-sm">
@@ -201,56 +203,59 @@ const AvailableDetail = ({
steps: ActionEntry[];
result: PluginActionResult | null;
running: PluginVerb | null;
}) => (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
<AvailableGlyph plugin={plugin} />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
}) => {
const { t } = useTranslation();
return (
<div className="h-full overflow-auto p-6">
<div className="flex items-center gap-3">
<AvailableGlyph plugin={plugin} />
<div className="min-w-0">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
</div>
</div>
</div>
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Publisher">{plugin.publisher}</Row>
<Row label="Version">{plugin.version}</Row>
<Row label="Needs platform">{plugin.platform}</Row>
<Row label="Would mount">
<code>/api{plugin.prefix}</code>
</Row>
<Row label="Ships">
<Parts has={plugin.has} />
</Row>
<Row label="Permissions">
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
</Row>
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
<Row label={t('plugins.version')}>{plugin.version}</Row>
<Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
<Row label={t('plugins.wouldMount')}>
<code>/api{plugin.prefix}</code>
</Row>
<Row label={t('plugins.ships')}>
<Parts has={plugin.has} />
</Row>
<Row label={t('plugins.permissions')}>
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
</Row>
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on
their machine should be able to read where it comes from without opening a config file. */}
<Row label="Source">
<code className="break-all text-xs">{plugin.source}</code>
</Row>
<Row label={t('plugins.source')}>
<code className="break-all text-xs">{plugin.source}</code>
</Row>
</div>
<Dependencies deps={plugin.dependencies} />
<div className="mt-6">
<Button
tone="primary"
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={onInstall}
>
{t('plugins.install')}
</Button>
<p className="mt-2 text-xs text-duck-dark/50">
{t('plugins.clonesInto')} <code>plugins/{plugin.appName}</code>, then installs it.
</p>
</div>
<ActionLog steps={steps} result={result} running={running} />
<Readme appName={plugin.appName} />
</div>
<Dependencies deps={plugin.dependencies} />
<div className="mt-6">
<Button
tone="primary"
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={onInstall}
>
Install
</Button>
<p className="mt-2 text-xs text-duck-dark/50">
Clones the repository into <code>plugins/{plugin.appName}</code>, then installs it.
</p>
</div>
<ActionLog steps={steps} result={result} running={running} />
<Readme appName={plugin.appName} />
</div>
);
);
};
/**
* What the marketplace says about a plugin, in the pane where somebody decides whether to install it.
@@ -317,6 +322,7 @@ const DeletePlugin = ({
busy: boolean;
onDelete: (dropData: boolean) => void;
}) => {
const { t, tUi } = useTranslation();
const [open, setOpen] = useState(false);
const [dropData, setDropData] = useState(false);
@@ -334,7 +340,7 @@ const DeletePlugin = ({
setOpen(true);
}}
>
Delete
{t('common.delete')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
@@ -360,7 +366,7 @@ const DeletePlugin = ({
className="mt-0.5"
/>
<span className="text-xs text-duck-dark">
<span className="font-medium">Also delete all its data</span>
<span className="font-medium">{t('plugins.deleteData')}</span>
<span className="mt-0.5 block text-duck-dark/60">
Drops every table this plugin owns. Without this its rows stay, and reinstalling later finds them
where they were.
@@ -368,12 +374,12 @@ const DeletePlugin = ({
</span>
</label>
) : (
<p className="text-xs text-duck-dark/50">This plugin declares no tables, so there is no data to keep.</p>
<p className="text-xs text-duck-dark/50">{t('plugins.noTables')}</p>
)}
<DialogFooter className="mt-2">
<Button disabled={busy} onClick={() => setOpen(false)}>
Cancel
{tUi('button.cancel')}
</Button>
<Button
tone="danger"
@@ -383,7 +389,7 @@ const DeletePlugin = ({
onDelete(dropData);
}}
>
{dropData ? 'Delete it and its data' : `Delete ${plugin.label}`}
{dropData ? 'Delete it and its data' : t('plugins.deleteNamed', { name: plugin.label })}
</Button>
</DialogFooter>
</DialogContent>
@@ -393,6 +399,7 @@ const DeletePlugin = ({
};
export const PluginDetail = () => {
const { t, tUi } = useTranslation();
const { plugins, available, steps, result, running, run } = usePlugins();
const [params] = useSearchParams();
const selected = params.get('selected');
@@ -417,7 +424,7 @@ export const PluginDetail = () => {
}
if (!plugin) {
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
return <div className="p-6 text-sm text-duck-dark/50">{t('plugins.selectOne')}</div>;
}
return (
@@ -431,22 +438,22 @@ export const PluginDetail = () => {
</div>
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Mounts at">
<Row label={t('plugins.mountsAt')}>
<code>/api{plugin.prefix}</code>
</Row>
<Row label="Publisher">{plugin.publisher}</Row>
<Row label="Version">
<Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
<Row label={t('plugins.version')}>
{plugin.version}
{plugin.outdated ? (
<span className="ml-2 text-busy-ink">on disk — installed {plugin.installedVersion}</span>
) : null}
</Row>
<Row label="Needs platform">{plugin.platform}</Row>
<Row label="Ships">
<Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
<Row label={t('plugins.ships')}>
<Parts has={plugin.has} />
</Row>
{plugin.has.sidecar ? (
<Row label="Sidecar">
<Row label={t('plugins.sidecar')}>
{/* Enabled but not online is the state worth naming: the plugin is switched on and its
process is not running, which is broken rather than off. */}
<span
@@ -460,7 +467,7 @@ export const PluginDetail = () => {
</span>
</Row>
) : null}
<Row label="Permissions">
<Row label={t('plugins.permissions')}>
{plugin.permissions.length
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
: 'none — reachable by anyone who can reach the platform'}
@@ -494,7 +501,7 @@ export const PluginDetail = () => {
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={() => run('install', plugin.appName)}
>
Install
{t('plugins.install')}
</Button>
) : null}
{!plugin.installed ? (
@@ -508,11 +515,11 @@ export const PluginDetail = () => {
<>
{plugin.enabled ? (
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
Disable
{t('plugins.disable')}
</Button>
) : (
<Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}>
Enable
{t('plugins.enable')}
</Button>
)}
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the
@@ -521,7 +528,7 @@ export const PluginDetail = () => {
which cannot fetch: the directory exists, so the clone is skipped and only the apply
steps re-ran. `update` pulls first. */}
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
{plugin.outdated ? `Update to ${plugin.version}` : 'Update'}
{plugin.outdated ? t('plugins.updateTo', { version: plugin.version }) : 'Update'}
</Button>
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being
conditioned here, so the rule lives next to the confirmation that explains it. */}
@@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle, Database } from 'lucide-react';
import { usePlugins, type OrphanTable, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
import { useTranslation } from '../../locales';
// The left panel: every plugin in the tree, installed or not.
//
@@ -17,11 +18,12 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
};
export const PluginsList = () => {
const { t } = useTranslation();
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins();
const [params] = useSearchParams();
const selected = params.get('selected');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">{t('plugins.loading')}</div>;
return (
<div className="h-full overflow-auto">
@@ -34,11 +36,13 @@ 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">
{/* "On this machine", not "Installed" — the list is every plugin DIRECTORY, and an uninstalled
{/* t('plugins.onThisMachine'), 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>
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
{t('plugins.onThisMachine')}
</span>
<button
type="button"
onClick={() => void updateAll()}
@@ -73,7 +77,7 @@ export const PluginsList = () => {
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
<div className="p-4 text-sm text-duck-dark/50">
No plugins in <code>plugins/</code> yet.
{t('plugins.noPluginsIn')} <code>plugins/</code> yet.
</div>
) : null}
@@ -113,7 +117,9 @@ export const PluginsList = () => {
directory and the next read discovers it like any other. */}
{available.length ? (
<div className="border-b border-duck-dark/5 bg-duck-dark/[0.02] px-3 py-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">Available</span>
<span className="text-xs font-medium uppercase tracking-wide text-duck-dark/40">
{t('plugins.available')}
</span>
</div>
) : null}
{available.map((plugin) => (
@@ -188,5 +188,30 @@
"extractedTo": "Extracted to “{{name}}”",
"downloadInstead": "Download instead",
"archiveFile": "Archive file"
},
"plugins": {
"loading": "Loading…",
"onThisMachine": "On this machine",
"noPluginsIn": "No plugins in",
"available": "Available",
"needsOnThisMachine": "Needs on this machine",
"install": "Install",
"clonesInto": "Clones the repository into",
"deleteData": "Also delete all its data",
"noTables": "This plugin declares no tables, so there is no data to keep.",
"selectOne": "Select a plugin.",
"disable": "Disable",
"enable": "Enable",
"publisher": "Publisher",
"version": "Version",
"needsPlatform": "Needs platform",
"wouldMount": "Would mount",
"ships": "Ships",
"permissions": "Permissions",
"source": "Source",
"mountsAt": "Mounts at",
"sidecar": "Sidecar",
"deleteNamed": "Delete {{name}}",
"updateTo": "Update to {{version}}"
}
}
@@ -188,5 +188,30 @@
"extractedTo": "Extraído para “{{name}}”",
"downloadInstead": "Descarregar em vez disso",
"archiveFile": "Ficheiro de arquivo"
},
"plugins": {
"loading": "A carregar…",
"onThisMachine": "Nesta máquina",
"noPluginsIn": "Sem plugins em",
"available": "Disponíveis",
"needsOnThisMachine": "Requisitos nesta máquina",
"install": "Instalar",
"clonesInto": "Clona o repositório para",
"deleteData": "Eliminar também todos os seus dados",
"noTables": "Este plugin não declara tabelas, pelo que não há dados a guardar.",
"selectOne": "Selecione um plugin.",
"disable": "Desativar",
"enable": "Ativar",
"publisher": "Editor",
"version": "Versão",
"needsPlatform": "Requer a plataforma",
"wouldMount": "Montaria em",
"ships": "Inclui",
"permissions": "Permissões",
"source": "Origem",
"mountsAt": "Monta em",
"sidecar": "Sidecar",
"deleteNamed": "Eliminar {{name}}",
"updateTo": "Atualizar para {{version}}"
}
}