feat(plugin): add plugin framework workflow
This commit is contained in:
247 files changed
+33841
-2256
No files matched your search
@@ -34,7 +34,7 @@ import {
|
||||
type DriverInstallProgress,
|
||||
} from "@/lib/connection/driverInstallProgressUi";
|
||||
import { installRegisteredManagedJdbcDriver, isManagedJdbcDriver, managedJdbcDriverRows, uninstallRegisteredManagedJdbcDriver } from "@/lib/database/managedJdbcDrivers";
|
||||
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
|
||||
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { runAgentOfflineExportAction } from "@/lib/driverStore/agentOfflineExportFlow";
|
||||
@@ -55,7 +55,7 @@ function backendError(e: unknown): string {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
updateNotificationsEnabled?: boolean;
|
||||
activeTab?: "agent" | "jdbc" | "storage" | "runtime";
|
||||
activeTab?: DriverStoreTab;
|
||||
focusTarget?: DriverStoreFocus | null;
|
||||
}>(),
|
||||
{
|
||||
@@ -67,12 +67,12 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update-count-change": [count: number];
|
||||
"update:activeTab": [tab: "agent" | "jdbc" | "storage" | "runtime"];
|
||||
"update:activeTab": [tab: DriverStoreTab];
|
||||
}>();
|
||||
|
||||
const driverStoreTab = computed({
|
||||
get: () => props.activeTab,
|
||||
set: (tab: "agent" | "jdbc" | "storage" | "runtime") => emit("update:activeTab", tab),
|
||||
set: (tab: DriverStoreTab) => emit("update:activeTab", tab),
|
||||
});
|
||||
|
||||
// ──────────── Driver store path ────────────
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,10 +32,12 @@ import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
|
||||
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
|
||||
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
|
||||
const props = defineProps<{
|
||||
showConnectionDialog: boolean;
|
||||
connectionPrefill?: ConnectionDeepLinkDraft | null;
|
||||
connectionPluginProvider?: PluginCenterFocus | null;
|
||||
connectionInitialTab?: ConfigTab;
|
||||
showDangerDialog: boolean;
|
||||
dangerSql: string;
|
||||
@@ -140,7 +142,8 @@ const editConfig = computed(() => {
|
||||
const shouldShowConnectionDialog = computed(() => props.showConnectionDialog || !!editConfig.value);
|
||||
|
||||
watch(editConfig, (v) => {
|
||||
if (v) emit("update:showConnectionDialog", true);
|
||||
if (!v) return;
|
||||
emit("update:showConnectionDialog", true);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -167,6 +170,7 @@ watch(
|
||||
:open="shouldShowConnectionDialog"
|
||||
:edit-config="editConfig"
|
||||
:prefill-config="connectionPrefill"
|
||||
:plugin-provider="connectionPluginProvider"
|
||||
:initial-tab="connectionInitialTab"
|
||||
@update:open="emit('update:showConnectionDialog', $event)"
|
||||
@connect-started="emit('connectStarted', $event)"
|
||||
|
||||
@@ -14,6 +14,8 @@ import "./appTabBar.css";
|
||||
const props = defineProps<{
|
||||
driverStoreOpen?: boolean;
|
||||
driverStoreActive?: boolean;
|
||||
pluginCenterOpen?: boolean;
|
||||
pluginCenterActive?: boolean;
|
||||
settingsPageOpen?: boolean;
|
||||
settingsPageActive?: boolean;
|
||||
agentDriverUpdateCount?: number;
|
||||
@@ -26,6 +28,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"activate-driver-store": [];
|
||||
"close-driver-store": [];
|
||||
"activate-plugin-center": [];
|
||||
"close-plugin-center": [];
|
||||
"activate-settings-page": [];
|
||||
"close-settings-page": [];
|
||||
"activate-tab": [tabId: string];
|
||||
@@ -121,7 +125,7 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
type SpecialRegularSurface = "driverStore" | "settings";
|
||||
type SpecialRegularSurface = "driverStore" | "pluginCenter" | "settings";
|
||||
|
||||
function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
|
||||
if (keep !== "driverStore" && props.driverStoreOpen) {
|
||||
@@ -130,6 +134,9 @@ function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
|
||||
if (keep !== "settings" && props.settingsPageOpen) {
|
||||
emit("close-settings-page");
|
||||
}
|
||||
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
|
||||
emit("close-plugin-center");
|
||||
}
|
||||
}
|
||||
|
||||
// Regular tabs live in editor groups now, so "close other tabs" at App level
|
||||
@@ -143,6 +150,10 @@ function closeOtherActiveTabs() {
|
||||
closeSpecialRegularSurfaces("driverStore");
|
||||
return;
|
||||
}
|
||||
if (props.pluginCenterActive) {
|
||||
closeSpecialRegularSurfaces("pluginCenter");
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTabId = queryStore.activeTabId;
|
||||
if (!activeTabId) {
|
||||
@@ -188,9 +199,12 @@ function handleCancelClose() {
|
||||
<template>
|
||||
<!-- Targets remain mounted while inactive so the original group bars can
|
||||
move here without losing their local presentation state. -->
|
||||
<div v-show="driverStoreActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
|
||||
<div v-show="driverStoreActive || pluginCenterActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
|
||||
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
|
||||
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
|
||||
<button v-if="pluginCenterOpen" type="button" data-plugin-center-tab class="shrink-0 rounded-md px-2 py-1 text-left text-sm" :class="pluginCenterActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" @click="emit('activate-plugin-center')">
|
||||
{{ t("toolbar.pluginCenter") }}
|
||||
</button>
|
||||
</div>
|
||||
<div data-special-page-content class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<slot />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, FileDown, FolderTree } from "@lucide/vue";
|
||||
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
@@ -38,6 +38,7 @@ const props = defineProps<{
|
||||
sqlLibrarySaveFeedbackId: number;
|
||||
showSqlFilePanel: boolean;
|
||||
showDriverStore: boolean;
|
||||
showPluginCenter: boolean;
|
||||
showSettingsPage: boolean;
|
||||
checkingUpdates: boolean;
|
||||
updateVersion?: string;
|
||||
@@ -64,6 +65,7 @@ const emit = defineEmits<{
|
||||
"open-github": [];
|
||||
"open-settings": [];
|
||||
"open-driver-store": [];
|
||||
"open-plugin-center": [];
|
||||
"check-updates": [];
|
||||
"open-transfer": [];
|
||||
"open-sql-file": [];
|
||||
@@ -438,6 +440,9 @@ const moreItems = computed(() => {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (!toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
|
||||
// "More" menu items (individually toggleable)
|
||||
if (toolbarItems.value.sqlFile) {
|
||||
@@ -504,6 +509,9 @@ const collapsedItems = computed(() => {
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
if (toolbarItems.value.pluginCenter) {
|
||||
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
|
||||
}
|
||||
// Always include moreItems (may contain hidden left-side items + overflowed right items)
|
||||
if (moreItems.value.length > 0) {
|
||||
items.push(...moreItems.value);
|
||||
@@ -571,6 +579,10 @@ const toolbarStyle = computed(() => {
|
||||
<!-- 小圆点仅提示"有可更新驱动",具体数量交给对话框内标签页红点展示,避免工具栏长期挂红数字。 -->
|
||||
<span v-if="agentDriverUpdateCount > 0" class="ml-0.5 inline-block h-2 w-2 rounded-full bg-red-500" :aria-label="t('toolbar.updatableDriverCount')" :title="t('toolbar.updatableDriverCount')" />
|
||||
</Button>
|
||||
<Button v-if="toolbarItems.pluginCenter" variant="ghost" size="sm" :class="[toolbarTextButtonClass, { 'bg-accent': showPluginCenter }]" @click="emit('open-plugin-center')">
|
||||
<PlugZap class="h-3.5 w-3.5" />
|
||||
<span :class="toolbarTextLabelClass">{{ t("toolbar.pluginCenter") }}</span>
|
||||
</Button>
|
||||
|
||||
<LightDropdown
|
||||
v-if="showMoreDropdown"
|
||||
|
||||
@@ -123,6 +123,8 @@ const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admi
|
||||
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||||
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
|
||||
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
|
||||
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
|
||||
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -981,6 +983,25 @@ function onRefreshActiveKvBrowser(event: Event) {
|
||||
void nextTick(() => refreshData());
|
||||
}
|
||||
|
||||
function openPluginResultView(pluginId: string, contributionId: string, label: string) {
|
||||
const result = props.activeTab.result;
|
||||
if (!result) return;
|
||||
// Plugin workbenches receive a bounded snapshot; plugins re-query through
|
||||
// their backend when they need the full or streamed result set.
|
||||
const cappedRows = result.rows.slice(0, 500);
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: label,
|
||||
connectionId: props.activeTab.connectionId || "",
|
||||
database: props.activeTab.database || "",
|
||||
context: {
|
||||
connectionId: props.activeTab.connectionId || "",
|
||||
database: props.activeTab.database || "",
|
||||
sql: props.activeTab.sql,
|
||||
result: { columns: result.columns, rows: cappedRows, truncated: result.rows.length > cappedRows.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function exportResultArchive() {
|
||||
if (resultArchiveExporting.value) return;
|
||||
resultArchiveExporting.value = true;
|
||||
@@ -1712,9 +1733,11 @@ defineExpose({
|
||||
:can-export-archive="canExportResultArchive"
|
||||
:archive-exporting="resultArchiveExporting"
|
||||
:compact="standaloneResultToolbarCompact"
|
||||
:has-result="!!activeTab.result"
|
||||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||||
@export-archive="exportResultArchive"
|
||||
@open-result-view="openPluginResultView"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1953,9 +1976,11 @@ defineExpose({
|
||||
:can-export-archive="canExportResultArchive"
|
||||
:archive-exporting="resultArchiveExporting"
|
||||
:compact="compact"
|
||||
:has-result="!!activeTab.result"
|
||||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||||
@export-archive="exportResultArchive"
|
||||
@open-result-view="openPluginResultView"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
|
||||
@@ -2501,6 +2526,23 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
|
||||
<div class="flex-1 min-h-0">
|
||||
<PluginWorkbenchTab :key="activeTab.id" :plugin-id="activeTab.pluginWorkbench.pluginId" :contribution-id="activeTab.pluginWorkbench.contributionId" :context="activeTab.pluginWorkbench.context" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<PluginFilesystemTab
|
||||
:key="activeTab.id"
|
||||
:plugin-id="activeTab.pluginFilesystem.pluginId"
|
||||
:provider-id="activeTab.pluginFilesystem.providerId"
|
||||
:connection-id="activeTab.connectionId || undefined"
|
||||
:root-uri="activeTab.pluginFilesystem.rootUri"
|
||||
:initial-uri="activeTab.pluginFilesystem.currentUri"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
|
||||
<div class="min-w-0 flex-1 min-h-0">
|
||||
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { GitBranch, Gauge, Loader2, Upload } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { GitBranch, Gauge, Loader2, PlugZap, Upload } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry, type PluginContributionEntry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin, PluginResultViewContribution } from "@/types/database";
|
||||
|
||||
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activeView: OutputView;
|
||||
canShowExplain: boolean;
|
||||
@@ -14,17 +18,30 @@ withDefaults(
|
||||
canExportArchive: boolean;
|
||||
archiveExporting: boolean;
|
||||
compact?: boolean;
|
||||
hasResult?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
{ compact: false, hasResult: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectExplain: [];
|
||||
selectProfile: [];
|
||||
exportArchive: [];
|
||||
openResultView: [pluginId: string, contributionId: string, label: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const resultViews = computed<PluginContributionEntry<PluginResultViewContribution>[]>(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listResultViews());
|
||||
const visibleResultViews = computed(() => (props.hasResult ? resultViews.value.slice(0, 4) : []));
|
||||
|
||||
void api.listPlugins().then(
|
||||
(plugins) => {
|
||||
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,5 +96,19 @@ const { t } = useI18n();
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.exportResultArchive") }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip v-for="view in visibleResultViews" :key="view.plugin.manifest.id + ':' + view.contribution.id" :text="t('pluginPlatform.openResultView', { label: view.contribution.label })" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 shrink-0 text-xs leading-none text-muted-foreground hover:text-foreground"
|
||||
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
|
||||
:title="t('pluginPlatform.openResultView', { label: view.contribution.label })"
|
||||
:aria-label="t('pluginPlatform.openResultView', { label: view.contribution.label })"
|
||||
@click="emit('openResultView', view.plugin.manifest.id, view.contribution.id, view.contribution.label)"
|
||||
>
|
||||
<PlugZap class="block h-3.5 w-3.5 self-center" />
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ view.contribution.label }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@ describe("AppTabBar shared group navigation", () => {
|
||||
});
|
||||
|
||||
it("retains targets while hiding inactive special content and follows all placements", () => {
|
||||
expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
|
||||
expect(tabBarSource).toContain('v-show="driverStoreActive || pluginCenterActive || settingsPageActive"');
|
||||
expect(tabBarSource).toContain("data-special-page-navigation");
|
||||
expect(tabBarSource).toContain("data-special-page-content");
|
||||
expect(tabBarSource).toContain("<slot />");
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "ut
|
||||
// assertions pin the sibling order so a future merge cannot re-introduce it.
|
||||
describe("App main content surface structure", () => {
|
||||
it("hides only the editor workspace via the surface guard, never the special pages", () => {
|
||||
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
|
||||
const guard = 'v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive"';
|
||||
// The empty-state slot shares the query surface's visibility guard.
|
||||
expect(appSource.split(guard).length - 1).toBe(1);
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
const contribution: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "example.connection",
|
||||
label: "Example connection",
|
||||
database_type: "example",
|
||||
fields: [
|
||||
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
|
||||
{ key: "password", label: "Password", type: "password" },
|
||||
{
|
||||
key: "protocol",
|
||||
label: "Protocol",
|
||||
type: "radio",
|
||||
default: "https",
|
||||
options: [
|
||||
{ label: "HTTPS", value: "https" },
|
||||
{ label: "HTTP", value: "http" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function mountFields(initialValues: Record<string, PluginFormFieldValue> = {}, hiddenBindings: PluginFormFieldBinding[] = [], layout: "stacked" | "connection-dialog" = "stacked") {
|
||||
const state = reactive({ values: initialValues });
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h(PluginConnectionFields, {
|
||||
contribution,
|
||||
modelValue: state.values,
|
||||
hiddenBindings,
|
||||
layout,
|
||||
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
|
||||
state.values = value;
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
return state;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("PluginConnectionFields", () => {
|
||||
it("renders declared fields and emits immutable model updates", async () => {
|
||||
const state = await mountFields({ password: "secret" });
|
||||
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
|
||||
const passwordInput = document.querySelector<HTMLInputElement>("#example-connection-password");
|
||||
|
||||
expect(hostInput?.placeholder).toBe("localhost");
|
||||
expect(passwordInput?.value).toBe("secret");
|
||||
|
||||
if (!hostInput) throw new Error("host input not mounted");
|
||||
hostInput.value = "db.internal";
|
||||
hostInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
expect(state.values).toEqual({ password: "secret", host: "db.internal" });
|
||||
});
|
||||
|
||||
it("can hide host-owned common bindings", async () => {
|
||||
contribution.fields[0].binding = "host";
|
||||
await mountFields({}, ["host"]);
|
||||
expect(document.querySelector("#example-connection-host")).toBeNull();
|
||||
expect(document.querySelector("#example-connection-password")).not.toBeNull();
|
||||
contribution.fields[0].binding = undefined;
|
||||
});
|
||||
|
||||
it("uses the native four-column connection layout without a nested card", async () => {
|
||||
await mountFields({}, [], "connection-dialog");
|
||||
|
||||
const root = document.querySelector(".contents");
|
||||
const hostInput = document.querySelector("#example-connection-host");
|
||||
const fieldRow = hostInput?.closest(".grid");
|
||||
|
||||
expect(root).not.toBeNull();
|
||||
expect(root?.textContent).not.toContain("Example connection");
|
||||
expect(fieldRow?.classList.contains("grid-cols-4")).toBe(true);
|
||||
expect(document.querySelector(".rounded-lg.border")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders radio fields and emits the selected option", async () => {
|
||||
const state = await mountFields();
|
||||
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
|
||||
|
||||
expect(radios).toHaveLength(2);
|
||||
expect(radios[0]?.checked).toBe(true);
|
||||
radios[1]?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(state.values.protocol).toBe("http");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
contribution: PluginConnectionProviderContribution;
|
||||
modelValue: Record<string, PluginFormFieldValue>;
|
||||
hiddenBindings?: PluginFormFieldBinding[];
|
||||
layout?: "stacked" | "connection-dialog";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
|
||||
}>();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
return formValues.value[field.key] ?? field.default ?? defaultValueFor(field);
|
||||
}
|
||||
|
||||
function updateField(field: PluginFormField, value: PluginFormFieldValue) {
|
||||
emit("update:modelValue", { ...formValues.value, [field.key]: value });
|
||||
}
|
||||
|
||||
function updateTextField(field: PluginFormField, value: string | number) {
|
||||
if (field.type === "number") {
|
||||
updateField(field, value === "" ? undefined : Number(value));
|
||||
return;
|
||||
}
|
||||
updateField(field, String(value));
|
||||
}
|
||||
|
||||
function updateBooleanField(field: PluginFormField, value: boolean) {
|
||||
updateField(field, value);
|
||||
}
|
||||
|
||||
function updateSelectField(field: PluginFormField, value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
updateField(field, undefined);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") updateField(field, value);
|
||||
}
|
||||
|
||||
function fieldId(field: PluginFormField): string {
|
||||
return `${props.contribution.id}-${field.key}`.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
}
|
||||
|
||||
function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
|
||||
if (field.type === "boolean") return false;
|
||||
if (field.type === "number") return undefined;
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isConnectionDialogLayout ? 'contents' : 'space-y-4'">
|
||||
<div v-if="!isConnectionDialogLayout && (contribution.label || contribution.description)" class="space-y-1">
|
||||
<div v-if="contribution.label" class="text-sm font-medium">{{ contribution.label }}</div>
|
||||
<div v-if="contribution.description" class="text-xs text-muted-foreground">{{ contribution.description }}</div>
|
||||
</div>
|
||||
<div v-else-if="isConnectionDialogLayout && contribution.description" class="col-span-full grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ contribution.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
|
||||
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-destructive">*</span>
|
||||
</Label>
|
||||
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
|
||||
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="fieldId(field)"
|
||||
:value="String(fieldValue(field) ?? '')"
|
||||
:placeholder="field.placeholder"
|
||||
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-2.5 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
@input="updateField(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<Select v-else-if="field.type === 'select'" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
|
||||
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
|
||||
<SelectValue :placeholder="field.placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="option in field.options || []" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
|
||||
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
|
||||
<span>{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
<div v-if="field.description" class="text-[11px] leading-5 text-muted-foreground">{{ field.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,871 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, LayoutGrid, List, Loader2, PackageCheck, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
|
||||
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
|
||||
import { buildMarketplacePluginListings, filterMarketplacePluginListings, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { InstalledPlugin, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
focusTarget?: PluginCenterFocus | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
newConnection: [pluginId: string, providerId: string];
|
||||
}>();
|
||||
|
||||
// lucide no longer ships brand icons; mirror the inline glyph used in AppToolbar.
|
||||
const GithubIcon = {
|
||||
render() {
|
||||
return h("svg", { class: "size-3", viewBox: "0 0 24 24", fill: "currentColor" }, [
|
||||
h("path", {
|
||||
d: "M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
|
||||
|
||||
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const activeSection = ref<"marketplace" | "installed" | "settings">("marketplace");
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const trustedKeys = ref<PluginTrustedKey[]>([]);
|
||||
const repositories = ref<PluginRepository[]>([]);
|
||||
const catalogResults = ref<PluginRepositoryCatalogResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const marketplaceLoading = ref(false);
|
||||
const installing = ref(false);
|
||||
const marketplaceInstallingKey = ref("");
|
||||
const operating = ref(false);
|
||||
const error = ref("");
|
||||
const selectedPluginId = ref("");
|
||||
const selectedContributionId = ref("");
|
||||
const selectedConnectionId = ref("");
|
||||
const allowUnsigned = ref(
|
||||
((): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const trustedKeyId = ref("");
|
||||
const trustedPublicKey = ref("");
|
||||
const repositoryId = ref("");
|
||||
const repositoryName = ref("");
|
||||
const repositoryCatalogUrl = ref("");
|
||||
const marketplaceQuery = ref("");
|
||||
const marketplaceRepositoryId = ref("all");
|
||||
const marketplaceViewMode = ref<"grid" | "list">("grid");
|
||||
const webFileInput = ref<HTMLInputElement | null>(null);
|
||||
const panelRootRef = ref<HTMLElement | null>(null);
|
||||
const draggingPackage = ref(false);
|
||||
let webDragDepth = 0;
|
||||
|
||||
const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
const definitions = computed(() => registry.value.listPlugins());
|
||||
const connectionProviders = computed(() => registry.value.listConnectionProviders());
|
||||
const selectedEntry = computed(() => connectionProviders.value.find((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value) || null);
|
||||
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
|
||||
const selectedWorkbenches = computed(() => registry.value.listWorkbenches().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const selectedFilesystems = computed(() => registry.value.listFilesystemProviders().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const providerConnections = computed(() => {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return [];
|
||||
return connectionStore.connections.filter((connection) => connection.db_type === "plugin" && connection.plugin_id === entry.plugin.manifest.id && connection.plugin_connection_provider === entry.contribution.id);
|
||||
});
|
||||
const selectedConnection = computed(() => providerConnections.value.find((connection) => connection.id === selectedConnectionId.value));
|
||||
const marketplaceListings = computed(() => buildMarketplacePluginListings(catalogResults.value, installedPlugins.value, appLocale.value));
|
||||
const filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value));
|
||||
const catalogErrors = computed(() => catalogResults.value.filter((result) => result.error));
|
||||
const customRepositories = computed(() => repositories.value.filter((repository) => !repository.managed));
|
||||
const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0);
|
||||
|
||||
function openExternal(url?: string) {
|
||||
const target = url?.trim();
|
||||
if (!target) return;
|
||||
try {
|
||||
const parsed = new URL(target);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (isTauriRuntime()) void import("@tauri-apps/plugin-shell").then(({ open }) => open(target));
|
||||
else window.open(target, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function refresh(preferredPluginId = props.focusTarget?.pluginId || selectedPluginId.value) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
[installedPlugins.value, trustedKeys.value, repositories.value] = await Promise.all([api.listPlugins(), api.listPluginTrustedKeys(), api.listPluginRepositories()]);
|
||||
await refreshMarketplace();
|
||||
if (props.focusTarget) applyFocusTarget(props.focusTarget);
|
||||
else selectFirstProvider(preferredPluginId);
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMarketplace() {
|
||||
marketplaceLoading.value = true;
|
||||
try {
|
||||
catalogResults.value = await api.fetchPluginMarketplaceCatalogs();
|
||||
} catch (cause) {
|
||||
catalogResults.value = [];
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
marketplaceLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFocusTarget(focus: PluginCenterFocus) {
|
||||
activeSection.value = "installed";
|
||||
if (!focus.pluginId) return selectFirstProvider();
|
||||
const provider = connectionProviders.value.find((entry) => entry.plugin.manifest.id === focus.pluginId && (!focus.providerId || entry.contribution.id === focus.providerId));
|
||||
if (!provider) {
|
||||
selectPlugin(focus.pluginId);
|
||||
return;
|
||||
}
|
||||
selectProvider(focus.pluginId, provider.contribution.id);
|
||||
}
|
||||
|
||||
async function installMarketplaceListing(listing: MarketplacePluginListing) {
|
||||
if (!listing.artifact || listing.status === "installed") return;
|
||||
marketplaceInstallingKey.value = listing.key;
|
||||
try {
|
||||
const result = await api.installMarketplacePlugin({
|
||||
repositoryId: listing.repository.id,
|
||||
pluginId: listing.plugin.id,
|
||||
version: listing.plugin.latestVersion,
|
||||
});
|
||||
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 8000);
|
||||
} finally {
|
||||
marketplaceInstallingKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepository() {
|
||||
const id = repositoryId.value.trim();
|
||||
const name = repositoryName.value.trim();
|
||||
const catalogUrl = repositoryCatalogUrl.value.trim();
|
||||
if (!id || !name || !catalogUrl) return toast(t("pluginPlatform.repositoryFieldsRequired"));
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.savePluginRepository({ id, name, catalogUrl, kind: "custom", enabled: true, managed: false });
|
||||
repositoryId.value = "";
|
||||
repositoryName.value = "";
|
||||
repositoryCatalogUrl.value = "";
|
||||
toast(t("pluginPlatform.repositorySaved", { name }));
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRepository(repository: PluginRepository) {
|
||||
if (repository.managed) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.savePluginRepository({ ...repository, enabled: !repository.enabled });
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRepository(repository: PluginRepository) {
|
||||
if (repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.removePluginRepository(repository.id);
|
||||
if (marketplaceRepositoryId.value === repository.id) marketplaceRepositoryId.value = "all";
|
||||
toast(t("pluginPlatform.repositoryRemoved", { name: repository.name }));
|
||||
await refreshMarketplace();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrustedKey() {
|
||||
const keyId = trustedKeyId.value.trim();
|
||||
const publicKey = trustedPublicKey.value.trim();
|
||||
if (!keyId || !publicKey) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
trustedKeys.value = await api.savePluginTrustedKey(keyId, publicKey);
|
||||
trustedKeyId.value = "";
|
||||
trustedPublicKey.value = "";
|
||||
toast(t("pluginPlatform.repositoryKeyAdded", { keyId }));
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTrustedKey(keyId: string) {
|
||||
if (!window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
trustedKeys.value = await api.removePluginTrustedKey(keyId);
|
||||
toast(t("pluginPlatform.repositoryKeyRemoved", { keyId }));
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function abbreviatedPublicKey(publicKey: string): string {
|
||||
return publicKey.length <= 24 ? publicKey : `${publicKey.slice(0, 12)}…${publicKey.slice(-8)}`;
|
||||
}
|
||||
|
||||
function selectFirstProvider(preferredPluginId = "") {
|
||||
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === preferredPluginId) || connectionProviders.value[0];
|
||||
if (first) return selectProvider(first.plugin.manifest.id, first.contribution.id);
|
||||
selectedPluginId.value = definitions.value.find((definition) => definition.plugin.manifest.id === preferredPluginId)?.plugin.manifest.id || definitions.value[0]?.plugin.manifest.id || "";
|
||||
selectedContributionId.value = "";
|
||||
selectedConnectionId.value = "";
|
||||
}
|
||||
|
||||
function selectProvider(pluginId: string, contributionId: string) {
|
||||
selectedPluginId.value = pluginId;
|
||||
selectedContributionId.value = contributionId;
|
||||
const existing = connectionStore.connections.find((connection) => connection.db_type === "plugin" && connection.plugin_id === pluginId && connection.plugin_connection_provider === contributionId);
|
||||
selectedConnectionId.value = existing?.id || "";
|
||||
}
|
||||
|
||||
function selectPlugin(pluginId: string) {
|
||||
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId);
|
||||
if (first) return selectProvider(pluginId, first.contribution.id);
|
||||
selectedPluginId.value = pluginId;
|
||||
selectedContributionId.value = "";
|
||||
selectedConnectionId.value = "";
|
||||
}
|
||||
|
||||
function selectConnection(connectionId: string) {
|
||||
selectedConnectionId.value = connectionId;
|
||||
}
|
||||
|
||||
function editConnection(connectionId: string) {
|
||||
connectionStore.startEditing(connectionId);
|
||||
}
|
||||
|
||||
function createConnection() {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return toast(t("pluginPlatform.selectConnectionProvider"));
|
||||
emit("newConnection", entry.plugin.manifest.id, entry.contribution.id);
|
||||
}
|
||||
|
||||
async function openFilesystem(pluginId: string, providerId: string, label: string, rootUri?: string) {
|
||||
const connection = selectedConnection.value;
|
||||
try {
|
||||
if (connection) await connectionStore.ensureConnected(connection.id);
|
||||
queryStore.openPluginFilesystem(pluginId, providerId, {
|
||||
title: connection ? `${connection.name} · ${label}` : label,
|
||||
connectionId: connection?.id,
|
||||
rootUri,
|
||||
});
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, label: string) {
|
||||
const connection = selectedConnection.value;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: connection ? `${connection.name} · ${label}` : label,
|
||||
connectionId: connection?.id,
|
||||
context: connection
|
||||
? {
|
||||
connectionId: connection.id,
|
||||
providerId: connection.plugin_connection_provider,
|
||||
connectionType: connection.plugin_connection_type,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function choosePluginPackage() {
|
||||
if (!isTauriRuntime()) {
|
||||
webFileInput.value?.click();
|
||||
return;
|
||||
}
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await open({ multiple: false, filters: [{ name: t("pluginPlatform.packageFileType"), extensions: ["dbxp"] }] });
|
||||
if (typeof path === "string") await installPlugin(path);
|
||||
}
|
||||
|
||||
async function handleWebPackage(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (file) await installPlugin(file);
|
||||
}
|
||||
|
||||
async function installPlugin(source: string | File) {
|
||||
installing.value = true;
|
||||
try {
|
||||
const result = await api.installPluginPackage(source, allowUnsigned.value);
|
||||
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
activeSection.value = "installed";
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 8000);
|
||||
} finally {
|
||||
installing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPluginPackagePath(path: string): boolean {
|
||||
return /\.dbxp$/i.test(path);
|
||||
}
|
||||
|
||||
function webDropPluginPackage(event: DragEvent): File | null {
|
||||
const files = event.dataTransfer?.files;
|
||||
if (!files) return null;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (isPluginPackagePath(files[i].name)) return files[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onWebDragEnter(event: DragEvent) {
|
||||
if (!webDropPluginPackage(event)) return;
|
||||
event.preventDefault();
|
||||
webDragDepth++;
|
||||
draggingPackage.value = true;
|
||||
}
|
||||
|
||||
function onWebDragOver(event: DragEvent) {
|
||||
if (!webDropPluginPackage(event)) return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onWebDragLeave() {
|
||||
if (webDragDepth > 0 && --webDragDepth === 0) draggingPackage.value = false;
|
||||
}
|
||||
|
||||
function onWebDrop(event: DragEvent) {
|
||||
const claimed = draggingPackage.value;
|
||||
webDragDepth = 0;
|
||||
draggingPackage.value = false;
|
||||
const file = webDropPluginPackage(event);
|
||||
if (!file) {
|
||||
if (claimed) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (installing.value) return;
|
||||
void installPlugin(file);
|
||||
}
|
||||
|
||||
function dropInsidePanel(payload: Exclude<TauriFileDropPayload, { type: "leave" }>): boolean {
|
||||
const root = panelRootRef.value;
|
||||
if (!root) return false;
|
||||
return physicalDropPositionInsideRect(payload.position, root.getBoundingClientRect(), window.devicePixelRatio);
|
||||
}
|
||||
|
||||
function onTauriPluginDrop(event: Event) {
|
||||
const routedEvent = event as CustomEvent<TauriFileDropPayload>;
|
||||
const payload = routedEvent.detail;
|
||||
if (!payload) return;
|
||||
if (payload.type === "leave") {
|
||||
draggingPackage.value = false;
|
||||
return;
|
||||
}
|
||||
const inside = dropInsidePanel(payload);
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
const relevant = payload.type === "enter" ? inside && payload.paths.some(isPluginPackagePath) : inside;
|
||||
if (relevant) routedEvent.preventDefault();
|
||||
draggingPackage.value = relevant;
|
||||
return;
|
||||
}
|
||||
draggingPackage.value = false;
|
||||
const path = payload.paths.find(isPluginPackagePath);
|
||||
if (!inside || !path || installing.value) return;
|
||||
routedEvent.preventDefault();
|
||||
void installPlugin(path);
|
||||
}
|
||||
|
||||
async function rollbackSelectedPlugin() {
|
||||
if (!selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
const result = await api.rollbackPlugin(selectedPluginId.value);
|
||||
toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version }));
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallSelectedPlugin() {
|
||||
const definition = selectedDefinition.value;
|
||||
if (!definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
|
||||
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
|
||||
selectFirstProvider();
|
||||
} catch (cause) {
|
||||
toast(cause instanceof Error ? cause.message : String(cause), 5000);
|
||||
} finally {
|
||||
operating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(connectionProviders, (providers) => {
|
||||
if (!providers.some((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value)) selectFirstProvider(selectedPluginId.value);
|
||||
});
|
||||
watch(providerConnections, (connections) => {
|
||||
if (selectedConnectionId.value && connections.some((connection) => connection.id === selectedConnectionId.value)) return;
|
||||
selectedConnectionId.value = connections[0]?.id || "";
|
||||
});
|
||||
watch(
|
||||
() => props.focusTarget,
|
||||
(focus) => {
|
||||
if (focus && installedPlugins.value.length) applyFocusTarget(focus);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
watch(allowUnsigned, (value) => {
|
||||
try {
|
||||
localStorage.setItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY, value ? "1" : "0");
|
||||
} catch {
|
||||
// storage unavailable (private mode); the flag stays session-only
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="panelRootRef" class="plugin-center-view relative mx-auto flex h-full w-full max-w-6xl flex-col gap-4 overflow-hidden px-6 py-6" @dragenter="onWebDragEnter" @dragover="onWebDragOver" @dragleave="onWebDragLeave" @drop="onWebDrop">
|
||||
<input ref="webFileInput" type="file" accept=".dbxp" class="hidden" @change="handleWebPackage" />
|
||||
<div v-if="error" class="shrink-0 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<Tabs v-model="activeSection" class="min-h-0 flex-1 gap-3">
|
||||
<TabsList class="grid h-9 w-full grid-cols-3">
|
||||
<TabsTrigger value="marketplace" class="gap-1.5 text-xs"><Store class="size-3.5" />{{ t("pluginPlatform.marketplace") }}</TabsTrigger>
|
||||
<TabsTrigger value="installed" class="gap-1.5 text-xs"><PackageCheck class="size-3.5" />{{ t("pluginPlatform.installed") }}</TabsTrigger>
|
||||
<TabsTrigger value="settings" class="gap-1.5 text-xs"><Settings2 class="size-3.5" />{{ t("pluginPlatform.settings") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="marketplace" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="flex min-h-full w-full flex-col gap-4 pb-2">
|
||||
<div class="flex w-full flex-col gap-2 rounded-xl border bg-card/70 p-3 sm:flex-row sm:items-center">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
|
||||
<Select v-model="marketplaceRepositoryId">
|
||||
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-52 sm:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t("pluginPlatform.allRepositories") }}</SelectItem>
|
||||
<SelectItem v-for="repository in repositories.filter((entry) => entry.enabled)" :key="repository.id" :value="repository.id">{{ repository.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex shrink-0 items-center rounded-md border bg-muted/20 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
||||
:class="marketplaceViewMode === 'grid' ? 'bg-background text-foreground shadow-sm' : ''"
|
||||
:aria-label="t('structure.viewGrid')"
|
||||
:aria-pressed="marketplaceViewMode === 'grid'"
|
||||
@click="marketplaceViewMode = 'grid'"
|
||||
>
|
||||
<LayoutGrid class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
||||
:class="marketplaceViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : ''"
|
||||
:aria-label="t('structure.viewList')"
|
||||
:aria-pressed="marketplaceViewMode === 'list'"
|
||||
@click="marketplaceViewMode = 'list'"
|
||||
>
|
||||
<List class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" class="shrink-0" :disabled="marketplaceLoading" :title="t('common.refresh')" :aria-label="t('common.refresh')" @click="refreshMarketplace"><RefreshCw class="size-3.5" :class="marketplaceLoading ? 'animate-spin' : ''" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="result in catalogErrors" :key="result.repository.id" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-medium">{{ result.repository.name }}:</span> {{ result.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceLoading" class="flex min-h-[440px] flex-1 items-center justify-center gap-2 rounded-lg border border-dashed p-12 text-xs text-muted-foreground"><Loader2 class="size-4 animate-spin" />{{ t("pluginPlatform.loadingMarketplace") }}</div>
|
||||
<div v-else-if="!filteredMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<Store class="size-7 text-muted-foreground" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noMarketplacePlugins") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
|
||||
</div>
|
||||
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
|
||||
<div class="flex items-start gap-3">
|
||||
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-11 rounded-xl border bg-background p-1.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
|
||||
<Badge v-if="listing.verified" variant="secondary" class="h-5 gap-1 px-1.5 text-[10px]"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
|
||||
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
<p class="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-auto flex items-end justify-between gap-3 pt-4">
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
<span v-if="listing.status === 'unsupported'">{{ t("pluginPlatform.unsupportedTarget", { target: listing.target }) }}</span>
|
||||
<span v-else-if="listing.installed">{{ t("pluginPlatform.installedVersion", { version: listing.installed.manifest.version }) }}</span>
|
||||
<span v-else>{{ listing.plugin.license || t("pluginPlatform.licenseUnknown") }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
|
||||
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
|
||||
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
|
||||
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
|
||||
<Download v-else class="size-3.5" />
|
||||
{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col gap-2">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
|
||||
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-10 rounded-lg border bg-background p-1.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
|
||||
<Badge v-if="listing.verified" variant="secondary" class="hidden h-5 shrink-0 gap-1 px-1.5 text-[10px] sm:inline-flex"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
|
||||
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
|
||||
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
|
||||
</div>
|
||||
<div class="hidden max-w-52 shrink-0 gap-1.5 lg:flex">
|
||||
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
|
||||
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
|
||||
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
|
||||
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
|
||||
<Download v-else class="size-3.5" />
|
||||
<span class="hidden sm:inline">{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="installed" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div v-if="loading && !installedPlugins.length" class="py-10 text-center text-xs text-muted-foreground">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="!installedPlugins.length" class="rounded-lg border border-dashed p-10 text-center">
|
||||
<PackageCheck class="mx-auto size-7 text-muted-foreground" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noInstalledPlugins") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noInstalledPluginsDescription") }}</div>
|
||||
<Button class="mt-4 gap-1.5" size="sm" @click="activeSection = 'marketplace'"><Store class="size-3.5" />{{ t("pluginPlatform.browseMarketplace") }}</Button>
|
||||
</div>
|
||||
<div v-else class="grid min-h-[440px] gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div class="space-y-1 rounded-lg border bg-muted/10 p-2">
|
||||
<button
|
||||
v-for="definition in definitions"
|
||||
:key="definition.plugin.manifest.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-muted"
|
||||
:class="selectedPluginId === definition.plugin.manifest.id ? 'bg-muted ring-1 ring-primary/30' : ''"
|
||||
@click="selectPlugin(definition.plugin.manifest.id)"
|
||||
>
|
||||
<Check v-if="selectedPluginId === definition.plugin.manifest.id" class="mt-0.5 size-3.5 shrink-0 text-primary" /><span v-else class="mt-0.5 size-3.5 shrink-0" />
|
||||
<PluginIcon :plugin-id="definition.plugin.manifest.id" :icon="definition.plugin.manifest.icon" class="size-8 rounded-md border bg-background p-1" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
|
||||
<span class="mt-1 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
|
||||
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-lg border p-4">
|
||||
<template v-if="selectedDefinition">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b pb-4">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<PluginIcon :plugin-id="selectedDefinition.plugin.manifest.id" :icon="selectedDefinition.plugin.manifest.icon" class="size-10 rounded-lg border bg-background p-1.5" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">{{ selectedDefinition.plugin.manifest.name }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ selectedDefinition.plugin.manifest.description }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-mono text-[10px] text-muted-foreground">{{ selectedDefinition.plugin.manifest.id }}</span>
|
||||
<button
|
||||
v-if="selectedDefinition.plugin.manifest.source"
|
||||
type="button"
|
||||
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
|
||||
:title="t('pluginPlatform.sourceRepository')"
|
||||
:aria-label="t('pluginPlatform.sourceRepository')"
|
||||
@click="openExternal(selectedDefinition.plugin.manifest.source)"
|
||||
>
|
||||
<GithubIcon />
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedDefinition.plugin.manifest.homepage"
|
||||
type="button"
|
||||
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
|
||||
:title="t('pluginPlatform.pluginHomepage')"
|
||||
:aria-label="t('pluginPlatform.pluginHomepage')"
|
||||
@click="openExternal(selectedDefinition.plugin.manifest.homepage)"
|
||||
>
|
||||
<Globe class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="outline" class="gap-1.5" :disabled="operating" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
|
||||
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="operating" @click="uninstallSelectedPlugin"><Trash2 class="size-3.5" />{{ t("pluginPlatform.uninstall") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="connectionProviders.some((entry) => entry.plugin.manifest.id === selectedPluginId)" class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="entry in connectionProviders.filter((candidate) => candidate.plugin.manifest.id === selectedPluginId)"
|
||||
:key="entry.contribution.id"
|
||||
size="sm"
|
||||
:variant="selectedContributionId === entry.contribution.id ? 'secondary' : 'outline'"
|
||||
@click="selectProvider(entry.plugin.manifest.id, entry.contribution.id)"
|
||||
><PluginIcon :plugin-id="entry.plugin.manifest.id" :icon="pluginConnectionProviderIcon(entry)" class="mr-1 size-3.5" />{{ entry.contribution.label }}</Button
|
||||
>
|
||||
</div>
|
||||
<div v-if="selectedEntry" class="space-y-4 rounded-lg border p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ selectedEntry.contribution.label }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ selectedEntry.contribution.description || selectedEntry.contribution.database_type }}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{{ t("pluginPlatform.connectionProvider") }}</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" class="gap-1.5" @click="createConnection"><Plus class="size-3.5" />{{ t("pluginPlatform.newConnection") }}</Button>
|
||||
<div v-for="connection in providerConnections" :key="connection.id" class="inline-flex items-center">
|
||||
<Button size="sm" class="rounded-r-none" :variant="selectedConnectionId === connection.id ? 'secondary' : 'outline'" @click="selectConnection(connection.id)">{{ connection.name }}</Button>
|
||||
<Button size="icon" variant="outline" class="h-8 w-8 rounded-l-none border-l-0" :title="t('common.edit')" :aria-label="t('common.edit')" @click="editConnection(connection.id)"><Pencil class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-dashed bg-muted/20 p-3 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.connectionManagedInDialog") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedWorkbenches.length" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.workbenches") }}</div>
|
||||
<div v-for="entry in selectedWorkbenches" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ entry.contribution.description || entry.contribution.id }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="gap-1.5" @click="openWorkbench(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label)"><ExternalLink class="size-3.5" />{{ t("pluginPlatform.open") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFilesystems.length" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.filesystemProviders") }}</div>
|
||||
<div v-for="entry in selectedFilesystems" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ entry.contribution.schemes.join(", ") }} · {{ (entry.contribution.capabilities || []).join(", ") }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" class="gap-1.5" @click="openFilesystem(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label, entry.contribution.root_uri)"><FolderTree class="size-3.5" />{{ t("pluginPlatform.browse") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" class="m-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="space-y-4 pb-2">
|
||||
<section class="space-y-3 rounded-xl border p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-md bg-primary/10 p-2 text-primary"><FileUp class="size-4" /></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.localInstallTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.localInstallDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><ShieldCheck class="size-3.5 text-emerald-600 dark:text-emerald-400" />{{ t("pluginPlatform.signedPackagesVerifiedAutomatically") }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><FileUp class="size-3.5" />{{ t("pluginPlatform.dropInstallHint") }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-xl border p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-md bg-primary/10 p-2 text-primary"><Store class="size-4" /></div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoriesTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoriesDescription") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y rounded-md border">
|
||||
<div v-for="repository in repositories" :key="repository.id" class="flex flex-wrap items-center gap-3 px-3 py-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 text-xs font-medium">
|
||||
<span>{{ repository.name }}</span
|
||||
><Badge variant="outline" class="h-4 px-1.5 text-[9px]">{{ t(`pluginPlatform.repositoryKind.${repository.kind}`) }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 truncate font-mono text-[10px] text-muted-foreground">{{ repository.catalogUrl || t("pluginPlatform.repositoryNotConfigured") }}</div>
|
||||
</div>
|
||||
<Badge :variant="repository.enabled ? 'secondary' : 'outline'" class="h-5 px-1.5 text-[10px]">{{ repository.enabled ? t("pluginPlatform.enabled") : t("pluginPlatform.disabled") }}</Badge>
|
||||
<Button v-if="!repository.managed" size="sm" variant="ghost" class="h-7" :disabled="operating" @click="toggleRepository(repository)">{{ repository.enabled ? t("pluginPlatform.disable") : t("pluginPlatform.enable") }}</Button>
|
||||
<Button v-if="!repository.managed" size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeRepository(repository)"><Trash2 class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 lg:grid-cols-[180px_220px_minmax(260px,1fr)_auto]">
|
||||
<Input v-model="repositoryId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryIdPlaceholder')" />
|
||||
<Input v-model="repositoryName" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryNamePlaceholder')" />
|
||||
<Input v-model="repositoryCatalogUrl" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryCatalogUrlPlaceholder')" />
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveRepository"><Plus class="size-3.5" />{{ t("pluginPlatform.addRepository") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="group rounded-xl border bg-muted/15" open>
|
||||
<summary class="flex cursor-pointer list-none items-start gap-3 p-4 marker:content-none">
|
||||
<div class="rounded-md bg-muted p-2 text-muted-foreground"><Settings2 class="size-4" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.developerOptionsTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.developerOptionsDescription") }}</div>
|
||||
</div>
|
||||
<ChevronRight class="mt-2 size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div class="space-y-4 border-t p-4">
|
||||
<div class="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-amber-800 dark:text-amber-200">
|
||||
<CircleAlert class="mt-0.5 size-4 shrink-0" />
|
||||
<div class="text-xs leading-5">{{ t("pluginPlatform.developerOptionsWarning") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-4 rounded-lg border bg-background p-3">
|
||||
<div class="min-w-0">
|
||||
<Label for="allow-unsigned-plugin-package" class="text-xs font-medium">{{ t("pluginPlatform.allowUnsignedDevelopmentPackage") }}</Label>
|
||||
<div class="mt-1 text-[11px] leading-5 text-muted-foreground">{{ t("pluginPlatform.allowUnsignedDevelopmentPackageDescription") }}</div>
|
||||
</div>
|
||||
<Switch id="allow-unsigned-plugin-package" v-model="allowUnsigned" size="sm" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
|
||||
<template v-if="showCustomRepositoryTrustSettings">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoryTrustTitle") }}</div>
|
||||
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoryTrustDescription") }}</div>
|
||||
</div>
|
||||
<div v-if="trustedKeys.length" class="divide-y rounded-md border bg-background">
|
||||
<div v-for="key in trustedKeys" :key="key.keyId" class="flex items-center gap-3 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-medium">{{ key.keyId }}</div>
|
||||
<div class="truncate font-mono text-[10px] text-muted-foreground" :title="key.publicKey">{{ abbreviatedPublicKey(key.publicKey) }}</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeTrustedKey(key.keyId)"><Trash2 class="size-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-[180px_minmax(260px,1fr)_auto]">
|
||||
<Input v-model="trustedKeyId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryKeyIdPlaceholder')" />
|
||||
<Input v-model="trustedPublicKey" class="h-8 font-mono text-xs" :placeholder="t('pluginPlatform.repositoryPublicKeyPlaceholder')" />
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveTrustedKey"><ShieldCheck class="size-3.5" />{{ t("pluginPlatform.trustRepository") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div v-if="draggingPackage" class="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5">
|
||||
<FileUp class="size-8 text-primary" />
|
||||
<div class="text-sm font-medium text-primary">{{ t("pluginPlatform.dropToInstall") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import { AlertTriangle, ArrowUp, File, FileCode2, Folder, Loader2, RefreshCw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem";
|
||||
import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
provider: PluginFilesystemProviderContribution;
|
||||
connectionId?: string;
|
||||
initialUri?: string;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentUri = ref("");
|
||||
const address = ref("");
|
||||
const entries = ref<PluginFilesystemEntry[]>([]);
|
||||
const nextCursor = ref<string>();
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref("");
|
||||
const selected = ref<PluginFilesystemEntry>();
|
||||
const preview = ref("");
|
||||
const previewImageUrl = ref("");
|
||||
const previewKind = ref<"text" | "image" | "binary">("text");
|
||||
const previewContentType = ref("");
|
||||
const previewTruncated = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const previewError = ref("");
|
||||
let listGeneration = 0;
|
||||
let previewGeneration = 0;
|
||||
|
||||
const rootUri = computed(() => pluginFilesystemRootUri(props.provider));
|
||||
const canRead = computed(() => (props.provider.capabilities || []).includes("read"));
|
||||
const sortedEntries = computed(() => sortPluginFilesystemEntries(entries.value));
|
||||
const parentUri = computed(() => pluginFilesystemParentUri(currentUri.value, rootUri.value));
|
||||
|
||||
async function load(uri = address.value || currentUri.value || rootUri.value, append = false) {
|
||||
const targetUri = uri.trim() || rootUri.value;
|
||||
const generation = ++listGeneration;
|
||||
if (append) loadingMore.value = true;
|
||||
else loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.listPluginFilesystemEntries(props.pluginId, props.provider.id, {
|
||||
connectionId: props.connectionId,
|
||||
uri: targetUri,
|
||||
cursor: append ? nextCursor.value : undefined,
|
||||
limit: 200,
|
||||
});
|
||||
if (generation !== listGeneration) return;
|
||||
currentUri.value = targetUri;
|
||||
address.value = targetUri;
|
||||
entries.value = append ? uniquePluginFilesystemEntries([...entries.value, ...result.entries]) : uniquePluginFilesystemEntries(result.entries);
|
||||
nextCursor.value = result.nextCursor;
|
||||
if (!append) clearPreview();
|
||||
} catch (cause) {
|
||||
if (generation !== listGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === listGeneration) {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activateEntry(entry: PluginFilesystemEntry) {
|
||||
selected.value = entry;
|
||||
if (entry.kind === "directory") {
|
||||
await load(entry.uri);
|
||||
return;
|
||||
}
|
||||
if (!canRead.value || entry.kind !== "file") {
|
||||
preview.value = "";
|
||||
previewContentType.value = "";
|
||||
previewTruncated.value = false;
|
||||
previewError.value = canRead.value ? t("pluginPlatform.entryCannotPreview") : t("pluginPlatform.providerNoReadCapability");
|
||||
return;
|
||||
}
|
||||
const generation = ++previewGeneration;
|
||||
previewLoading.value = true;
|
||||
previewError.value = "";
|
||||
revokePreviewImage();
|
||||
preview.value = "";
|
||||
previewKind.value = "text";
|
||||
try {
|
||||
const contentType = (entry.contentType || "").split(";", 1)[0].toLowerCase();
|
||||
const isImage = contentType.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(entry.name);
|
||||
const result = await api.readPluginFilesystemFile(props.pluginId, props.provider.id, entry.uri, {
|
||||
connectionId: props.connectionId,
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
if (generation !== previewGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(result.dataBase64), (character) => character.charCodeAt(0));
|
||||
const resolvedContentType = (result.contentType || entry.contentType || "application/octet-stream").split(";", 1)[0].toLowerCase();
|
||||
previewContentType.value = resolvedContentType;
|
||||
if (isImage || resolvedContentType.startsWith("image/")) {
|
||||
if (result.truncated) {
|
||||
previewError.value = t("pluginPlatform.entryCannotPreview");
|
||||
} else {
|
||||
previewImageUrl.value = URL.createObjectURL(new Blob([bytes], { type: resolvedContentType }));
|
||||
previewKind.value = "image";
|
||||
}
|
||||
} else if (isTextPreview(entry.name, resolvedContentType)) {
|
||||
preview.value = new TextDecoder().decode(bytes);
|
||||
previewKind.value = "text";
|
||||
} else {
|
||||
previewKind.value = "binary";
|
||||
}
|
||||
previewTruncated.value = result.truncated;
|
||||
} catch (cause) {
|
||||
if (generation !== previewGeneration) return;
|
||||
previewError.value = cause instanceof Error ? cause.message : String(cause);
|
||||
preview.value = "";
|
||||
} finally {
|
||||
if (generation === previewGeneration) previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
previewGeneration += 1;
|
||||
selected.value = undefined;
|
||||
preview.value = "";
|
||||
revokePreviewImage();
|
||||
previewKind.value = "text";
|
||||
previewContentType.value = "";
|
||||
previewTruncated.value = false;
|
||||
previewLoading.value = false;
|
||||
previewError.value = "";
|
||||
}
|
||||
|
||||
function revokePreviewImage() {
|
||||
if (previewImageUrl.value) URL.revokeObjectURL(previewImageUrl.value);
|
||||
previewImageUrl.value = "";
|
||||
}
|
||||
|
||||
function isTextPreview(name: string, contentType: string) {
|
||||
return contentType.startsWith("text/") || /^(application\/(json|javascript|xml|yaml)|image\/svg\+xml)$/.test(contentType) || /\.(cjs|css|csv|html?|js|json|md|mjs|toml|ts|tsx|txt|vue|xml|yaml|yml)$/i.test(name);
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
if (size === undefined) return "";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
onMounted(() => void load(props.initialUri || rootUri.value));
|
||||
onBeforeUnmount(() => {
|
||||
revokePreviewImage();
|
||||
});
|
||||
watch(
|
||||
() => [props.pluginId, props.provider.id, props.connectionId, props.initialUri, rootUri.value] as const,
|
||||
() => void load(props.initialUri || rootUri.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 flex-col bg-background">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<Button size="icon" variant="ghost" class="size-8" :disabled="!parentUri || loading" :title="t('pluginPlatform.parentDirectory')" @click="parentUri && load(parentUri)">
|
||||
<ArrowUp class="size-4" />
|
||||
</Button>
|
||||
<Input v-model="address" class="h-8 min-w-0 flex-1 font-mono text-xs" :aria-label="t('pluginPlatform.filesystemUri')" @keydown.enter="load(address)" />
|
||||
<Button size="icon" variant="ghost" class="size-8" :disabled="loading" :title="t('pluginPlatform.refresh')" @click="load(currentUri)">
|
||||
<Loader2 v-if="loading" class="size-4 animate-spin" />
|
||||
<RefreshCw v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="m-3 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid min-h-0 flex-1 grid-cols-[minmax(18rem,42%)_minmax(0,1fr)] divide-x">
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<div v-if="loading && !entries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFiles") }}</div>
|
||||
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
|
||||
<RecycleScroller v-else class="min-h-0 flex-1 min-w-[28rem] text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
|
||||
<template #default="{ item: entry }">
|
||||
<button type="button" class="grid h-[46px] w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" @click="selected = entry" @dblclick="activateEntry(entry)">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
|
||||
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
|
||||
<File v-else class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
|
||||
</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
<div v-if="nextCursor" class="flex shrink-0 justify-center p-3">
|
||||
<Button size="sm" variant="outline" :disabled="loadingMore" @click="load(currentUri, true)"> <Loader2 v-if="loadingMore" class="mr-2 size-3.5 animate-spin" />{{ t("pluginPlatform.loadMore") }} </Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 min-w-0 flex-col">
|
||||
<div v-if="!selected" class="m-auto max-w-sm px-6 text-center text-sm text-muted-foreground">{{ t("pluginPlatform.fileManagerHint") }}</div>
|
||||
<template v-else>
|
||||
<div class="shrink-0 border-b px-4 py-3">
|
||||
<div class="truncate text-sm font-medium" :title="selected.name">{{ selected.name }}</div>
|
||||
<div class="mt-1 truncate font-mono text-[11px] text-muted-foreground" :title="selected.uri">{{ selected.uri }}</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div v-if="previewLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingPreview") }}</div>
|
||||
<div v-else-if="previewError" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangle class="mt-0.5 size-4 shrink-0" />{{ previewError }}</div>
|
||||
<template v-else-if="previewKind === 'image' && previewImageUrl">
|
||||
<img :src="previewImageUrl" :alt="selected.name" class="max-h-full max-w-full rounded-md object-contain" />
|
||||
</template>
|
||||
<template v-else-if="previewKind === 'text' && preview">
|
||||
<div class="mb-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ previewContentType }}</span>
|
||||
<span v-if="previewTruncated">{{ t("pluginPlatform.previewTruncated") }}</span>
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ preview }}</pre>
|
||||
</template>
|
||||
<div v-else-if="previewKind === 'binary'" class="m-auto max-w-sm text-center text-sm text-muted-foreground">{{ t("pluginPlatform.entryCannotPreview") }}</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t("pluginPlatform.previewSelectedFileHint") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import PluginFileManager from "@/components/plugins/PluginFileManager.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
providerId: string;
|
||||
connectionId?: string;
|
||||
rootUri?: string;
|
||||
initialUri?: string;
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
let loadGeneration = 0;
|
||||
const entry = computed(() =>
|
||||
createFrontendPluginRegistry(plugins.value, appLocale.value)
|
||||
.listFilesystemProviders()
|
||||
.find((candidate) => candidate.plugin.manifest.id === props.pluginId && candidate.contribution.id === props.providerId),
|
||||
);
|
||||
const provider = computed(() => (entry.value ? { ...entry.value.contribution, root_uri: props.rootUri || entry.value.contribution.root_uri } : undefined));
|
||||
|
||||
async function load() {
|
||||
const generation = ++loadGeneration;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const installed = await api.listPlugins();
|
||||
if (generation !== loadGeneration) return;
|
||||
plugins.value = installed;
|
||||
if (!entry.value) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId: props.pluginId, providerId: props.providerId }));
|
||||
} catch (cause) {
|
||||
if (generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
watch(
|
||||
() => [props.pluginId, props.providerId],
|
||||
() => void load(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0">
|
||||
<div v-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFilesystem") }}</div>
|
||||
<div v-else-if="error || !provider" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error || t("pluginPlatform.filesystemUnavailableFallback") }}</span>
|
||||
</div>
|
||||
<PluginFileManager v-else class="min-h-0 flex-1" :plugin-id="pluginId" :provider="provider" :connection-id="connectionId" :initial-uri="initialUri" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import PluginIcon from "./PluginIcon.vue";
|
||||
|
||||
const { readPluginAssetMock } = vi.hoisted(() => ({
|
||||
readPluginAssetMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
readPluginAsset: readPluginAssetMock,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
async function mountIcon(icon?: string) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(PluginIcon, { pluginId: "example.plugin", icon });
|
||||
app.mount(container);
|
||||
mountedApps.push(app);
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
readPluginAssetMock.mockReset();
|
||||
vi.stubGlobal("URL", {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => "blob:plugin-icon"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PluginIcon", () => {
|
||||
it("renders a developer-provided image asset", async () => {
|
||||
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
|
||||
|
||||
const container = await mountIcon("assets/icon.svg");
|
||||
|
||||
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/icon.svg");
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
|
||||
});
|
||||
|
||||
it("falls back when the asset cannot be loaded", async () => {
|
||||
readPluginAssetMock.mockRejectedValue(new Error("missing"));
|
||||
|
||||
const container = await mountIcon("assets/missing.svg");
|
||||
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses the fallback without reading an undeclared asset", async () => {
|
||||
const container = await mountIcon();
|
||||
|
||||
expect(readPluginAssetMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders marketplace icon URLs without reading installed assets", async () => {
|
||||
const container = await mountIcon("https://plugins.example.com/icon.svg");
|
||||
|
||||
expect(readPluginAssetMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe("https://plugins.example.com/icon.svg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import { PlugZap } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
icon?: string;
|
||||
}>();
|
||||
|
||||
const objectUrl = ref("");
|
||||
const failed = ref(false);
|
||||
let ownsObjectUrl = false;
|
||||
let requestSequence = 0;
|
||||
|
||||
function clearObjectUrl() {
|
||||
if (!objectUrl.value) return;
|
||||
if (ownsObjectUrl) URL.revokeObjectURL(objectUrl.value);
|
||||
objectUrl.value = "";
|
||||
ownsObjectUrl = false;
|
||||
}
|
||||
|
||||
function showFallback() {
|
||||
clearObjectUrl();
|
||||
failed.value = true;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.pluginId, props.icon] as const,
|
||||
async ([pluginId, icon]) => {
|
||||
const sequence = ++requestSequence;
|
||||
clearObjectUrl();
|
||||
if (!pluginId || !icon) {
|
||||
failed.value = true;
|
||||
return;
|
||||
}
|
||||
failed.value = false;
|
||||
if (/^https?:\/\//i.test(icon)) {
|
||||
objectUrl.value = icon;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const asset = await api.readPluginAsset(pluginId, icon);
|
||||
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));
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: asset.contentType }));
|
||||
if (sequence !== requestSequence) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
objectUrl.value = url;
|
||||
ownsObjectUrl = true;
|
||||
} catch {
|
||||
if (sequence === requestSequence) failed.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestSequence += 1;
|
||||
clearObjectUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex shrink-0 items-center justify-center">
|
||||
<img v-if="objectUrl && !failed" :src="objectUrl" alt="" class="size-full object-contain" @error="showFallback" />
|
||||
<PlugZap v-else aria-hidden="true" class="size-full text-violet-500" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginWorkbenchContribution;
|
||||
context?: PluginWorkbenchContext;
|
||||
}>(),
|
||||
{ context: () => ({}) },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: [];
|
||||
error: [message: string];
|
||||
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext];
|
||||
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const { isDark, activeCustomUiColors } = useTheme();
|
||||
const iframe = ref<HTMLIFrameElement>();
|
||||
const source = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
let bridge: PluginHostBridge | undefined;
|
||||
let unsubscribeEvents: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let loadGeneration = 0;
|
||||
|
||||
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
|
||||
|
||||
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
|
||||
function currentBridgeTheme(): PluginBridgeTheme {
|
||||
const tokens: Record<string, string> = {};
|
||||
if (typeof document !== "undefined") {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
for (const name of style) {
|
||||
if (!name.startsWith("--") || name.startsWith("--dbx-")) continue;
|
||||
if (/^--(color|radius|font)/.test(name)) {
|
||||
const value = style.getPropertyValue(name).trim();
|
||||
if (value) tokens[name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { appearance: isDark.value ? "dark" : "light", tokens };
|
||||
}
|
||||
|
||||
function createBridge() {
|
||||
bridge = new PluginHostBridge(
|
||||
props.plugin,
|
||||
props.contribution,
|
||||
props.context,
|
||||
() => iframe.value?.contentWindow || null,
|
||||
{
|
||||
invoke: api.invokePlugin,
|
||||
notify: api.notifyPlugin,
|
||||
sendBinary: api.sendPluginBinary,
|
||||
readAsset: api.readPluginUiAsset,
|
||||
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
},
|
||||
appLocale.value,
|
||||
currentBridgeTheme(),
|
||||
);
|
||||
}
|
||||
|
||||
function localUiAssetPath(source: string): string | undefined {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
|
||||
try {
|
||||
const resolved = new URL(trimmed, "https://dbx-plugin.invalid/");
|
||||
if (resolved.origin !== "https://dbx-plugin.invalid") return undefined;
|
||||
const path = decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
|
||||
if (!path || path.split("/").some((segment) => segment === "..")) return undefined;
|
||||
return path;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
|
||||
for (const resource of resources) {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) continue;
|
||||
const asset = await api.readPluginUiAsset(pluginId, path);
|
||||
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
|
||||
if (resource.tagName === "SCRIPT") {
|
||||
const script = document.createElement("script");
|
||||
for (const attribute of [...resource.attributes]) {
|
||||
if (attribute.name !== "src") script.setAttribute(attribute.name, attribute.value);
|
||||
}
|
||||
script.textContent = content;
|
||||
resource.replaceWith(script);
|
||||
} else {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = content;
|
||||
resource.replaceWith(style);
|
||||
}
|
||||
}
|
||||
return document.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge = undefined;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions);
|
||||
await nextTick();
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
createBridge();
|
||||
} catch (cause) {
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
emit("error", error.value);
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onMessage(event: MessageEvent) {
|
||||
bridge?.handleWindowMessage(event);
|
||||
}
|
||||
|
||||
function onFrameLoad() {
|
||||
bridge?.sendInit();
|
||||
emit("ready");
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("message", onMessage);
|
||||
const unsubscribe = await api.subscribePluginEvents(
|
||||
(event) => bridge?.forwardEvent(event),
|
||||
(event) => bridge?.forwardBinary(event),
|
||||
);
|
||||
if (disposed) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
unsubscribeEvents = unsubscribe;
|
||||
await loadWorkbench();
|
||||
});
|
||||
|
||||
// Identity changes require rebuilding the sandbox document; context and locale
|
||||
// changes are pushed through the bridge so plugin UI state survives them.
|
||||
watch(
|
||||
() => [props.plugin.manifest.id, props.plugin.manifest.version, props.contribution.id] as const,
|
||||
() => void loadWorkbench(),
|
||||
);
|
||||
watch(
|
||||
() => props.context,
|
||||
(context) => bridge?.updateContext(context ?? {}),
|
||||
{ deep: true },
|
||||
);
|
||||
watch(appLocale, (locale) => bridge?.updateLocale(locale));
|
||||
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
loadGeneration += 1;
|
||||
bridge = undefined;
|
||||
window.removeEventListener("message", onMessage);
|
||||
unsubscribeEvents?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
|
||||
<Loader2 class="mr-2 size-4 animate-spin" />
|
||||
{{ t("pluginPlatform.loadingTitle", { title }) }}
|
||||
</div>
|
||||
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<iframe v-else ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { AlertTriangle, Loader2 } from "@lucide/vue";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const props = defineProps<{
|
||||
pluginId: string;
|
||||
contributionId: string;
|
||||
context?: PluginWorkbenchContext;
|
||||
}>();
|
||||
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
let loadGeneration = 0;
|
||||
const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(props.pluginId, props.contributionId));
|
||||
|
||||
async function load() {
|
||||
const generation = ++loadGeneration;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const installed = await api.listPlugins();
|
||||
if (generation !== loadGeneration) return;
|
||||
plugins.value = installed;
|
||||
if (!entry.value) throw new Error(t("pluginPlatform.workbenchUnavailable", { pluginId: props.pluginId, contributionId: props.contributionId }));
|
||||
} catch (cause) {
|
||||
if (generation !== loadGeneration) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
if (generation === loadGeneration) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext) {
|
||||
const target = createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(pluginId, contributionId);
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, { title: target?.contribution.label || contributionId, context });
|
||||
}
|
||||
|
||||
function openFilesystem(pluginId: string, providerId: string, context?: PluginWorkbenchContext) {
|
||||
const target = createFrontendPluginRegistry(plugins.value, appLocale.value)
|
||||
.listFilesystemProviders()
|
||||
.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
|
||||
if (!target) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId, providerId }));
|
||||
queryStore.openPluginFilesystem(pluginId, providerId, {
|
||||
title: target.contribution.label,
|
||||
connectionId: typeof context?.connectionId === "string" ? context.connectionId : undefined,
|
||||
rootUri: target.contribution.root_uri,
|
||||
currentUri: typeof context?.uri === "string" ? context.uri : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
watch(
|
||||
() => [props.pluginId, props.contributionId],
|
||||
() => void load(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0">
|
||||
<div v-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingWorkbench") }}</div>
|
||||
<div v-else-if="error || !entry" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
|
||||
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
|
||||
</div>
|
||||
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="context" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Plug,
|
||||
Unplug,
|
||||
Pin,
|
||||
PlugZap,
|
||||
ArrowRightLeft,
|
||||
Download,
|
||||
Eye,
|
||||
@@ -70,6 +71,8 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -339,7 +342,7 @@ import {
|
||||
type DuplicateStructureSource,
|
||||
} from "./sidebarTreeDialogState";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale: appLocale } = useI18n();
|
||||
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -350,6 +353,15 @@ const settingsStore = useSettingsStore();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
|
||||
const { toast } = useToast();
|
||||
const installedPlugins = ref<InstalledPlugin[]>([]);
|
||||
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
|
||||
void api.listPlugins().then(
|
||||
(plugins) => {
|
||||
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
const { highlight } = useSqlHighlighter();
|
||||
|
||||
@@ -924,6 +936,9 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.loadNacosNamespaces(node.connectionId, treeLoadSearchOptions);
|
||||
} else if (config?.db_type === "mqtt") {
|
||||
await connectionStore.loadMqttTopics(node.connectionId);
|
||||
} else if (config?.db_type === "plugin") {
|
||||
await queryStore.openPluginConnection(node.connectionId);
|
||||
return;
|
||||
} else {
|
||||
await connectionStore.loadDatabases(node.connectionId, treeLoadSearchOptions);
|
||||
}
|
||||
@@ -1628,13 +1643,16 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean
|
||||
const eventCreateRequestId = openEventEditor === "create" && node.type === "group-events" ? ++mysqlEventCreateRequestSeq : undefined;
|
||||
const objectFilter = node.type === "event" || node.type === "group-events" ? "events" : undefined;
|
||||
|
||||
const connection = connectionStore.getConfig(node.connectionId);
|
||||
if (!connection) return;
|
||||
if (connection.db_type === "plugin") {
|
||||
await queryStore.openPluginConnection(node.connectionId);
|
||||
return;
|
||||
}
|
||||
if (hasTreeNodeDatabaseContext(node)) {
|
||||
queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connectionStore.getConfig(node.connectionId);
|
||||
if (!connection) return;
|
||||
const options = await getDatabaseOptions(node.connectionId);
|
||||
const database = resolveDefaultDatabase(connection, options);
|
||||
if (database) {
|
||||
@@ -6453,9 +6471,40 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
}
|
||||
|
||||
appendPluginConnectionMenuItems(items, node);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Plugin-contributed native menu entries for saved connections. */
|
||||
function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNode) {
|
||||
if (node.type !== "connection") return;
|
||||
const pluginItems = sidebarPluginRegistry.value.listContextMenuItems("connection");
|
||||
if (pluginItems.length === 0) return;
|
||||
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
|
||||
if (!config) return;
|
||||
items.push({ label: "", separator: true });
|
||||
for (const { plugin, contribution } of pluginItems) {
|
||||
items.push({
|
||||
label: contribution.label,
|
||||
icon: PlugZap,
|
||||
action: () => {
|
||||
api
|
||||
.invokePlugin(plugin.manifest.id, `contextMenu/${contribution.id}`, {
|
||||
connection: { id: config.id, dbType: config.db_type, name: config.name, database: config.database || "" },
|
||||
})
|
||||
.then((result) => {
|
||||
const message = (result as { message?: unknown } | null | undefined)?.message;
|
||||
if (typeof message === "string" && message.trim()) toast(message, 4000);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast(String((error as Error)?.message || error), 5000);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function activateRuntimeNode(node: TreeNode) {
|
||||
activeNode.value = node;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useI18n } from "vue-i18n";
|
||||
import { Eye, EyeOff } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const model = defineModel<string>();
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
Reference in new issue
Block a user