From 98c400bf336817b87cbf516e94c07cfa80185824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 20:27:58 +0000 Subject: [PATCH] a /plugins screen to install, enable, disable and uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/App.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 3 + .../Dashboard/Plugins/PluginsScreen.tsx | 25 ++++ .../Dashboard/Plugins/defaultLayout.ts | 14 ++ .../Screens/Dashboard/Plugins/index.tsx | 1 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../src/apps/Plugins/PluginDetail.tsx | 131 ++++++++++++++++++ .../src/apps/Plugins/PluginsList.tsx | 63 +++++++++ .../officerdev/src/apps/Plugins/index.ts | 17 +++ .../officerdev/src/apps/Plugins/usePlugins.ts | 81 +++++++++++ 12 files changed, 340 insertions(+) create mode 100644 src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx create mode 100644 src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx create mode 100644 src/workspaces/officerdev/src/apps/Plugins/index.ts create mode 100644 src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index adb6038f..3a576cad 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -64,6 +64,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index c1a67354..1c5659f6 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, ]; /** diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx new file mode 100644 index 00000000..240bc4c3 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Plugins/PluginsScreen.tsx @@ -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('screens/plugins', defaultLayout); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts new file mode 100644 index 00000000..044aab24 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Plugins/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx b/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx new file mode 100644 index 00000000..b191bc22 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Plugins/index.tsx @@ -0,0 +1 @@ +export * from './PluginsScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index edc23121..51a8304a 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -1,4 +1,5 @@ export * from './AppStore'; +export * from './Plugins'; export * from './Layout'; export * from './Home'; export * from './PasskeyGate'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3660b0e0..9219b8a1 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 5a2ba79a..e1dae034 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, ]; /** diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx new file mode 100644 index 00000000..74c1e2b2 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -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 }) => ( +
+ {label} + {children} +
+); + +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 ( + + ); +}; + +/** 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
Select a plugin.
; + } + + const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending; + + return ( +
+

{plugin.label}

+

{plugin.summary}

+ +
+ + /api{plugin.prefix} + + {plugin.publisher} + + {plugin.version} + {plugin.outdated ? ( + on disk — installed {plugin.installedVersion} + ) : null} + + {plugin.platform} + + + + + {plugin.permissions.length + ? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ') + : 'none — reachable by anyone who can reach the platform'} + +
+ +
+ {!plugin.installed ? ( + + ) : ( + <> + {plugin.enabled ? ( + + ) : ( + + )} + {plugin.outdated ? ( + + ) : null} + + + )} +
+ + {/* 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 ? ( +

+ Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes + anything the plugin stored. +

+ ) : null} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx new file mode 100644 index 00000000..57d17daf --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -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 ``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 not installed; + if (!plugin.enabled) return disabled; + if (plugin.outdated) return update available; + return enabled; +}; + +export const PluginsList = () => { + const { plugins, broken, isLoading } = usePlugins(); + const [params] = useSearchParams(); + const selected = params.get('selected'); + + if (isLoading) return
Loading…
; + + return ( +
+ {plugins.length === 0 && broken.length === 0 ? ( +
+ No plugins in plugins/ yet. +
+ ) : null} + + {plugins.map((plugin) => ( + + +
+
{plugin.label}
+
{plugin.prefix}
+
+ + + ))} + + {/* 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) => ( +
+ +
+
{b.appName}
+
{b.error}
+
+
+ ))} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Plugins/index.ts b/src/workspaces/officerdev/src/apps/Plugins/index.ts new file mode 100644 index 00000000..f95c5c1c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/index.ts @@ -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 }, +]; diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts new file mode 100644 index 00000000..fe20fc3e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -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//` 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, + }; +}