feat(plugins): support custom plugin icons

This commit is contained in:
t8y2 committed 2026-09-13 12:49:18 +08:00
1 parent 7b7d21f54f
commit bcd1aafb33
13 files changed
+123 -20

No files matched your search

@@ -11,6 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
@@ -297,7 +298,7 @@ async function openFilesystem(pluginId: string, providerId: string, label: strin
try {
if (connection) await connectionStore.ensureConnected(connection.id);
queryStore.openPluginFilesystem(pluginId, providerId, {
title: connection ? `${connection.name} · ${label}` : label,
title: connection?.name || label,
connectionId: connection?.id,
rootUri,
});
@@ -309,7 +310,7 @@ async function openFilesystem(pluginId: string, providerId: string, label: strin
function openWorkbench(pluginId: string, contributionId: string, label: string) {
const connection = selectedConnection.value;
queryStore.openPluginWorkbench(pluginId, contributionId, {
title: connection ? `${connection.name} · ${label}` : label,
title: connection?.name || label,
connectionId: connection?.id,
context: connection
? {
@@ -343,6 +344,7 @@ async function installPlugin(source: string | File) {
try {
const result = await api.installPluginPackage(source, allowUnsigned.value);
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
clearPluginIconCache();
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
activeSection.value = "installed";
@@ -431,6 +433,7 @@ async function rollbackSelectedPlugin() {
try {
const result = await api.rollbackPlugin(selectedPluginId.value);
toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version }));
clearPluginIconCache();
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
@@ -446,6 +449,7 @@ async function uninstallSelectedPlugin() {
operating.value = true;
try {
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
clearPluginIconCache();
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
selectFirstProvider();
} catch (cause) {
+48 -3
View File
@@ -3,29 +3,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
import PluginIcon from "./PluginIcon.vue";
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
const { readPluginAssetMock } = vi.hoisted(() => ({
const { listPluginsMock, readPluginAssetMock } = vi.hoisted(() => ({
listPluginsMock: vi.fn(),
readPluginAssetMock: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
listPlugins: listPluginsMock,
readPluginAsset: readPluginAssetMock,
}));
const mountedApps: App[] = [];
async function mountIcon(icon?: string) {
async function mountIcon(icon?: string, contributionId?: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(PluginIcon, { pluginId: "example.plugin", icon });
const app = createApp(PluginIcon, { pluginId: "example.plugin", icon, contributionId });
app.mount(container);
mountedApps.push(app);
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
return container;
}
beforeEach(() => {
clearPluginIconCache();
listPluginsMock.mockResolvedValue([]);
readPluginAssetMock.mockReset();
vi.stubGlobal("URL", {
...URL,
@@ -72,4 +81,40 @@ describe("PluginIcon", () => {
expect(readPluginAssetMock).not.toHaveBeenCalled();
expect(container.querySelector("img")?.getAttribute("src")).toBe("https://plugins.example.com/icon.svg");
});
it("resolves a contribution icon when the caller does not provide a path", async () => {
listPluginsMock.mockResolvedValue([
{
manifest: {
id: "example.plugin",
icon: "assets/plugin.svg",
contributions: [{ type: "workbench", id: "example.workbench", label: "Example", icon: "assets/workbench.svg" }],
},
},
]);
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
const container = await mountIcon(undefined, "example.workbench");
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/workbench.svg");
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
});
it("resolves a filesystem provider icon", async () => {
listPluginsMock.mockResolvedValue([
{
manifest: {
id: "example.plugin",
icon: "assets/plugin.svg",
contributions: [{ type: "filesystem-provider", id: "example.files", label: "Example files", schemes: ["example"], icon: "assets/files.svg" }],
},
},
]);
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
const container = await mountIcon(undefined, "example.files");
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/files.svg");
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
});
});
@@ -2,10 +2,12 @@
import { onBeforeUnmount, ref, watch } from "vue";
import { PlugZap } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { resolvePluginIcon } from "@/lib/plugins/pluginIconResolver";
const props = defineProps<{
pluginId: string;
icon?: string;
contributionId?: string;
}>();
const objectUrl = ref("");
@@ -26,21 +28,26 @@ function showFallback() {
}
watch(
() => [props.pluginId, props.icon] as const,
async ([pluginId, icon]) => {
() => [props.pluginId, props.icon, props.contributionId] as const,
async ([pluginId, icon, contributionId]) => {
const sequence = ++requestSequence;
clearObjectUrl();
if (!pluginId || !icon) {
if (!pluginId) {
failed.value = true;
return;
}
failed.value = false;
if (/^https?:\/\//i.test(icon)) {
objectUrl.value = icon;
const resolvedIcon = icon || (await resolvePluginIcon(pluginId, contributionId).catch(() => undefined));
if (sequence !== requestSequence || !resolvedIcon) {
if (sequence === requestSequence) failed.value = true;
return;
}
if (/^https?:\/\//i.test(resolvedIcon)) {
objectUrl.value = resolvedIcon;
return;
}
try {
const asset = await api.readPluginAsset(pluginId, icon);
const asset = await api.readPluginAsset(pluginId, resolvedIcon);
if (!asset.contentType.startsWith("image/")) throw new Error("Plugin icon is not an image");
const binary = atob(asset.dataBase64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));