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 PluginItem,
type PluginVerb, type PluginVerb,
} from './usePlugins'; } from './usePlugins';
import { useTranslation } from '../../locales';
// The right panel: one plugin, and its verbs — install, enable, disable, update, delete. // 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. * "Requires: nothing" row is noise on every other plugin's page.
*/ */
const Dependencies = ({ deps }: { deps: PluginDependencies }) => { const Dependencies = ({ deps }: { deps: PluginDependencies }) => {
const { t, tUi } = useTranslation();
if (!deps.items.length) return null; if (!deps.items.length) return null;
return ( return (
<div className="mt-4 border-t border-duck-dark/10 pt-4"> <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"> <ul className="mt-2 space-y-2">
{deps.items.map((item) => ( {deps.items.map((item) => (
<li key={item.binary} className="flex gap-2 text-sm"> <li key={item.binary} className="flex gap-2 text-sm">
@@ -201,56 +203,59 @@ const AvailableDetail = ({
steps: ActionEntry[]; steps: ActionEntry[];
result: PluginActionResult | null; result: PluginActionResult | null;
running: PluginVerb | null; running: PluginVerb | null;
}) => ( }) => {
<div className="h-full overflow-auto p-6"> const { t } = useTranslation();
<div className="flex items-center gap-3"> return (
<AvailableGlyph plugin={plugin} /> <div className="h-full overflow-auto p-6">
<div className="min-w-0"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2> <AvailableGlyph plugin={plugin} />
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p> <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>
<div className="mt-5 border-t border-duck-dark/10 pt-4"> <div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Publisher">{plugin.publisher}</Row> <Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
<Row label="Version">{plugin.version}</Row> <Row label={t('plugins.version')}>{plugin.version}</Row>
<Row label="Needs platform">{plugin.platform}</Row> <Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
<Row label="Would mount"> <Row label={t('plugins.wouldMount')}>
<code>/api{plugin.prefix}</code> <code>/api{plugin.prefix}</code>
</Row> </Row>
<Row label="Ships"> <Row label={t('plugins.ships')}>
<Parts has={plugin.has} /> <Parts has={plugin.has} />
</Row> </Row>
<Row label="Permissions"> <Row label={t('plugins.permissions')}>
{plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'} {plugin.permissions.length ? plugin.permissions.map((p) => p.key).join(', ') : 'none'}
</Row> </Row>
{/* Shown because installing this runs `git clone` against it. An owner agreeing to put code on {/* 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. */} their machine should be able to read where it comes from without opening a config file. */}
<Row label="Source"> <Row label={t('plugins.source')}>
<code className="break-all text-xs">{plugin.source}</code> <code className="break-all text-xs">{plugin.source}</code>
</Row> </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> </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. * 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; busy: boolean;
onDelete: (dropData: boolean) => void; onDelete: (dropData: boolean) => void;
}) => { }) => {
const { t, tUi } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dropData, setDropData] = useState(false); const [dropData, setDropData] = useState(false);
@@ -334,7 +340,7 @@ const DeletePlugin = ({
setOpen(true); setOpen(true);
}} }}
> >
Delete {t('common.delete')}
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -360,7 +366,7 @@ const DeletePlugin = ({
className="mt-0.5" className="mt-0.5"
/> />
<span className="text-xs text-duck-dark"> <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"> <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 Drops every table this plugin owns. Without this its rows stay, and reinstalling later finds them
where they were. where they were.
@@ -368,12 +374,12 @@ const DeletePlugin = ({
</span> </span>
</label> </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"> <DialogFooter className="mt-2">
<Button disabled={busy} onClick={() => setOpen(false)}> <Button disabled={busy} onClick={() => setOpen(false)}>
Cancel {tUi('button.cancel')}
</Button> </Button>
<Button <Button
tone="danger" tone="danger"
@@ -383,7 +389,7 @@ const DeletePlugin = ({
onDelete(dropData); onDelete(dropData);
}} }}
> >
{dropData ? 'Delete it and its data' : `Delete ${plugin.label}`} {dropData ? 'Delete it and its data' : t('plugins.deleteNamed', { name: plugin.label })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -393,6 +399,7 @@ const DeletePlugin = ({
}; };
export const PluginDetail = () => { export const PluginDetail = () => {
const { t, tUi } = useTranslation();
const { plugins, available, steps, result, running, run } = usePlugins(); const { plugins, available, steps, result, running, run } = usePlugins();
const [params] = useSearchParams(); const [params] = useSearchParams();
const selected = params.get('selected'); const selected = params.get('selected');
@@ -417,7 +424,7 @@ export const PluginDetail = () => {
} }
if (!plugin) { 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 ( return (
@@ -431,22 +438,22 @@ export const PluginDetail = () => {
</div> </div>
<div className="mt-5 border-t border-duck-dark/10 pt-4"> <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> <code>/api{plugin.prefix}</code>
</Row> </Row>
<Row label="Publisher">{plugin.publisher}</Row> <Row label={t('plugins.publisher')}>{plugin.publisher}</Row>
<Row label="Version"> <Row label={t('plugins.version')}>
{plugin.version} {plugin.version}
{plugin.outdated ? ( {plugin.outdated ? (
<span className="ml-2 text-busy-ink">on disk — installed {plugin.installedVersion}</span> <span className="ml-2 text-busy-ink">on disk — installed {plugin.installedVersion}</span>
) : null} ) : null}
</Row> </Row>
<Row label="Needs platform">{plugin.platform}</Row> <Row label={t('plugins.needsPlatform')}>{plugin.platform}</Row>
<Row label="Ships"> <Row label={t('plugins.ships')}>
<Parts has={plugin.has} /> <Parts has={plugin.has} />
</Row> </Row>
{plugin.has.sidecar ? ( {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 {/* 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. */} process is not running, which is broken rather than off. */}
<span <span
@@ -460,7 +467,7 @@ export const PluginDetail = () => {
</span> </span>
</Row> </Row>
) : null} ) : null}
<Row label="Permissions"> <Row label={t('plugins.permissions')}>
{plugin.permissions.length {plugin.permissions.length
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ') ? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
: 'none — reachable by anyone who can reach the platform'} : 'none — reachable by anyone who can reach the platform'}
@@ -494,7 +501,7 @@ export const PluginDetail = () => {
disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)} disabled={busy || (!plugin.dependencies.satisfied && !plugin.dependencies.canInstall)}
onClick={() => run('install', plugin.appName)} onClick={() => run('install', plugin.appName)}
> >
Install {t('plugins.install')}
</Button> </Button>
) : null} ) : null}
{!plugin.installed ? ( {!plugin.installed ? (
@@ -508,11 +515,11 @@ export const PluginDetail = () => {
<> <>
{plugin.enabled ? ( {plugin.enabled ? (
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}> <Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
Disable {t('plugins.disable')}
</Button> </Button>
) : ( ) : (
<Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}> <Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}>
Enable {t('plugins.enable')}
</Button> </Button>
)} )}
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the {/* 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 which cannot fetch: the directory exists, so the clone is skipped and only the apply
steps re-ran. `update` pulls first. */} steps re-ran. `update` pulls first. */}
<Button disabled={busy} onClick={() => run('update', plugin.appName)}> <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> </Button>
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being {/* 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. */} 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 { Puzzle, AlertTriangle, Database } from 'lucide-react';
import { usePlugins, type OrphanTable, type PluginItem } from './usePlugins'; import { usePlugins, type OrphanTable, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon'; import { resolveIcon } from '../../utils/resolve-icon';
import { useTranslation } from '../../locales';
// The left panel: every plugin in the tree, installed or not. // The left panel: every plugin in the tree, installed or not.
// //
@@ -17,11 +18,12 @@ const Status = ({ plugin }: { plugin: PluginItem }) => {
}; };
export const PluginsList = () => { export const PluginsList = () => {
const { t } = useTranslation();
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins(); const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll, updateAllResult } = usePlugins();
const [params] = useSearchParams(); const [params] = useSearchParams();
const selected = params.get('selected'); 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 ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
@@ -34,11 +36,13 @@ export const PluginsList = () => {
that: a count would imply a check that has not happened. */} that: a count would imply a check that has not happened. */}
{plugins.length ? ( {plugins.length ? (
<div className="flex items-center justify-between gap-2 border-b border-duck-dark/5 px-3 py-2"> <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 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 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. */} 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 <button
type="button" type="button"
onClick={() => void updateAll()} onClick={() => void updateAll()}
@@ -73,7 +77,7 @@ export const PluginsList = () => {
{plugins.length === 0 && available.length === 0 && broken.length === 0 ? ( {plugins.length === 0 && available.length === 0 && broken.length === 0 ? (
<div className="p-4 text-sm text-duck-dark/50"> <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> </div>
) : null} ) : null}
@@ -113,7 +117,9 @@ export const PluginsList = () => {
directory and the next read discovers it like any other. */} directory and the next read discovers it like any other. */}
{available.length ? ( {available.length ? (
<div className="border-b border-duck-dark/5 bg-duck-dark/[0.02] px-3 py-1.5"> <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> </div>
) : null} ) : null}
{available.map((plugin) => ( {available.map((plugin) => (
@@ -188,5 +188,30 @@
"extractedTo": "Extracted to “{{name}}”", "extractedTo": "Extracted to “{{name}}”",
"downloadInstead": "Download instead", "downloadInstead": "Download instead",
"archiveFile": "Archive file" "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}}”", "extractedTo": "Extraído para “{{name}}”",
"downloadInstead": "Descarregar em vez disso", "downloadInstead": "Descarregar em vez disso",
"archiveFile": "Ficheiro de arquivo" "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}}"
} }
} }