Files
kandev/apps/web/components/settings/plugins/use-plugin-actions.ts
T
2026-08-02 22:25:27 +01:00

299 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { toast } from "@/lib/toast/sonner";
import type { StoreApi } from "zustand";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useTheme } from "@/components/theme/app-theme";
import {
disablePlugin,
enablePlugin,
getPlugin,
installPluginFromUrl,
installPluginUpload,
listPlugins,
setPluginAutoUpdate,
syncPlugins,
uninstallPlugin,
} from "@/lib/api/domains/plugins-api";
import { toActivePlugin } from "@/lib/plugins/active-plugin";
import { buildHostApi } from "@/lib/plugins/host-api";
import { loadPlugins, unloadPlugin } from "@/lib/plugins/host";
import { summarizeSyncResult } from "@/lib/plugins/sync-summary";
import type { InstallResult } from "@/lib/api/domains/plugins-api";
import type { PluginRecord, PluginStatus, SyncError } from "@/lib/types/plugins";
import type { AppState } from "@/lib/state/store";
function withStatus(plugin: PluginRecord, status: PluginStatus): PluginRecord {
if (status === "active") {
return { ...plugin, status, last_error: undefined, last_error_at: undefined };
}
return { ...plugin, status };
}
/**
* Loads a plugin's UI bundle into the running app, if it declares one.
* Unloads any previous registrations first — a first-time load is a safe
* no-op through `unloadPlugin` since nothing is registered yet.
*
* `evictCache` controls whether the cached bundle registration is dropped
* before reloading:
* - Install/update (`afterInstall`) passes `evictCache: true`: the backend
* install endpoint is also how an update is applied, so without eviction an
* update would leave the prior version's nav/route/slot registrations in
* place alongside the new ones (e.g. a duplicated top-bar widget), and
* `loadPlugins` would skip re-importing the bundle and re-run the old
* version's `initialize()` against the new registry entry.
* - Plain enable (`handleEnable`) passes `evictCache: false`: the disable path
* intentionally keeps the cached registration (see `unloadPlugin`'s doc) so
* a same-tab disable→enable cycle can reuse it without depending on the
* browser re-executing the bundle's module-eval side effect — the
* `bundleUrl` is unchanged, so a forced re-import would silently return the
* already-evaluated module without calling `registerKandevPlugin` again.
*/
async function loadIfActive(
record: PluginRecord,
storeApi: StoreApi<AppState>,
theme: "light" | "dark",
evictCache: boolean,
) {
if (record.status !== "active") return;
const active = toActivePlugin(record);
if (!active) return;
if (evictCache) {
unloadPlugin(record.id, { evictCache: true });
} else {
unloadPlugin(record.id);
}
await loadPlugins([active], (pluginId) => buildHostApi(pluginId, storeApi, theme));
}
/**
* Enable/disable action wiring, per task-20's output contract: enabling a
* plugin with a UI bundle calls the task-18 runtime `loadPlugins` for just
* that plugin (no full page reload); disabling calls `unloadPlugin` to
* revoke its nav items/routes/slots immediately.
*/
function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
const storeApi = useAppStoreApi();
const { resolvedTheme } = useTheme();
const [busyId, setBusyId] = useState<string | null>(null);
const handleEnable = async (plugin: PluginRecord) => {
setBusyId(plugin.id);
try {
await enablePlugin(plugin.id);
const updated = withStatus(plugin, "active");
upsertPlugin(updated);
await loadIfActive(updated, storeApi, resolvedTheme, false);
} catch (err) {
try {
const refreshed = await getPlugin(plugin.id, { cache: "no-store" });
upsertPlugin(refreshed);
} catch {
// Preserve the original Enable failure toast if the diagnostic refresh
// itself cannot reach the backend.
}
toast.error(err instanceof Error ? err.message : `Failed to enable ${plugin.display_name}`);
} finally {
setBusyId(null);
}
};
const handleDisable = async (plugin: PluginRecord) => {
setBusyId(plugin.id);
try {
await disablePlugin(plugin.id);
unloadPlugin(plugin.id);
upsertPlugin(withStatus(plugin, "disabled"));
} catch (err) {
toast.error(err instanceof Error ? err.message : `Failed to disable ${plugin.display_name}`);
} finally {
setBusyId(null);
}
};
return { busyId, handleEnable, handleDisable };
}
/**
* Per-plugin auto-update override wiring: PUT /api/plugins/:id/auto-update,
* then upsert the returned record so the row's toggle reflects the new
* override. `value` is `true`/`false` to force the plugin on/off, or `null` to
* clear the override and inherit the instance-wide default again.
*/
function useAutoUpdateAction(upsertPlugin: (p: PluginRecord) => void) {
const [autoUpdateBusyId, setAutoUpdateBusyId] = useState<string | null>(null);
const handleSetAutoUpdate = async (plugin: PluginRecord, value: boolean | null) => {
setAutoUpdateBusyId(plugin.id);
try {
const updated = await setPluginAutoUpdate(plugin.id, value);
upsertPlugin(updated);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: `Failed to update auto-update for ${plugin.display_name}`,
);
} finally {
setAutoUpdateBusyId(null);
}
};
return { autoUpdateBusyId, handleSetAutoUpdate };
}
function useUninstallAction(removePlugin: (id: string) => void) {
const [uninstallTarget, setUninstallTarget] = useState<PluginRecord | null>(null);
const [uninstallBusy, setUninstallBusy] = useState(false);
const confirmUninstall = async () => {
if (!uninstallTarget) return;
const target = uninstallTarget;
setUninstallBusy(true);
try {
await uninstallPlugin(target.id);
unloadPlugin(target.id);
removePlugin(target.id);
setUninstallTarget(null);
} catch (err) {
toast.error(
err instanceof Error ? err.message : `Failed to uninstall ${target.display_name}`,
);
} finally {
setUninstallBusy(false);
}
};
return {
uninstallTarget,
uninstallBusy,
openUninstall: setUninstallTarget,
closeUninstall: () => setUninstallTarget(null),
confirmUninstall,
};
}
/**
* Install-plugin dialog wiring. Install-from-URL and upload share the same
* open/busy/error state and post-install effect: upsert the record into the
* store, and if the backend already brought it up `active` with a UI
* bundle, hot-load it into the running app (no full page reload) — same as
* the enable path.
*/
function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
const storeApi = useAppStoreApi();
const { resolvedTheme } = useTheme();
const [installOpen, setInstallOpenState] = useState(false);
const [installBusy, setInstallBusy] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const closeInstallDialog = () => {
setInstallOpenState(false);
setInstallError(null);
};
// A partial-install warning (package installed but failed to spawn — the
// backend leaves Plugin.Status "error") must not be masked by a green
// "installed" toast, so it takes priority over the success toast.
const afterInstall = async ({ plugin, warning }: InstallResult) => {
upsertPlugin(plugin);
await loadIfActive(plugin, storeApi, resolvedTheme, true);
if (warning) {
toast.warning(warning);
} else {
toast.success(`${plugin.display_name} installed`);
}
closeInstallDialog();
};
const runInstall = async (install: () => Promise<InstallResult>) => {
setInstallBusy(true);
setInstallError(null);
try {
const result = await install();
await afterInstall(result);
} catch (err) {
setInstallError(err instanceof Error ? err.message : "Failed to install plugin");
} finally {
setInstallBusy(false);
}
};
// marketplaceInstall runs the same post-install effect as the dialog
// (upsert + hot-load if active + success toast), but surfaces failures as a
// toast rather than the dialog-scoped installError region — the Browse tab
// has no such region. It resolves even on failure (after toasting) so its
// fire-and-forget onClick callers never leak an unhandled rejection; their
// try/finally still clears per-entry busy state.
const marketplaceInstall = async (url: string) => {
try {
const result = await installPluginFromUrl(url);
await afterInstall(result);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to install plugin");
}
};
return {
installOpen,
openInstall: () => setInstallOpenState(true),
setInstallOpen: (open: boolean) => (open ? setInstallOpenState(true) : closeInstallDialog()),
installBusy,
installError,
submitInstallUrl: (url: string) => runInstall(() => installPluginFromUrl(url)),
submitInstallFile: (file: File) => runInstall(() => installPluginUpload(file)),
marketplaceInstall,
closeInstallDialog,
};
}
/**
* Sync-button wiring: POST /api/plugins/sync, then refresh the plugin list
* via the same GET /api/plugins call usePlugins itself makes on mount, and
* summarize the result as a toast. result.errors are kept in state for an
* inline `plugins-sync-errors` region rather than only living in the toast,
* so they stay visible without depending on toast auto-dismiss timing.
*
* A sync can bring a dropped tarball plugin all the way to `active` with a
* UI bundle, but (unlike install/enable) this does not hot-load it — an
* operator can re-enable it (or reload) to pick up the bundle; wiring a
* silent hot-load here is out of scope for the sync button itself.
*/
function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) {
const [syncBusy, setSyncBusy] = useState(false);
const [syncErrors, setSyncErrors] = useState<SyncError[]>([]);
const handleSync = async () => {
setSyncBusy(true);
try {
const result = await syncPlugins();
const refreshed = await listPlugins({ cache: "no-store" });
setPlugins(refreshed);
setSyncErrors(result.errors ?? []);
toast.success(summarizeSyncResult(result));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to sync plugins");
} finally {
setSyncBusy(false);
}
};
return { syncBusy, syncErrors, handleSync };
}
export function usePluginActions() {
const upsertPlugin = useAppStore((s) => s.upsertPlugin);
const removePlugin = useAppStore((s) => s.removePlugin);
const setPlugins = useAppStore((s) => s.setPlugins);
const enableDisable = useEnableDisableActions(upsertPlugin);
const autoUpdate = useAutoUpdateAction(upsertPlugin);
const uninstall = useUninstallAction(removePlugin);
const install = useInstallAction(upsertPlugin);
const sync = useSyncAction(setPlugins);
return { ...enableDisable, ...autoUpdate, ...uninstall, ...install, ...sync };
}