feat(plugins): support custom plugin icons
This commit is contained in:
13 files changed
+123
-20
No files matched your search
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, AlertTriangle, Braces, CalendarClock, Clock, Code2, Database, Eye, FileCode, Gauge, KeyRound, Link2, ListTree, Network, Package, PencilRuler, ScrollText, ShieldCheck, Table, TableProperties, UsersRound, Zap } from "@lucide/vue";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { isEventObjectBrowserTab, tabDatabaseIconType } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
@@ -38,5 +39,7 @@ defineProps<{ tab: QueryTab }>();
|
||||
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" />
|
||||
<Activity v-else-if="tab.mode === 'processlist' || tab.mode === 'sqlserver-trace'" />
|
||||
<Gauge v-else-if="tab.mode === 'dolt-version-control'" />
|
||||
<PluginIcon v-else-if="tab.mode === 'plugin-workbench' && tab.pluginWorkbench" :plugin-id="tab.pluginWorkbench.pluginId" :contribution-id="tab.pluginWorkbench.contributionId" />
|
||||
<PluginIcon v-else-if="tab.mode === 'plugin-filesystem' && tab.pluginFilesystem" :plugin-id="tab.pluginFilesystem.pluginId" :contribution-id="tab.pluginFilesystem.providerId" />
|
||||
<Code2 v-else />
|
||||
</template>
|
||||
@@ -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));
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import ReadOnlySessionControl from "@/components/connection/ReadOnlySessionControl.vue";
|
||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||
@@ -965,6 +966,13 @@ function connectionIconType(connectionId?: string) {
|
||||
return config?.driver_profile || config?.db_type || "postgres";
|
||||
}
|
||||
|
||||
const pluginConnectionIcon = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return undefined;
|
||||
const config = connectionStore.getConfig(activeNode.value.connectionId);
|
||||
if (config?.db_type !== "plugin" || !config.plugin_id) return undefined;
|
||||
return { pluginId: config.plugin_id, contributionId: config.plugin_connection_provider };
|
||||
});
|
||||
|
||||
const connectionColor = computed(() => {
|
||||
const connectionId = activeNode.value.connectionId;
|
||||
return connectionId ? connectionStore.getConfig(connectionId)?.color || "" : "";
|
||||
@@ -1570,7 +1578,8 @@ function onKeydown(event: KeyboardEvent) {
|
||||
</template>
|
||||
<span v-else class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="relative flex h-3.5 w-3.5 shrink-0" :class="{ 'overflow-visible': node.valid === false }">
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<PluginIcon v-if="node.type === 'connection' && pluginConnectionIcon" :plugin-id="pluginConnectionIcon.pluginId" :contribution-id="pluginConnectionIcon.contributionId" class="h-3.5 w-3.5 shrink-0" />
|
||||
<DatabaseIcon v-else-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="h-3.5 w-3.5 shrink-0" :class="databaseOpenVisual.iconClass" />
|
||||
<CircleX v-if="node.valid === false" data-invalid-object-indicator="true" class="pointer-events-none absolute -right-1 -bottom-1 h-2.5 w-2.5 rounded-full bg-background text-destructive stroke-[3]" aria-hidden="true" />
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||
import TabModeIcon from "@/components/layout/TabModeIcon.vue";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import { connectionColor, connectionDisplayName, tabDisplayTitle, tabModeLabel } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -22,7 +20,6 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const listRef = ref<HTMLElement>();
|
||||
|
||||
function tabColor(tab: QueryTab): string {
|
||||
@@ -65,7 +62,7 @@ watch(
|
||||
@mouseenter="emit('update:selectedIndex', index)"
|
||||
@click="emit('select', tab.id)"
|
||||
>
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionStore.getConfig(tab.connectionId))" class="h-4 w-4 shrink-0" />
|
||||
<TabModeIcon :tab="tab" class="h-4 w-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ tabDisplayTitle(tab, t) }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
let installedPluginsPromise: Promise<InstalledPlugin[]> | null = null;
|
||||
|
||||
function installedPlugins(): Promise<InstalledPlugin[]> {
|
||||
installedPluginsPromise ||= api.listPlugins().catch((error) => {
|
||||
installedPluginsPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return installedPluginsPromise;
|
||||
}
|
||||
|
||||
export function clearPluginIconCache() {
|
||||
installedPluginsPromise = null;
|
||||
}
|
||||
|
||||
export async function resolvePluginIcon(pluginId: string, contributionId?: string): Promise<string | undefined> {
|
||||
const plugin = (await installedPlugins()).find((entry) => entry.manifest.id === pluginId);
|
||||
if (!plugin) return undefined;
|
||||
const contribution = contributionId ? plugin.manifest.contributions?.find((entry) => entry.id === contributionId) : undefined;
|
||||
return (contribution && "icon" in contribution ? contribution.icon : undefined) || plugin.manifest.icon;
|
||||
}
|
||||
@@ -3222,7 +3222,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
if (workbench) {
|
||||
return openPluginWorkbench(pluginId, workbench.contribution.id, {
|
||||
title: `${connection.name} · ${workbench.contribution.label}`,
|
||||
title: connection.name,
|
||||
connectionId,
|
||||
context: {
|
||||
connectionId,
|
||||
@@ -3235,7 +3235,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
|
||||
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
|
||||
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
|
||||
title: `${connection.name} · ${filesystem.contribution.label}`,
|
||||
title: connection.name,
|
||||
connectionId,
|
||||
rootUri: filesystem.contribution.root_uri,
|
||||
});
|
||||
|
||||
@@ -342,6 +342,7 @@ export interface PluginFilesystemProviderContribution {
|
||||
label: string;
|
||||
schemes: string[];
|
||||
description?: string;
|
||||
icon?: string;
|
||||
root_uri?: string;
|
||||
capabilities?: Array<"read" | "write" | "delete" | "rename" | "mkdir">;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user