07038018ac
* fix(plugins): unload previous version before reloading on plugin update The install/update path reloaded a plugin without first unloading the previous version's registrations, unlike disable/uninstall. Because the slot registry only appends, this ran initialize() twice and left two main-top-bar slot entries, duplicating the plugin's widget icon until a page refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): cache-bust plugin bundle import so updates re-execute unloadPlugin(id, {evictCache: true}) drops the cached bundle registration on install/update, but a same-tab re-import() of the identical bundle URL returns the browser's already-evaluated ES module without re-running its top-level registerKandevPlugin() call — leaving an updated plugin active but missing from the UI. Key the bundle URL on the plugin's installed version (?v=<version>) on both the frontend (toActivePlugin) and the backend boot payload (bootActivePlugins) so a version bump forces a real re-import while an unchanged version stays cache-friendly across boots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(plugins): update bundleUrl assertions for cache-bust query param toActivePlugin now appends a ?v=<version> cache-busting query param to bundleUrl; these two assertions still expected the bare path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): only evict bundle cache on install/update, not on enable loadIfActive unconditionally evicted the cached bundle registration, which broke the disable->enable cycle: since bundleUrl doesn't change on plain enable, the browser's ES module cache returned the already-evaluated module without re-running registerKandevPlugin, leaving the plugin's slots/routes unregistered until a full page refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
258 lines
9.4 KiB
TypeScript
258 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { toast } from "sonner";
|
|
import type { StoreApi } from "zustand";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { useTheme } from "@/components/theme/app-theme";
|
|
import {
|
|
disablePlugin,
|
|
enablePlugin,
|
|
installPluginFromUrl,
|
|
installPluginUpload,
|
|
listPlugins,
|
|
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 {
|
|
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) {
|
|
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 };
|
|
}
|
|
|
|
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 uninstall = useUninstallAction(removePlugin);
|
|
const install = useInstallAction(upsertPlugin);
|
|
const sync = useSyncAction(setPlugins);
|
|
|
|
return { ...enableDisable, ...uninstall, ...install, ...sync };
|
|
}
|