refactor: simplify plugin toolbar menu
This commit is contained in:
1 parent
6036bb7162
commit
29ec6fe766
7 files changed
+10
-102
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Clock3, LoaderCircle, PanelRightOpen, Play, Puzzle, Settings2 } from "lucide-react";
|
||||
import { 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,
|
||||
@@ -14,7 +13,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 { getPluginToolbarGroups } from "@/lib/plugins/plugin-navigation";
|
||||
import type {
|
||||
EdgeEverPluginHost,
|
||||
RegisteredPluginAction,
|
||||
@@ -40,7 +39,6 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
|
||||
|
||||
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
|
||||
));
|
||||
@@ -74,7 +72,7 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
|
||||
}
|
||||
};
|
||||
|
||||
const renderAction = (action: RegisteredPluginAction, prefix: string, pluginName?: string) => {
|
||||
const renderAction = (action: RegisteredPluginAction, prefix: string) => {
|
||||
const key = actionKey(action);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -94,7 +92,6 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
|
||||
<Play className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{action.title}</span>
|
||||
{pluginName ? <span className="max-w-24 truncate text-[10px] text-slate-400">{pluginName}</span> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -121,30 +118,11 @@ export const PluginToolbarMenu = ({ host, onManage, align = "end", className }:
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenuContent align={align} className="w-72">
|
||||
{snapshot.recentActions.length ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{t("plugins.toolbar.recent")}
|
||||
</DropdownMenuLabel>
|
||||
{snapshot.recentActions.map((action) => renderAction(action, "recent", pluginNames.get(action.pluginId)))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
|
||||
@@ -227,7 +227,6 @@ describe("EdgeEverPluginHost", () => {
|
||||
expect(host.getSnapshot().commands).toHaveLength(7);
|
||||
expect(host.getSnapshot().panels).toEqual([{ pluginId: "org.edgeever.test-plugin", id: "fixture", title: "Fixture panel", presentation: "dialog" }]);
|
||||
expect(notices).toEqual(["hello from plugin"]);
|
||||
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "hello", type: "command" });
|
||||
await host.runCommand("org.edgeever.test-plugin", "read-without-permission");
|
||||
await host.runCommand("org.edgeever.test-plugin", "subscribe-without-read-permission");
|
||||
await host.runCommand("org.edgeever.test-plugin", "replace-selection");
|
||||
@@ -245,7 +244,6 @@ describe("EdgeEverPluginHost", () => {
|
||||
const container = {};
|
||||
const disposePanel = await host.mountPanel("org.edgeever.test-plugin", "fixture", container);
|
||||
expect(container.mountedByFixture).toBe(true);
|
||||
expect(host.getSnapshot().recentActions[0]).toMatchObject({ id: "fixture", type: "panel" });
|
||||
disposePanel();
|
||||
expect(container.mountedByFixture).toBe(false);
|
||||
const mountedDuringDisable = {};
|
||||
@@ -253,7 +251,6 @@ describe("EdgeEverPluginHost", () => {
|
||||
await host.setEnabled("org.edgeever.test-plugin", false);
|
||||
expect(mountedDuringDisable.mountedByFixture).toBe(false);
|
||||
expect(host.getSnapshot().panels).toHaveLength(0);
|
||||
expect(host.getSnapshot().recentActions).toHaveLength(0);
|
||||
await host.uninstall("org.edgeever.test-plugin");
|
||||
expect(secrets.has("test:org.edgeever.test-plugin:token")).toBe(false);
|
||||
expect(secrets.has("test:org.edgeever.test-plugin:setting:token")).toBe(false);
|
||||
|
||||
@@ -43,7 +43,6 @@ const INSTALLED_EXTENSIONS_STORAGE_KEY = "edgeever.extensions.installed.v1";
|
||||
const ACTIVE_THEME_STORAGE_KEY = "edgeever.extensions.active-theme.v1";
|
||||
const STORAGE_PREFIX = "edgeever.plugin-data.v1";
|
||||
const SETTINGS_STORAGE_PREFIX = "edgeever.plugin-settings.v1";
|
||||
const RECENT_ACTIONS_STORAGE_PREFIX = "edgeever.extensions.recent-actions.v1";
|
||||
|
||||
const readStorageItem = (key: string) => {
|
||||
try {
|
||||
@@ -172,7 +171,6 @@ export interface PluginHostSnapshot {
|
||||
commands: RegisteredPluginCommand[];
|
||||
panels: RegisteredPluginPanel[];
|
||||
embeds: RegisteredPluginEmbed[];
|
||||
recentActions: RegisteredPluginAction[];
|
||||
activeThemeId: string | null;
|
||||
}
|
||||
|
||||
@@ -425,8 +423,7 @@ export class EdgeEverPluginHost {
|
||||
private readonly eventListeners = new Map<keyof PluginEventMap, Set<{ pluginId: string; listener: (payload: never) => void }>>();
|
||||
private extensions = readInstalledExtensions();
|
||||
private activeThemeId = readStorageItem(ACTIVE_THEME_STORAGE_KEY);
|
||||
private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], embeds: [], recentActions: [], activeThemeId: null };
|
||||
private recentActions: RegisteredPluginAction[];
|
||||
private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], embeds: [], activeThemeId: null };
|
||||
private editorAdapter: PluginEditorAdapter | null = null;
|
||||
private navigationAdapter: PluginNavigationAdapter | null = null;
|
||||
private panelAdapter: PluginPanelAdapter | null = null;
|
||||
@@ -452,7 +449,6 @@ export class EdgeEverPluginHost {
|
||||
this.secretStorage = options.secretStorage ?? new WebPluginSecretStore();
|
||||
this.packageStorage = options.packageStorage ?? new WebPluginPackageStore();
|
||||
this.scheduleAdapter = options.scheduleAdapter;
|
||||
this.recentActions = this.readRecentActions();
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
@@ -647,8 +643,6 @@ export class EdgeEverPluginHost {
|
||||
const extension = this.requireExtension(extensionId);
|
||||
if (extension.manifest.type === "plugin") await this.deactivatePlugin(extensionId);
|
||||
this.extensions = this.extensions.filter((item) => item.manifest.id !== extensionId);
|
||||
this.recentActions = this.recentActions.filter((action) => action.pluginId !== extensionId);
|
||||
this.persistRecentActions();
|
||||
if (this.activeThemeId === extensionId) {
|
||||
this.activeThemeId = null;
|
||||
removeStorageItem(ACTIVE_THEME_STORAGE_KEY);
|
||||
@@ -715,7 +709,6 @@ export class EdgeEverPluginHost {
|
||||
const command = this.commands.get(`${pluginId}:${commandId}`);
|
||||
if (!command) throw new Error("Plugin command is not registered.");
|
||||
await command.run();
|
||||
this.recordRecentAction({ pluginId, id: commandId, title: command.title, type: "command" });
|
||||
}
|
||||
|
||||
async mountPanel(
|
||||
@@ -749,7 +742,6 @@ export class EdgeEverPluginHost {
|
||||
if (typeof pluginDispose === "function") pluginDispose();
|
||||
};
|
||||
mounted.add(dispose);
|
||||
this.recordRecentAction({ pluginId, id: panelId, title: panel.title, type: "panel" });
|
||||
return dispose;
|
||||
}
|
||||
|
||||
@@ -1517,54 +1509,7 @@ export class EdgeEverPluginHost {
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
private readRecentActions(): RegisteredPluginAction[] {
|
||||
try {
|
||||
const parsed = JSON.parse(readStorageItem(`${RECENT_ACTIONS_STORAGE_PREFIX}:${this.scope}`) ?? "[]") as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const action = item as Partial<RegisteredPluginAction>;
|
||||
if (
|
||||
typeof action.pluginId !== "string" ||
|
||||
typeof action.id !== "string" ||
|
||||
typeof action.title !== "string" ||
|
||||
(action.type !== "command" && action.type !== "panel")
|
||||
) return [];
|
||||
return [action as RegisteredPluginAction];
|
||||
}).slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private recordRecentAction(action: RegisteredPluginAction) {
|
||||
this.recentActions = [
|
||||
action,
|
||||
...this.recentActions.filter((item) => item.pluginId !== action.pluginId || item.id !== action.id || item.type !== action.type),
|
||||
].slice(0, 5);
|
||||
this.persistRecentActions();
|
||||
this.refreshSnapshot();
|
||||
}
|
||||
|
||||
private persistRecentActions() {
|
||||
try {
|
||||
writeStorageItem(`${RECENT_ACTIONS_STORAGE_PREFIX}:${this.scope}`, JSON.stringify(this.recentActions));
|
||||
} catch {
|
||||
// Recent actions are a convenience and must never make a successful plugin action fail.
|
||||
}
|
||||
}
|
||||
|
||||
private refreshSnapshot() {
|
||||
const registeredActions = new Map<string, RegisteredPluginAction>([
|
||||
...[...this.commands.values()].map(({ pluginId, id, title }) => [
|
||||
`command:${pluginId}:${id}`,
|
||||
{ pluginId, id, title, type: "command" as const },
|
||||
] as const),
|
||||
...[...this.panels.values()].map(({ pluginId, id, title }) => [
|
||||
`panel:${pluginId}:${id}`,
|
||||
{ pluginId, id, title, type: "panel" as const },
|
||||
] as const),
|
||||
]);
|
||||
this.snapshot = {
|
||||
extensions: this.extensions.map((item) => ({ ...item, manifest: { ...item.manifest } })),
|
||||
commands: [...this.commands.values()].map(({ pluginId, id, title }) => ({ pluginId, id, title })),
|
||||
@@ -1575,10 +1520,6 @@ export class EdgeEverPluginHost {
|
||||
presentation: presentation === "fullscreen" ? "fullscreen" : "dialog",
|
||||
})),
|
||||
embeds: [...this.embeds.values()].map(({ pluginId, type }) => ({ pluginId, type })),
|
||||
recentActions: this.recentActions.flatMap((action) => {
|
||||
const registered = registeredActions.get(`${action.type}:${action.pluginId}:${action.id}`);
|
||||
return registered ? [registered] : [];
|
||||
}),
|
||||
activeThemeId: this.activeThemeId,
|
||||
};
|
||||
for (const listener of this.listeners) listener();
|
||||
|
||||
@@ -17,18 +17,16 @@ describe("plugin settings navigation", () => {
|
||||
expect(hasPluginSettings({ type: "theme" })).toBe(false);
|
||||
});
|
||||
|
||||
test("disabled plugins retain settings but never expose executable actions", () => {
|
||||
test("does not expose disabled plugins through the command menu", () => {
|
||||
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([]);
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps commands and panels while hiding settings-free inactive plugins and themes", () => {
|
||||
test("keeps commands and panels while hiding settings-only plugins, inactive plugins, and themes", () => {
|
||||
const groups = getPluginToolbarGroups({
|
||||
extensions: [
|
||||
{ manifest: { type: "plugin", id: "actions" }, enabled: true },
|
||||
@@ -39,11 +37,8 @@ describe("plugin settings navigation", () => {
|
||||
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.map((group) => group.pluginId)).toEqual(["actions"]);
|
||||
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", () => {
|
||||
|
||||
@@ -15,12 +15,11 @@ export const getPluginDetailPage = (manifest: ExtensionManifest, requestedPage:
|
||||
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 }]
|
||||
return actions.length
|
||||
? [{ pluginId: extension.manifest.id, name: extension.manifest.name, actions }]
|
||||
: [];
|
||||
});
|
||||
@@ -759,7 +759,6 @@ export const enUS = {
|
||||
},
|
||||
toolbar: {
|
||||
open: "Plugins",
|
||||
recent: "Recently used",
|
||||
empty: "Enabled plugins have not provided any commands or panels yet.",
|
||||
manage: "Manage plugins and themes",
|
||||
},
|
||||
|
||||
@@ -759,7 +759,6 @@ export const zhCN = {
|
||||
},
|
||||
toolbar: {
|
||||
open: "插件",
|
||||
recent: "最近使用",
|
||||
empty: "已启用的插件还没有提供命令或面板。",
|
||||
manage: "管理插件与主题",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user