feat(plugins): support custom plugin icons
This commit is contained in:
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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user