a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either side of a selection that lives in ?selected= and is read by both independently, so neither can be telling the other something stale — rows are real Links, not buttons holding the name in a closure. the detail panel shows what the tree declared (api, schema, sidecar, web), because "installed and nothing happened" is otherwise a mystery, and it names what uninstall does NOT do: neither disable nor uninstall deletes anything the plugin stored, and the screen says so rather than leaving someone to guess whether a button destroys their data. a directory whose manifest will not parse is listed with its error rather than skipped. a malformed plugin that simply does not appear is indistinguishable from one nobody wrote. `outdated` is surfaced as an Update button: the version on disk moving after an install is the normal state on a developer's machine, and it should be visible rather than inferred. the four mutations are written out rather than generated in a loop — useMutation is a hook, and a hook called from inside a helper is a rules-of-hooks violation even when the call order happens to be stable. caught before it shipped. verified against a running server: the spa builds (19.8 MB bundle containing the new screen), / serves 200, /api/plugins answers authenticated and 401s without a token. full suite 719 pass, same 10 pre-existing failures. live server and plugin_installs left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ export function App() {
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -181,6 +182,8 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
|
||||
// handler. This screen is the courtesy half of that.
|
||||
//
|
||||
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
|
||||
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
|
||||
export const PluginsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
|
||||
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
|
||||
// and destroy the side-by-side. See docs/navigation-audit.md.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'plugins-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PluginsScreen';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './AppStore';
|
||||
export * from './Plugins';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
|
||||
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
|
||||
import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
|
||||
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
|
||||
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
|
||||
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||
@@ -45,6 +46,7 @@ export const apps = [
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { usePlugins, type PluginItem } from './usePlugins';
|
||||
|
||||
// The right panel: one plugin, and the four verbs.
|
||||
//
|
||||
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
|
||||
// anything, so they cannot disagree.
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex gap-3 py-1.5 text-sm">
|
||||
<span className="w-28 shrink-0 text-duck-dark/50">{label}</span>
|
||||
<span className="min-w-0 text-duck-dark">{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Button = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'ghost',
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'primary' | 'ghost' | 'danger';
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const tones = {
|
||||
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
|
||||
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
|
||||
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors disabled:opacity-40 ${tones[tone]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** What the tree declared. Shown because "installed but nothing happened" is otherwise a mystery. */
|
||||
const Parts = ({ has }: { has: PluginItem['has'] }) => {
|
||||
const parts = [
|
||||
['api', has.api],
|
||||
['schema', has.schema],
|
||||
['sidecar', has.sidecar],
|
||||
['web', has.web],
|
||||
] as const;
|
||||
const present = parts.filter(([, yes]) => yes).map(([name]) => name);
|
||||
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
|
||||
};
|
||||
|
||||
export const PluginDetail = () => {
|
||||
const { plugins, install, uninstall, enable, disable } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const plugin = plugins.find((p) => p.appName === params.get('selected'));
|
||||
|
||||
if (!plugin) {
|
||||
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
||||
}
|
||||
|
||||
const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">{plugin.summary}</p>
|
||||
|
||||
<div className="mt-5 border-t border-duck-dark/10 pt-4">
|
||||
<Row label="Mounts at">
|
||||
<code>/api{plugin.prefix}</code>
|
||||
</Row>
|
||||
<Row label="Publisher">{plugin.publisher}</Row>
|
||||
<Row label="Version">
|
||||
{plugin.version}
|
||||
{plugin.outdated ? (
|
||||
<span className="ml-2 text-amber-600">on disk — installed {plugin.installedVersion}</span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Row label="Needs platform">{plugin.platform}</Row>
|
||||
<Row label="Ships">
|
||||
<Parts has={plugin.has} />
|
||||
</Row>
|
||||
<Row label="Permissions">
|
||||
{plugin.permissions.length
|
||||
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
|
||||
: 'none — reachable by anyone who can reach the platform'}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{!plugin.installed ? (
|
||||
<Button tone="primary" disabled={busy} onClick={() => install.mutate(plugin.appName)}>
|
||||
Install
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{plugin.enabled ? (
|
||||
<Button disabled={busy} onClick={() => disable.mutate(plugin.appName)}>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button tone="primary" disabled={busy} onClick={() => enable.mutate(plugin.appName)}>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{plugin.outdated ? (
|
||||
<Button disabled={busy} onClick={() => install.mutate(plugin.appName)}>
|
||||
Update to {plugin.version}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button tone="danger" disabled={busy} onClick={() => uninstall.mutate(plugin.appName)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
|
||||
leaving someone to guess whether the button destroys their data. */}
|
||||
{plugin.installed ? (
|
||||
<p className="mt-4 text-xs text-duck-dark/40">
|
||||
Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes
|
||||
anything the plugin stored.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Puzzle, AlertTriangle } from 'lucide-react';
|
||||
import { usePlugins, type PluginItem } from './usePlugins';
|
||||
|
||||
// The left panel: every plugin in the tree, installed or not.
|
||||
//
|
||||
// Rows are real `<Link>`s carrying `?selected=`, not buttons with the name in a closure — so cmd-click,
|
||||
// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told.
|
||||
// See docs/navigation-audit.md on the opaque-click anti-pattern.
|
||||
|
||||
const Status = ({ plugin }: { plugin: PluginItem }) => {
|
||||
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
|
||||
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
|
||||
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
|
||||
return <span className="text-xs text-emerald-600">enabled</span>;
|
||||
};
|
||||
|
||||
export const PluginsList = () => {
|
||||
const { plugins, broken, isLoading } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{plugins.length === 0 && broken.length === 0 ? (
|
||||
<div className="p-4 text-sm text-duck-dark/50">
|
||||
No plugins in <code>plugins/</code> yet.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plugins.map((plugin) => (
|
||||
<Link
|
||||
key={plugin.appName}
|
||||
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
|
||||
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
|
||||
<div className="truncate text-xs text-duck-dark/50">{plugin.prefix}</div>
|
||||
</div>
|
||||
<Status plugin={plugin} />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
|
||||
manifest looks exactly like a plugin nobody wrote. */}
|
||||
{broken.map((b) => (
|
||||
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
|
||||
<div className="text-xs text-red-600">{b.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PluginsList } from './PluginsList';
|
||||
import { PluginDetail } from './PluginDetail';
|
||||
|
||||
export { PluginsList } from './PluginsList';
|
||||
export { PluginDetail } from './PluginDetail';
|
||||
export { usePlugins } from './usePlugins';
|
||||
export type { PluginItem, PluginPermission } from './usePlugins';
|
||||
|
||||
// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and
|
||||
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
|
||||
// /plugins, together.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
|
||||
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Reading and driving the plugin system. One query, four verbs.
|
||||
//
|
||||
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
|
||||
// asking questions; this installs plugins from the tree and asks nothing.
|
||||
|
||||
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
|
||||
|
||||
export type PluginItem = {
|
||||
appName: string;
|
||||
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
|
||||
prefix: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
publisher: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
permissions: PluginPermission[];
|
||||
/** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
|
||||
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
installedVersion: string | null;
|
||||
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
|
||||
outdated: boolean;
|
||||
};
|
||||
|
||||
export type BrokenPlugin = { appName: string; error: string };
|
||||
|
||||
const PLUGINS_KEY = ['plugins'];
|
||||
|
||||
export function usePlugins() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: PLUGINS_KEY,
|
||||
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
|
||||
});
|
||||
|
||||
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
|
||||
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
|
||||
};
|
||||
|
||||
// Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a
|
||||
// helper is a rules-of-hooks violation even when the call order happens to be stable.
|
||||
const install = useMutation({
|
||||
mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const uninstall = useMutation({
|
||||
mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const enable = useMutation({
|
||||
mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const disable = useMutation({
|
||||
mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return {
|
||||
plugins: data?.plugins ?? [],
|
||||
broken: data?.broken ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
install,
|
||||
uninstall,
|
||||
enable,
|
||||
disable,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user