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" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||||
|
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ import {
|
|||||||
Clapperboard,
|
Clapperboard,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Store,
|
Store,
|
||||||
|
Puzzle,
|
||||||
} from 'lucide-react';
|
} 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
|
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||||
// things that disappears when uninstalled.
|
// things that disappears when uninstalled.
|
||||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
{ 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 './AppStore';
|
||||||
|
export * from './Plugins';
|
||||||
export * from './Layout';
|
export * from './Layout';
|
||||||
export * from './Home';
|
export * from './Home';
|
||||||
export * from './PasskeyGate';
|
export * from './PasskeyGate';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
{ 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('/photos'), title: 'Photos' },
|
||||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
|
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
|
||||||
|
import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
|
||||||
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
|
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
|
||||||
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
|
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
|
||||||
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||||
@@ -45,6 +46,7 @@ export const apps = [
|
|||||||
...qrTransferMetas,
|
...qrTransferMetas,
|
||||||
...davMetas,
|
...davMetas,
|
||||||
...appStoreMetas,
|
...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