feat(plugins): add dedicated settings navigation and improve configuration UX

This commit is contained in:
tianma-if committed 2026-09-03 14:21:15 +08:00
1 parent f4aa8c5467
commit 4432f2ea47
10 files changed
+385 -198

No files matched your search

@@ -1,6 +1,6 @@
import { ChevronLeft, Store } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router";
import { useNavigate, useParams, useSearchParams } from "react-router";
import * as m from "motion/react-m";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ThemeToggle";
@@ -9,6 +9,7 @@ import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { contentEnterMotion } from "@/lib/motion";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { ExecutionCenterButton } from "@/components/execution/ExecutionCenterButton";
import { getPluginDetailPath } from "@/lib/plugins/plugin-navigation";
export const PluginMarketplacePane = ({
host,
@@ -22,6 +23,7 @@ export const PluginMarketplacePane = ({
const { t } = useTranslation();
const navigate = useNavigate();
const { pluginId = null } = useParams<{ pluginId: string }>();
const [searchParams] = useSearchParams();
const close = () => {
if (pluginId) {
navigate("/plugins");
@@ -59,7 +61,8 @@ export const PluginMarketplacePane = ({
<PluginManagerCard
host={host}
selectedPluginId={pluginId}
onOpenPlugin={(id) => navigate(`/plugins/${encodeURIComponent(id)}`)}
requestedPage={searchParams.get("view")}
onOpenPlugin={(id) => navigate(getPluginDetailPath(id))}
onClosePlugin={() => navigate("/plugins")}
/>
</m.div>
@@ -0,0 +1,168 @@
import { useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginManifest, PluginSettingValue } from "@edgeever/plugin-api";
import type { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export const PluginSettingsSection = ({ host, manifest }: { host: EdgeEverPluginHost; manifest: PluginManifest }) => {
const { t } = useTranslation();
const formId = useId();
const fields = manifest.settings?.fields ?? [];
const [values, setValues] = useState<Record<string, PluginSettingValue | "">>({});
const [configuredSecrets, setConfiguredSecrets] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(fields.length > 0);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadAttempt, setLoadAttempt] = useState(0);
useEffect(() => {
let active = true;
setLoading(fields.length > 0);
setMessage(null);
setError(null);
setLoadError(null);
void Promise.all(fields.map(async (field) => {
if (field.type === "secret") return { key: field.key, value: "" as const, configured: await host.hasSettingValue(manifest.id, field.key) };
return { key: field.key, value: await host.getSettingValue(manifest.id, field.key) ?? "", configured: false };
})).then((loaded) => {
if (!active) return;
setValues(Object.fromEntries(loaded.map((item) => [item.key, item.value])));
setConfiguredSecrets(Object.fromEntries(loaded.map((item) => [item.key, item.configured])));
setLoading(false);
}).catch((error) => {
if (!active) return;
setLoadError(error instanceof Error ? error.message : String(error));
setLoading(false);
});
return () => { active = false; };
}, [host, manifest.id, manifest.version, manifest.settings, loadAttempt]);
if (fields.length === 0) return null;
const clearFeedback = () => {
setMessage(null);
setError(null);
};
const save = async () => {
setSaving(true);
setMessage(null);
setError(null);
try {
// Check every required field before writing any values.
for (const field of fields) {
const value = values[field.key];
if (field.type === "secret" && value === "" && configuredSecrets[field.key]) continue;
if (field.required && (value == null || (typeof value === "string" && !value.trim()))) {
throw new Error(t("plugins.settings.required", { name: field.label }));
}
}
for (const field of fields) {
const value = values[field.key];
if (field.type === "secret" && value === "") {
continue;
}
if (value === "") {
await host.removeSettingValue(manifest.id, field.key);
continue;
}
await host.setSettingValue(manifest.id, field.key, value);
if (field.type === "secret") {
setConfiguredSecrets((current) => ({ ...current, [field.key]: true }));
setValues((current) => ({ ...current, [field.key]: "" }));
}
}
setMessage(t("plugins.settings.saved"));
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
return (
<section className="min-w-0">
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.settings.title")}</h3>
<p className="mt-2 max-w-2xl text-xs leading-5 text-slate-500">{t("plugins.settings.description")}</p>
{loading ? <p className="mt-3 text-xs text-slate-400" role="status">{t("common.loading")}</p> : loadError ? (
<div className="mt-4 grid justify-items-start gap-3">
<p className="text-sm text-rose-600" role="alert">{t("plugins.settings.loadFailed", { message: loadError })}</p>
<Button size="sm" variant="outline" onClick={() => setLoadAttempt((attempt) => attempt + 1)}>{t("plugins.settings.retry")}</Button>
</div>
) : (
<form className="mt-5 max-w-2xl" onChange={clearFeedback} onSubmit={(event) => { event.preventDefault(); void save(); }}>
<fieldset disabled={saving} className="grid min-w-0 gap-5">
{fields.map((field) => {
const value = values[field.key] ?? "";
const inputId = `${formId}-${field.key}`;
const descriptionId = field.description ? `${inputId}-description` : undefined;
return (
<div key={field.key} className="grid gap-1.5 text-xs text-slate-700">
<label htmlFor={inputId} className="font-medium">{field.label}{field.required ? " *" : ""}</label>
{field.type === "boolean" ? (
<Switch
id={inputId}
aria-describedby={descriptionId}
aria-label={field.label}
checked={value === true}
onCheckedChange={(checked) => { clearFeedback(); setValues((current) => ({ ...current, [field.key]: checked })); }}
/>
) : field.type === "select" ? (
<Select
disabled={saving}
value={value === "" ? (field.required ? "" : "none") : `option:${value}`}
onValueChange={(next) => {
clearFeedback();
setValues((current) => ({ ...current, [field.key]: next === "none" ? "" : next.slice(7) }));
}}
>
<SelectTrigger id={inputId} aria-describedby={descriptionId} aria-required={field.required}>
<SelectValue placeholder={t("plugins.settings.none")} />
</SelectTrigger>
<SelectContent>
{!field.required ? <SelectItem value="none">{t("plugins.settings.none")}</SelectItem> : null}
{field.options.map((option) => <SelectItem key={option.value} value={`option:${option.value}`}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input
id={inputId}
aria-describedby={descriptionId}
required={field.required && !(field.type === "secret" && configuredSecrets[field.key])}
autoComplete={field.type === "secret" ? "new-password" : "off"}
type={field.type === "secret" ? "password" : field.type === "number" ? "number" : "text"}
value={String(value)}
placeholder={field.type === "secret" && configuredSecrets[field.key]
? t("plugins.settings.secretConfigured")
: field.type === "text" || field.type === "secret"
? field.placeholder
: undefined}
min={field.type === "number" ? field.min : undefined}
max={field.type === "number" ? field.max : undefined}
step={field.type === "number" ? field.step ?? "any" : undefined}
onChange={(event) => setValues((current) => ({
...current,
[field.key]: field.type === "number" && event.target.value !== "" ? Number(event.target.value) : event.target.value,
}))}
/>
)}
{field.description ? <p id={descriptionId} className="text-slate-500">{field.description}</p> : null}
</div>
);
})}
<div className="flex items-center gap-3">
<Button type="submit" size="sm" disabled={saving}>{saving ? t("common.saving") : t("common.save")}</Button>
{message ? <span className="text-xs text-slate-500" role="status">{message}</span> : null}
</div>
{error ? <p className="text-sm text-rose-600" role="alert">{t("plugins.settings.saveFailed", { message: error })}</p> : null}
</fieldset>
</form>
)}
</section>
);
};
@@ -1,6 +1,7 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { Clock3, LoaderCircle, PanelRightOpen, Play, Puzzle, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -13,6 +14,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { PluginPanelDialog } from "@/components/plugins/PluginPanelDialog";
import { cn } from "@/lib/utils";
import { getPluginDetailPath, getPluginToolbarGroups } from "@/lib/plugins/plugin-navigation";
import type {
EdgeEverPluginHost,
RegisteredPluginAction,
@@ -36,15 +38,8 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
const activePanelPluginId = activePanel?.pluginId ?? null;
const activePanelId = activePanel?.id ?? null;
const groups = snapshot.extensions.flatMap((extension) => {
if (!extension.enabled || extension.manifest.type !== "plugin") return [];
const actions: RegisteredPluginAction[] = [
...snapshot.commands.filter((command) => command.pluginId === extension.manifest.id).map((command) => ({ ...command, type: "command" as const })),
...snapshot.panels.filter((panel) => panel.pluginId === extension.manifest.id).map((panel) => ({ ...panel, type: "panel" as const })),
];
return actions.length ? [{ pluginId: extension.manifest.id, name: extension.manifest.name, actions }] : [];
});
const hasActions = groups.length > 0;
const groups = getPluginToolbarGroups(snapshot);
const hasActions = groups.some((group) => group.actions.length > 0);
const pluginNames = new Map(snapshot.extensions.map((extension) => [extension.manifest.id, extension.manifest.name]));
const activePanelRegistered = Boolean(activePanelPluginId && activePanelId && snapshot.panels.some(
(panel) => panel.pluginId === activePanelPluginId && panel.id === activePanelId
@@ -137,11 +132,19 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
</>
) : null}
{hasActions ? groups.map((group, index) => (
{groups.length > 0 ? groups.map((group, index) => (
<div key={group.pluginId}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuLabel className="truncate text-xs text-slate-500">{group.name}</DropdownMenuLabel>
{group.actions.map((action) => renderAction(action, group.pluginId))}
{group.hasSettings ? (
<DropdownMenuItem asChild className="gap-2" onSelect={() => setOpen(false)}>
<Link to={getPluginDetailPath(group.pluginId, "settings")} aria-label={t("plugins.settings.open", { name: group.name })}>
<Settings2 className="h-4 w-4 text-slate-500" />
{t("plugins.settings.title")}
</Link>
</DropdownMenuItem>
) : null}
</div>
)) : (
<div className="px-2 py-5 text-center text-xs leading-5 text-slate-500">{t("plugins.toolbar.empty")}</div>
@@ -1,5 +1,6 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import { BadgeCheck, BookOpen, CalendarClock, Download, ExternalLink, History, PanelRightOpen, Play, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { BadgeCheck, BookOpen, CalendarClock, Download, ExternalLink, History, PanelRightOpen, Play, Puzzle, RefreshCw, Settings2, Trash2 } from "lucide-react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -12,7 +13,8 @@ import { loadPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
import { GitHubMark } from "@/components/GitHubRepositoryLink";
import { checkPluginUpdates, type PluginUpdateInfo } from "@/lib/plugins/plugin-updates";
import { PluginUpdateDialog } from "@/components/plugins/PluginUpdateDialog";
import type { PluginManifest, PluginSettingValue } from "@edgeever/plugin-api";
import { PluginSettingsSection } from "@/components/plugins/PluginSettingsSection";
import { getPluginDetailPage, getPluginDetailPath, hasPluginSettings, type PluginDetailPage } from "@/lib/plugins/plugin-navigation";
import type { ScheduledTask } from "@edgeever/shared";
import { Cron } from "croner";
import { api, getOrCreateClientDeviceId } from "@/lib/api";
@@ -223,124 +225,9 @@ const ScheduledPluginTasksSection = ({ commands }: { commands: RegisteredPluginC
);
};
const PluginSettingsSection = ({ host, manifest }: { host: EdgeEverPluginHost; manifest: PluginManifest }) => {
const { t } = useTranslation();
const fields = manifest.settings?.fields ?? [];
const [values, setValues] = useState<Record<string, PluginSettingValue | "">>({});
const [configuredSecrets, setConfiguredSecrets] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(fields.length > 0);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLoading(fields.length > 0);
setMessage(null);
void Promise.all(fields.map(async (field) => {
if (field.type === "secret") return { key: field.key, value: "" as const, configured: await host.hasSettingValue(manifest.id, field.key) };
return { key: field.key, value: await host.getSettingValue(manifest.id, field.key) ?? "", configured: false };
})).then((loaded) => {
if (!active) return;
setValues(Object.fromEntries(loaded.map((item) => [item.key, item.value])));
setConfiguredSecrets(Object.fromEntries(loaded.map((item) => [item.key, item.configured])));
setLoading(false);
}).catch((error) => {
if (!active) return;
setMessage(error instanceof Error ? error.message : String(error));
setLoading(false);
});
return () => { active = false; };
}, [host, manifest.id, manifest.version]);
if (fields.length === 0) return null;
const save = async () => {
setSaving(true);
setMessage(null);
try {
for (const field of fields) {
const value = values[field.key];
if (field.type === "secret" && value === "") {
if (field.required && !configuredSecrets[field.key]) throw new Error(t("plugins.settings.required", { name: field.label }));
continue;
}
if (value === "") {
if (field.required) throw new Error(t("plugins.settings.required", { name: field.label }));
await host.removeSettingValue(manifest.id, field.key);
continue;
}
await host.setSettingValue(manifest.id, field.key, value);
if (field.type === "secret") {
setConfiguredSecrets((current) => ({ ...current, [field.key]: true }));
setValues((current) => ({ ...current, [field.key]: "" }));
}
}
setMessage(t("plugins.settings.saved"));
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setSaving(false);
}
};
return (
<section className="rounded-lg border border-slate-200 p-4">
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.settings.title")}</h3>
{loading ? <p className="mt-3 text-xs text-slate-400">{t("common.loading")}</p> : (
<div className="mt-3 grid gap-4">
{fields.map((field) => {
const value = values[field.key] ?? "";
return (
<label key={field.key} className="grid gap-1.5 text-xs text-slate-700">
<span className="font-medium">{field.label}{field.required ? " *" : ""}</span>
{field.type === "boolean" ? (
<Switch
aria-label={field.label}
checked={value === true}
onCheckedChange={(checked) => setValues((current) => ({ ...current, [field.key]: checked }))}
/>
) : field.type === "select" ? (
<select
className="h-9 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
value={String(value)}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
>
{!field.required ? <option value="">{t("plugins.settings.none")}</option> : null}
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : (
<Input
type={field.type === "secret" ? "password" : field.type === "number" ? "number" : "text"}
value={String(value)}
placeholder={field.type === "secret" && configuredSecrets[field.key]
? t("plugins.settings.secretConfigured")
: field.type === "text" || field.type === "secret"
? field.placeholder
: undefined}
min={field.type === "number" ? field.min : undefined}
max={field.type === "number" ? field.max : undefined}
step={field.type === "number" ? field.step : undefined}
onChange={(event) => setValues((current) => ({
...current,
[field.key]: field.type === "number" && event.target.value !== "" ? Number(event.target.value) : event.target.value,
}))}
/>
)}
{field.description ? <span className="text-slate-400">{field.description}</span> : null}
</label>
);
})}
<div className="flex items-center gap-3">
<Button size="sm" disabled={saving} onClick={() => void save()}>{saving ? t("common.saving") : t("common.save")}</Button>
{message ? <span className="text-xs text-slate-500" role="status">{message}</span> : null}
</div>
</div>
)}
</section>
);
};
const PluginDetailView = ({
page,
commands,
extension,
host,
@@ -353,6 +240,7 @@ const PluginDetailView = ({
onUninstall,
onUpdate,
}: {
page: PluginDetailPage;
commands: RegisteredPluginCommand[];
extension: InstalledExtension;
host: EdgeEverPluginHost;
@@ -393,78 +281,96 @@ const PluginDetailView = ({
/>
</div>
<dl className="grid gap-3 rounded-lg bg-slate-50 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
[t("plugins.details.type"), manifest.type],
[t("plugins.details.version"), `v${manifest.version}`],
[t("plugins.details.source"), t(`plugins.sources.${sourceKey}`)],
[t("plugins.details.installedAt"), new Date(extension.installedAt).toLocaleString(i18n.language)],
].map(([label, value]) => (
<div key={label} className="min-w-0">
<dt className="text-xs text-slate-400">{label}</dt>
<dd className="mt-1 truncate font-medium text-slate-700">{value}</dd>
</div>
))}
</dl>
{extension.source.repositoryUrl ? (
<a className="inline-flex w-fit max-w-full items-center gap-2 text-sm text-slate-500 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-4 w-4 shrink-0" />
<span className="truncate">{extension.source.repositoryUrl.replace("https://github.com/", "")}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
</a>
{hasPluginSettings(manifest) ? (
<nav className="flex gap-2 border-b border-slate-200 pb-3" aria-label={t("plugins.details.navigation")}>
<Button asChild size="sm" variant={page === "overview" ? "soft" : "ghost"}>
<Link to={getPluginDetailPath(id)} aria-current={page === "overview" ? "page" : undefined}>{t("plugins.details.overview")}</Link>
</Button>
<Button asChild size="sm" variant={page === "settings" ? "soft" : "ghost"} className="gap-1.5">
<Link to={getPluginDetailPath(id, "settings")} aria-current={page === "settings" ? "page" : undefined}>
<Settings2 className="h-3.5 w-3.5" />
{t("plugins.settings.title")}
</Link>
</Button>
</nav>
) : null}
{manifest.type === "plugin" && manifest.permissions.length > 0 ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.permissions")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.permissions.map((permission) => (
<span key={permission} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">{permissionLabel(permission)}</span>
{page === "settings" && manifest.type === "plugin" ? (
<PluginSettingsSection key={`${id}:${manifest.version}`} host={host} manifest={manifest} />
) : (
<>
<dl className="grid gap-3 rounded-lg bg-slate-50 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
{[
[t("plugins.details.type"), manifest.type],
[t("plugins.details.version"), `v${manifest.version}`],
[t("plugins.details.source"), t(`plugins.sources.${sourceKey}`)],
[t("plugins.details.installedAt"), new Date(extension.installedAt).toLocaleString(i18n.language)],
].map(([label, value]) => (
<div key={label} className="min-w-0">
<dt className="text-xs text-slate-400">{label}</dt>
<dd className="mt-1 truncate font-medium text-slate-700">{value}</dd>
</div>
))}
</div>
</section>
) : null}
</dl>
{manifest.type === "plugin" && manifest.networkHosts?.length ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.networkHosts")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.networkHosts.map((hostname) => (
<span key={hostname} className="rounded-full bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600">{hostname}</span>
{extension.source.repositoryUrl ? (
<a className="inline-flex w-fit max-w-full items-center gap-2 text-sm text-slate-500 hover:text-emerald-700" href={extension.source.repositoryUrl} target="_blank" rel="noreferrer">
<GitHubMark className="h-4 w-4 shrink-0" />
<span className="truncate">{extension.source.repositoryUrl.replace("https://github.com/", "")}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
</a>
) : null}
{manifest.type === "plugin" && manifest.permissions.length > 0 ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.permissions")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.permissions.map((permission) => (
<span key={permission} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">{permissionLabel(permission)}</span>
))}
</div>
</section>
) : null}
{manifest.type === "plugin" && manifest.networkHosts?.length ? (
<section>
<h3 className="text-xs font-semibold text-slate-700">{t("plugins.details.networkHosts")}</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{manifest.networkHosts.map((hostname) => (
<span key={hostname} className="rounded-full bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600">{hostname}</span>
))}
</div>
</section>
) : null}
{extension.error ? <div className="text-sm text-rose-600">{extension.error}</div> : null}
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
{update ? (
<Button size="sm" className="gap-1.5" disabled={pendingId === `update:${id}`} onClick={onUpdate}>
<Download className="h-3.5 w-3.5" />
{t("plugins.updates.update")}
</Button>
) : null}
{commands.map((command) => (
<Button key={command.id} size="sm" variant="outline" className="gap-1.5" disabled={pendingId === `${id}:${command.id}`} onClick={() => onRunCommand(command)}>
<Play className="h-3.5 w-3.5" />
{command.title}
</Button>
))}
{panels.map((panel) => (
<Button key={panel.id} size="sm" variant="outline" className="gap-1.5" onClick={() => onOpenPanel(panel)}>
<PanelRightOpen className="h-3.5 w-3.5" />
{panel.title}
</Button>
))}
<Button size="sm" variant="ghost" className="ml-auto gap-1.5 text-rose-600 hover:bg-rose-50 hover:text-rose-700" disabled={pendingId === `remove:${id}`} onClick={onUninstall}>
<Trash2 className="h-3.5 w-3.5" />
{t("plugins.uninstall")}
</Button>
</div>
</section>
) : null}
{manifest.type === "plugin" ? <PluginSettingsSection host={host} manifest={manifest} /> : null}
{extension.error ? <div className="text-sm text-rose-600">{extension.error}</div> : null}
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4">
{update ? (
<Button size="sm" className="gap-1.5" disabled={pendingId === `update:${id}`} onClick={onUpdate}>
<Download className="h-3.5 w-3.5" />
{t("plugins.updates.update")}
</Button>
) : null}
{commands.map((command) => (
<Button key={command.id} size="sm" variant="outline" className="gap-1.5" disabled={pendingId === `${id}:${command.id}`} onClick={() => onRunCommand(command)}>
<Play className="h-3.5 w-3.5" />
{command.title}
</Button>
))}
{panels.map((panel) => (
<Button key={panel.id} size="sm" variant="outline" className="gap-1.5" onClick={() => onOpenPanel(panel)}>
<PanelRightOpen className="h-3.5 w-3.5" />
{panel.title}
</Button>
))}
<Button size="sm" variant="ghost" className="ml-auto gap-1.5 text-rose-600 hover:bg-rose-50 hover:text-rose-700" disabled={pendingId === `remove:${id}`} onClick={onUninstall}>
<Trash2 className="h-3.5 w-3.5" />
{t("plugins.uninstall")}
</Button>
</div>
</>
)}
</div>
);
};
@@ -474,11 +380,13 @@ export const PluginManagerCard = ({
onClosePlugin,
onOpenPlugin,
selectedPluginId,
requestedPage = null,
}: {
host: EdgeEverPluginHost;
onClosePlugin?: () => void;
onOpenPlugin?: (pluginId: string) => void;
selectedPluginId?: string | null;
requestedPage?: string | null;
}) => {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
@@ -636,6 +544,7 @@ export const PluginManagerCard = ({
{selectedPluginId ? (
selectedExtension ? (
<PluginDetailView
page={getPluginDetailPage(selectedExtension.manifest, requestedPage)}
extension={selectedExtension}
host={host}
update={updateQuery.data?.updates.find((update) => update.pluginId === selectedExtension.manifest.id)}
@@ -816,6 +725,14 @@ export const PluginManagerCard = ({
{extension.error ? <div className="mt-2 text-xs text-rose-600">{extension.error}</div> : null}
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-3">
{hasPluginSettings(extension.manifest) ? (
<Button asChild size="sm" variant="outline" className="h-8 gap-1.5 text-xs">
<Link to={getPluginDetailPath(id, "settings")} aria-label={t("plugins.settings.open", { name: extension.manifest.name })}>
<Settings2 className="h-3.5 w-3.5" />
{t("plugins.settings.title")}
</Link>
</Button>
) : null}
{availableUpdate ? (
<Button
size="sm"
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { getPluginDetailPage, getPluginDetailPath, getPluginToolbarGroups, hasPluginSettings } from "./plugin-navigation.ts";
const plugin = { type: "plugin", id: "example", settings: { fields: [{ key: "token", type: "secret", label: "Token" }] } };
describe("plugin settings navigation", () => {
test("links directly to settings while preserving the existing overview URL", () => {
expect(getPluginDetailPath("example")).toBe("/plugins/example");
expect(getPluginDetailPath("example", "settings")).toBe("/plugins/example?view=settings");
expect(getPluginDetailPath("scope/name?", "settings")).toBe("/plugins/scope%2Fname%3F?view=settings");
});
test("shows settings only for plugins with declared fields", () => {
expect(hasPluginSettings(plugin)).toBe(true);
expect(hasPluginSettings({ type: "plugin" })).toBe(false);
expect(hasPluginSettings({ type: "plugin", settings: { fields: [] } })).toBe(false);
expect(hasPluginSettings({ type: "theme" })).toBe(false);
});
test("disabled plugins retain settings but never expose executable actions", () => {
const groups = getPluginToolbarGroups({
extensions: [{ manifest: plugin, enabled: false }],
commands: [{ pluginId: "example", id: "run" }],
panels: [{ pluginId: "example", id: "panel" }],
});
expect(groups).toHaveLength(1);
expect(groups[0].hasSettings).toBe(true);
expect(groups[0].actions).toEqual([]);
});
test("keeps commands and panels while hiding settings-free inactive plugins and themes", () => {
const groups = getPluginToolbarGroups({
extensions: [
{ manifest: { type: "plugin", id: "actions" }, enabled: true },
{ manifest: plugin, enabled: true },
{ manifest: { type: "plugin", id: "inactive" }, enabled: false },
{ manifest: { type: "theme", id: "theme" }, enabled: true },
],
commands: [{ pluginId: "actions", id: "run" }, { pluginId: "inactive", id: "hidden" }],
panels: [{ pluginId: "actions", id: "panel" }],
});
expect(groups.map((group) => group.pluginId)).toEqual(["actions", "example"]);
expect(groups[0].hasSettings).toBe(false);
expect(groups[0].actions.map((action) => action.type)).toEqual(["command", "panel"]);
expect(groups[1].hasSettings).toBe(true);
expect(groups[1].actions).toEqual([]);
});
test("resolves deep links and safely falls back for unsupported settings pages", () => {
expect(getPluginDetailPage(plugin, "settings")).toBe("settings");
expect(getPluginDetailPage(plugin, null)).toBe("overview");
expect(getPluginDetailPage(plugin, "invalid")).toBe("overview");
expect(getPluginDetailPage({ type: "plugin" }, "settings")).toBe("overview");
expect(getPluginDetailPage({ type: "theme" }, "settings")).toBe("overview");
});
});
@@ -0,0 +1,26 @@
import type { ExtensionManifest } from "@edgeever/plugin-api";
import type { PluginHostSnapshot, RegisteredPluginAction } from "./plugin-host";
export type PluginDetailPage = "overview" | "settings";
export const hasPluginSettings = (manifest: ExtensionManifest) =>
manifest.type === "plugin" && (manifest.settings?.fields.length ?? 0) > 0;
export const getPluginDetailPath = (pluginId: string, page: PluginDetailPage = "overview") =>
`/plugins/${encodeURIComponent(pluginId)}${page === "settings" ? "?view=settings" : ""}`;
export const getPluginDetailPage = (manifest: ExtensionManifest, requestedPage: string | null): PluginDetailPage =>
requestedPage === "settings" && hasPluginSettings(manifest) ? "settings" : "overview";
export const getPluginToolbarGroups = (snapshot: Pick<PluginHostSnapshot, "extensions" | "commands" | "panels">) =>
snapshot.extensions.flatMap((extension) => {
if (extension.manifest.type !== "plugin") return [];
const hasSettings = hasPluginSettings(extension.manifest);
const actions: RegisteredPluginAction[] = extension.enabled ? [
...snapshot.commands.filter((command) => command.pluginId === extension.manifest.id).map((command) => ({ ...command, type: "command" as const })),
...snapshot.panels.filter((panel) => panel.pluginId === extension.manifest.id).map((panel) => ({ ...panel, type: "panel" as const })),
] : [];
return actions.length || hasSettings
? [{ pluginId: extension.manifest.id, name: extension.manifest.name, actions, hasSettings }]
: [];
});
+1 -1
View File
@@ -275,7 +275,7 @@ context.events.on("template.updated", ({ template }) => console.log(template.nam
## Host-rendered settings
Plugins can declare settings that EdgeEver renders consistently in plugin details. Supported field types are `text`, `secret`, `number`, `boolean`, and `select`:
Plugins can declare settings that EdgeEver renders consistently on a dedicated Plugin settings page within plugin details. Installed plugin cards and the plugin toolbar menu link directly to this page. Plugins without settings fields have no settings entry, while disabled plugins remain configurable. Settings are stored on the current device only. Put defaults and credentials in settings, and use plugin commands or functional panels for actual operations; ordinary configuration does not need a separate custom panel. Supported field types are `text`, `secret`, `number`, `boolean`, and `select`:
```json
{
+1 -1
View File
@@ -275,7 +275,7 @@ context.events.on("template.updated", ({ template }) => console.log(template.nam
## 宿主统一渲染的设置
插件可以在 Manifest 中声明设置,由 EdgeEver 在插件详情页统一渲染。目前支持 `text`、`secret`、`number`、`boolean` 和 `select`:
插件可以在 Manifest 中声明设置,由 EdgeEver 在插件详情的独立「插件设置」页面统一渲染。已安装插件卡片和插件工具菜单均可直达该页面;未声明配置项的插件不显示设置入口,停用的插件仍可配置。设置仅保存在当前设备。默认行为和凭据应放在设置中,实际操作使用插件命令或功能面板,无需为普通配置另建面板。目前支持 `text`、`secret`、`number`、`boolean` 和 `select`:
```json
{
+7
View File
@@ -403,6 +403,11 @@ export const enUS = {
uninstall: "Uninstall",
settings: {
title: "Plugin settings",
open: "Open settings for {{name}}",
description: "Settings are stored on this device only and do not sync across devices. You can edit them while the plugin is disabled. Leave configured secrets blank to keep them unchanged.",
loadFailed: "Could not load plugin settings: {{message}}",
retry: "Reload settings",
saveFailed: "Saving did not complete. Check your settings and try again: {{message}}",
saved: "Settings saved on this device.",
required: "{{name}} is required.",
secretConfigured: "Configured — leave blank to keep unchanged",
@@ -410,6 +415,8 @@ export const enUS = {
},
details: {
title: "Plugin details",
overview: "Overview",
navigation: "Plugin detail navigation",
open: "View details for {{name}}",
notFound: "This plugin was not found. It may have been uninstalled.",
type: "Type",
+7
View File
@@ -403,6 +403,11 @@ export const zhCN = {
uninstall: "卸载",
settings: {
title: "插件设置",
open: "打开 {{name}} 的设置",
description: "配置仅保存在当前设备,不会跨设备同步。插件停用时也可以编辑;留空的已配置密钥会保持不变。",
loadFailed: "无法读取插件设置:{{message}}",
retry: "重新读取",
saveFailed: "保存未完成,请检查并重试:{{message}}",
saved: "设置已保存到当前设备。",
required: "{{name}} 为必填项。",
secretConfigured: "已配置,留空表示保持不变",
@@ -410,6 +415,8 @@ export const zhCN = {
},
details: {
title: "插件详情",
overview: "概览",
navigation: "插件详情导航",
open: "查看 {{name}} 详情",
notFound: "未找到这个插件,可能已被卸载。",
type: "类型",