diff --git a/apps/web/src/components/PluginMarketplacePane.tsx b/apps/web/src/components/PluginMarketplacePane.tsx index f34c17c4..28d961c1 100644 --- a/apps/web/src/components/PluginMarketplacePane.tsx +++ b/apps/web/src/components/PluginMarketplacePane.tsx @@ -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 = ({ navigate(`/plugins/${encodeURIComponent(id)}`)} + requestedPage={searchParams.get("view")} + onOpenPlugin={(id) => navigate(getPluginDetailPath(id))} onClosePlugin={() => navigate("/plugins")} /> diff --git a/apps/web/src/components/plugins/PluginSettingsSection.tsx b/apps/web/src/components/plugins/PluginSettingsSection.tsx new file mode 100644 index 00000000..795c9cc9 --- /dev/null +++ b/apps/web/src/components/plugins/PluginSettingsSection.tsx @@ -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>({}); + const [configuredSecrets, setConfiguredSecrets] = useState>({}); + const [loading, setLoading] = useState(fields.length > 0); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(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 ( +
+

{t("plugins.settings.title")}

+

{t("plugins.settings.description")}

+ {loading ?

{t("common.loading")}

: loadError ? ( +
+

{t("plugins.settings.loadFailed", { message: loadError })}

+ +
+ ) : ( +
{ event.preventDefault(); void save(); }}> +
+ {fields.map((field) => { + const value = values[field.key] ?? ""; + const inputId = `${formId}-${field.key}`; + const descriptionId = field.description ? `${inputId}-description` : undefined; + return ( +
+ + {field.type === "boolean" ? ( + { clearFeedback(); setValues((current) => ({ ...current, [field.key]: checked })); }} + /> + ) : field.type === "select" ? ( + + ) : ( + setValues((current) => ({ + ...current, + [field.key]: field.type === "number" && event.target.value !== "" ? Number(event.target.value) : event.target.value, + }))} + /> + )} + {field.description ?

{field.description}

: null} +
+ ); + })} +
+ + {message ? {message} : null} +
+ {error ?

{t("plugins.settings.saveFailed", { message: error })}

: null} +
+
+ )} +
+ ); +}; diff --git a/apps/web/src/components/plugins/PluginToolbarMenu.tsx b/apps/web/src/components/plugins/PluginToolbarMenu.tsx index ff21c8d1..6e6e51ba 100644 --- a/apps/web/src/components/plugins/PluginToolbarMenu.tsx +++ b/apps/web/src/components/plugins/PluginToolbarMenu.tsx @@ -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) => (
{index > 0 ? : null} {group.name} {group.actions.map((action) => renderAction(action, group.pluginId))} + {group.hasSettings ? ( + setOpen(false)}> + + + {t("plugins.settings.title")} + + + ) : null}
)) : (
{t("plugins.toolbar.empty")}
diff --git a/apps/web/src/components/settings/PluginManagerCard.tsx b/apps/web/src/components/settings/PluginManagerCard.tsx index a17200cc..f449cd74 100644 --- a/apps/web/src/components/settings/PluginManagerCard.tsx +++ b/apps/web/src/components/settings/PluginManagerCard.tsx @@ -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>({}); - const [configuredSecrets, setConfiguredSecrets] = useState>({}); - const [loading, setLoading] = useState(fields.length > 0); - const [saving, setSaving] = useState(false); - const [message, setMessage] = useState(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 ( -
-

{t("plugins.settings.title")}

- {loading ?

{t("common.loading")}

: ( -
- {fields.map((field) => { - const value = values[field.key] ?? ""; - return ( - - ); - })} -
- - {message ? {message} : null} -
-
- )} -
- ); -}; 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 = ({ /> -
- {[ - [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]) => ( -
-
{label}
-
{value}
-
- ))} -
- - {extension.source.repositoryUrl ? ( - - - {extension.source.repositoryUrl.replace("https://github.com/", "")} - - + {hasPluginSettings(manifest) ? ( + ) : null} - {manifest.type === "plugin" && manifest.permissions.length > 0 ? ( -
-

{t("plugins.details.permissions")}

-
- {manifest.permissions.map((permission) => ( - {permissionLabel(permission)} + {page === "settings" && manifest.type === "plugin" ? ( + + ) : ( + <> +
+ {[ + [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]) => ( +
+
{label}
+
{value}
+
))} -
-
- ) : null} + - {manifest.type === "plugin" && manifest.networkHosts?.length ? ( -
-

{t("plugins.details.networkHosts")}

-
- {manifest.networkHosts.map((hostname) => ( - {hostname} + {extension.source.repositoryUrl ? ( + + + {extension.source.repositoryUrl.replace("https://github.com/", "")} + + + ) : null} + + {manifest.type === "plugin" && manifest.permissions.length > 0 ? ( +
+

{t("plugins.details.permissions")}

+
+ {manifest.permissions.map((permission) => ( + {permissionLabel(permission)} + ))} +
+
+ ) : null} + + {manifest.type === "plugin" && manifest.networkHosts?.length ? ( +
+

{t("plugins.details.networkHosts")}

+
+ {manifest.networkHosts.map((hostname) => ( + {hostname} + ))} +
+
+ ) : null} + + {extension.error ?
{extension.error}
: null} + +
+ {update ? ( + + ) : null} + {commands.map((command) => ( + ))} + {panels.map((panel) => ( + + ))} +
-
- ) : null} - - {manifest.type === "plugin" ? : null} - - {extension.error ?
{extension.error}
: null} - -
- {update ? ( - - ) : null} - {commands.map((command) => ( - - ))} - {panels.map((panel) => ( - - ))} - -
+ + )} ); }; @@ -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 ? ( update.pluginId === selectedExtension.manifest.id)} @@ -816,6 +725,14 @@ export const PluginManagerCard = ({ {extension.error ?
{extension.error}
: null}
+ {hasPluginSettings(extension.manifest) ? ( + + ) : null} {availableUpdate ? (