mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(plugins): add batch install update and uninstall actions
This commit is contained in:
@@ -17,6 +17,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
|
||||
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
|
||||
import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, marketplaceHomepageUrl, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch";
|
||||
import { formatBytes } from "@/lib/database/serverMetrics";
|
||||
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -97,6 +98,13 @@ const panelRootRef = ref<HTMLElement | null>(null);
|
||||
const draggingPackage = ref(false);
|
||||
let webDragDepth = 0;
|
||||
|
||||
// Batch operations (install / update on the marketplace tab, uninstall on the installed tab).
|
||||
const batchMode = ref(false);
|
||||
const selectedListingKeys = ref<Set<string>>(new Set());
|
||||
const selectedInstalledIds = ref<Set<string>>(new Set());
|
||||
const batchRunning = ref(false);
|
||||
const mutationRunning = computed(() => batchRunning.value || !!marketplaceInstallingKey.value || installing.value || urlInstalling.value || operating.value);
|
||||
|
||||
const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
|
||||
const definitions = computed(() => registry.value.listPlugins());
|
||||
const connectionProviders = computed(() => registry.value.listConnectionProviders());
|
||||
@@ -112,6 +120,9 @@ const providerConnections = computed(() => {
|
||||
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 batchUpdatableListings = computed(() => filteredMarketplaceListings.value.filter((listing) => listing.status === "update"));
|
||||
const batchSelectedListings = computed(() => filteredMarketplaceListings.value.filter((listing) => isBatchSelectableListing(listing.status) && selectedListingKeys.value.has(listing.key)));
|
||||
const batchSelectedInstalled = computed(() => definitions.value.filter((definition) => selectedInstalledIds.value.has(definition.plugin.manifest.id)));
|
||||
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);
|
||||
@@ -174,17 +185,22 @@ function applyFocusTarget(focus: PluginCenterFocus) {
|
||||
selectProvider(focus.pluginId, provider.contribution.id);
|
||||
}
|
||||
|
||||
async function installListing(listing: MarketplacePluginListing): Promise<PluginInstallResult> {
|
||||
const result = await api.installMarketplacePlugin({
|
||||
repositoryId: listing.repository.id,
|
||||
pluginId: listing.plugin.id,
|
||||
version: listing.plugin.latestVersion,
|
||||
});
|
||||
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function installMarketplaceListing(listing: MarketplacePluginListing) {
|
||||
if (!listing.artifact || listing.status === "installed") return;
|
||||
if (mutationRunning.value || !listing.artifact || !isBatchSelectableListing(listing.status)) return;
|
||||
marketplaceInstallingKey.value = listing.key;
|
||||
try {
|
||||
const result = await api.installMarketplacePlugin({
|
||||
repositoryId: listing.repository.id,
|
||||
pluginId: listing.plugin.id,
|
||||
version: listing.plugin.latestVersion,
|
||||
});
|
||||
const result = await installListing(listing);
|
||||
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
|
||||
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion);
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
@@ -194,7 +210,119 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) {
|
||||
}
|
||||
}
|
||||
|
||||
function batchSummaryKey(outcome: { succeeded: unknown[]; failed: { name: string }[] }): string {
|
||||
return outcome.failed.length ? "pluginPlatform.batchSummaryWithFailures" : "pluginPlatform.batchSummary";
|
||||
}
|
||||
|
||||
function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: string }[] }) {
|
||||
const failedNames = outcome.failed.map((failure) => failure.name).join("、");
|
||||
toast(t(batchSummaryKey(outcome), { success: outcome.succeeded.length, failed: outcome.failed.length, names: failedNames }), outcome.failed.length ? 8000 : 4000);
|
||||
}
|
||||
|
||||
async function refreshAfterBatch() {
|
||||
try {
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
} catch (cause) {
|
||||
error.value = t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
if (batchRunning.value) return;
|
||||
batchMode.value = !batchMode.value;
|
||||
if (!batchMode.value) clearBatchSelection();
|
||||
}
|
||||
|
||||
function clearBatchSelection() {
|
||||
selectedListingKeys.value = new Set();
|
||||
selectedInstalledIds.value = new Set();
|
||||
}
|
||||
|
||||
function isListingSelected(listing: MarketplacePluginListing): boolean {
|
||||
return selectedListingKeys.value.has(listing.key);
|
||||
}
|
||||
|
||||
function toggleListingSelection(listing: MarketplacePluginListing) {
|
||||
if (batchRunning.value || !isBatchSelectableListing(listing.status)) return;
|
||||
const next = new Set(selectedListingKeys.value);
|
||||
if (next.has(listing.key)) next.delete(listing.key);
|
||||
else next.add(listing.key);
|
||||
selectedListingKeys.value = next;
|
||||
}
|
||||
|
||||
function selectAllUpdatable() {
|
||||
if (batchRunning.value) return;
|
||||
const next = new Set(selectedListingKeys.value);
|
||||
for (const listing of batchUpdatableListings.value) next.add(listing.key);
|
||||
selectedListingKeys.value = next;
|
||||
}
|
||||
|
||||
function isInstalledSelected(pluginId: string): boolean {
|
||||
return selectedInstalledIds.value.has(pluginId);
|
||||
}
|
||||
|
||||
function toggleInstalledSelection(pluginId: string) {
|
||||
if (batchRunning.value) return;
|
||||
const next = new Set(selectedInstalledIds.value);
|
||||
if (next.has(pluginId)) next.delete(pluginId);
|
||||
else next.add(pluginId);
|
||||
selectedInstalledIds.value = next;
|
||||
}
|
||||
|
||||
async function runBatchInstallUpdate() {
|
||||
const targets = batchSelectedListings.value;
|
||||
if (!targets.length || mutationRunning.value) return;
|
||||
const pluginIds = new Set<string>();
|
||||
const duplicateIds = new Set<string>();
|
||||
for (const listing of marketplaceListings.value) {
|
||||
if (!selectedListingKeys.value.has(listing.key) || !isBatchSelectableListing(listing.status)) continue;
|
||||
if (pluginIds.has(listing.plugin.id)) duplicateIds.add(listing.plugin.id);
|
||||
pluginIds.add(listing.plugin.id);
|
||||
}
|
||||
if (duplicateIds.size) return toast(t("pluginPlatform.batchDuplicateSources", { names: [...duplicateIds].join("、") }), 8000);
|
||||
batchRunning.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const outcome = await runBatch(
|
||||
targets,
|
||||
(listing) => listing.name,
|
||||
async (listing) => {
|
||||
await installListing(listing);
|
||||
},
|
||||
);
|
||||
clearBatchSelection();
|
||||
reportBatchSummary(outcome);
|
||||
await refreshAfterBatch();
|
||||
} finally {
|
||||
batchRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBatchUninstall() {
|
||||
const targets = batchSelectedInstalled.value;
|
||||
if (!targets.length || mutationRunning.value) return;
|
||||
const names = targets.map((definition) => definition.plugin.manifest.name).join("、");
|
||||
if (!window.confirm(t("pluginPlatform.batchUninstallConfirm", { count: targets.length, names }))) return;
|
||||
batchRunning.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const outcome = await runBatch(
|
||||
targets,
|
||||
(definition) => definition.plugin.manifest.name,
|
||||
async (definition) => {
|
||||
await api.uninstallPlugin(definition.plugin.manifest.id);
|
||||
},
|
||||
);
|
||||
clearBatchSelection();
|
||||
reportBatchSummary(outcome);
|
||||
await refreshAfterBatch();
|
||||
} finally {
|
||||
batchRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRepository() {
|
||||
if (mutationRunning.value) return;
|
||||
const id = repositoryId.value.trim();
|
||||
const name = repositoryName.value.trim();
|
||||
const catalogUrl = repositoryCatalogUrl.value.trim();
|
||||
@@ -215,7 +343,7 @@ async function saveRepository() {
|
||||
}
|
||||
|
||||
async function toggleRepository(repository: PluginRepository) {
|
||||
if (repository.managed) return;
|
||||
if (mutationRunning.value || repository.managed) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.savePluginRepository({ ...repository, enabled: !repository.enabled });
|
||||
@@ -228,7 +356,7 @@ async function toggleRepository(repository: PluginRepository) {
|
||||
}
|
||||
|
||||
async function removeRepository(repository: PluginRepository) {
|
||||
if (repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return;
|
||||
if (mutationRunning.value || repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
repositories.value = await api.removePluginRepository(repository.id);
|
||||
@@ -243,6 +371,7 @@ async function removeRepository(repository: PluginRepository) {
|
||||
}
|
||||
|
||||
async function saveTrustedKey() {
|
||||
if (mutationRunning.value) return;
|
||||
const keyId = trustedKeyId.value.trim();
|
||||
const publicKey = trustedPublicKey.value.trim();
|
||||
if (!keyId || !publicKey) return;
|
||||
@@ -260,7 +389,7 @@ async function saveTrustedKey() {
|
||||
}
|
||||
|
||||
async function removeTrustedKey(keyId: string) {
|
||||
if (!window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return;
|
||||
if (mutationRunning.value || !window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
trustedKeys.value = await api.removePluginTrustedKey(keyId);
|
||||
@@ -343,6 +472,7 @@ function openWorkbench(pluginId: string, contributionId: string, label: string)
|
||||
}
|
||||
|
||||
async function choosePluginPackage() {
|
||||
if (mutationRunning.value) return;
|
||||
if (!isTauriRuntime()) {
|
||||
webFileInput.value?.click();
|
||||
return;
|
||||
@@ -385,6 +515,7 @@ async function finishInstall(result: PluginInstallResult) {
|
||||
}
|
||||
|
||||
async function installPlugin(source: string | File) {
|
||||
if (mutationRunning.value) return;
|
||||
installing.value = true;
|
||||
try {
|
||||
const result = await api.installPluginPackage(source, allowUnsigned.value);
|
||||
@@ -407,7 +538,7 @@ function isHttpPackageUrl(value: string): boolean {
|
||||
|
||||
async function installPluginFromUrl() {
|
||||
const url = installUrl.value.trim();
|
||||
if (!url || installing.value || urlInstalling.value) return;
|
||||
if (!url || mutationRunning.value) return;
|
||||
if (!isHttpPackageUrl(url)) return toast(t("pluginPlatform.invalidPackageUrl"));
|
||||
urlInstalling.value = true;
|
||||
urlDownloadProgress.value = { downloaded: 0, total: null };
|
||||
@@ -484,7 +615,7 @@ function onWebDrop(event: DragEvent) {
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (installing.value || urlInstalling.value) return;
|
||||
if (mutationRunning.value) return;
|
||||
void installPlugin(file);
|
||||
}
|
||||
|
||||
@@ -511,13 +642,13 @@ function onTauriPluginDrop(event: Event) {
|
||||
}
|
||||
draggingPackage.value = false;
|
||||
const path = payload.paths.find(isPluginPackagePath);
|
||||
if (!inside || !path || installing.value || urlInstalling.value) return;
|
||||
if (!inside || !path || mutationRunning.value) return;
|
||||
routedEvent.preventDefault();
|
||||
void installPlugin(path);
|
||||
}
|
||||
|
||||
async function rollbackSelectedPlugin() {
|
||||
if (!selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return;
|
||||
if (mutationRunning.value || !selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return;
|
||||
operating.value = true;
|
||||
try {
|
||||
const result = await api.rollbackPlugin(selectedPluginId.value);
|
||||
@@ -534,7 +665,7 @@ async function rollbackSelectedPlugin() {
|
||||
|
||||
async function uninstallSelectedPlugin() {
|
||||
const definition = selectedDefinition.value;
|
||||
if (!definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return;
|
||||
if (mutationRunning.value || !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);
|
||||
@@ -650,10 +781,20 @@ onBeforeUnmount(() => {
|
||||
<List class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0 gap-1.5 text-xs" :pressed="batchMode" :disabled="batchRunning" @click="toggleBatchMode"> <Check class="size-3.5" />{{ batchMode ? t("pluginPlatform.batchDone") : t("pluginPlatform.batchManage") }} </Button>
|
||||
<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-if="batchMode" class="flex flex-wrap items-center gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs">
|
||||
<span class="font-medium text-foreground">{{ t("pluginPlatform.batchSelected", { count: batchSelectedListings.length }) }}</span>
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" :disabled="!batchUpdatableListings.length || batchRunning" @click="selectAllUpdatable"><Download class="size-3.5" />{{ t("pluginPlatform.batchSelectAllUpdatable") }}</Button>
|
||||
<Button size="sm" class="h-7 gap-1.5 text-xs" :disabled="!batchSelectedListings.length || mutationRunning" @click="runBatchInstallUpdate"> <Loader2 v-if="batchRunning" class="size-3.5 animate-spin" />{{ t("pluginPlatform.batchInstallUpdate") }} </Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 text-xs" :disabled="batchRunning" @click="clearBatchSelection">{{ t("common.cancel") }}</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>
|
||||
@@ -670,6 +811,18 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-1 gap-3 md:grid-cols-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">
|
||||
<button
|
||||
v-if="batchMode && isBatchSelectableListing(listing.status)"
|
||||
type="button"
|
||||
class="mt-1 inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors"
|
||||
:class="isListingSelected(listing) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 bg-background'"
|
||||
:aria-pressed="isListingSelected(listing)"
|
||||
:aria-label="listing.name"
|
||||
:disabled="batchRunning"
|
||||
@click.stop="toggleListingSelection(listing)"
|
||||
>
|
||||
<Check v-if="isListingSelected(listing)" class="size-3" />
|
||||
</button>
|
||||
<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">
|
||||
@@ -724,7 +877,7 @@ onBeforeUnmount(() => {
|
||||
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="marketplaceActionClass(listing)"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
@@ -735,6 +888,18 @@ onBeforeUnmount(() => {
|
||||
</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">
|
||||
<button
|
||||
v-if="batchMode && isBatchSelectableListing(listing.status)"
|
||||
type="button"
|
||||
class="inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors"
|
||||
:class="isListingSelected(listing) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 bg-background'"
|
||||
:aria-pressed="isListingSelected(listing)"
|
||||
:aria-label="listing.name"
|
||||
:disabled="batchRunning"
|
||||
@click.stop="toggleListingSelection(listing)"
|
||||
>
|
||||
<Check v-if="isListingSelected(listing)" class="size-3" />
|
||||
</button>
|
||||
<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">
|
||||
@@ -779,7 +944,7 @@ onBeforeUnmount(() => {
|
||||
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="marketplaceActionClass(listing)"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
|
||||
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning"
|
||||
@click="installMarketplaceListing(listing)"
|
||||
>
|
||||
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
|
||||
@@ -798,120 +963,139 @@ onBeforeUnmount(() => {
|
||||
<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="marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, 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(marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, 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-sm" class="rounded-l-none border-l-0" :variant="selectedConnectionId === connection.id ? 'secondary' : 'outline'" :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>
|
||||
<div v-else class="flex min-h-[440px] flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" :pressed="batchMode" :disabled="batchRunning" @click="toggleBatchMode"> <Check class="size-3.5" />{{ batchMode ? t("pluginPlatform.batchDone") : t("pluginPlatform.batchManage") }} </Button>
|
||||
<template v-if="batchMode">
|
||||
<span class="text-xs font-medium text-foreground">{{ t("pluginPlatform.batchSelected", { count: batchSelectedInstalled.length }) }}</span>
|
||||
<Button size="sm" variant="outline" class="ml-auto h-8 gap-1.5 text-xs text-destructive" :disabled="!batchSelectedInstalled.length || mutationRunning" @click="runBatchUninstall">
|
||||
<Loader2 v-if="batchRunning" class="size-3.5 animate-spin" /><Trash2 class="size-3.5" />{{ t("pluginPlatform.batchUninstall") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-8 text-xs" :disabled="batchRunning" @click="clearBatchSelection">{{ t("common.cancel") }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="grid min-h-0 flex-1 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="batchMode ? (isInstalledSelected(definition.plugin.manifest.id) ? 'bg-muted ring-1 ring-primary/30' : '') : selectedPluginId === definition.plugin.manifest.id ? 'bg-muted ring-1 ring-primary/30' : ''"
|
||||
:disabled="batchMode && batchRunning"
|
||||
@click="batchMode ? toggleInstalledSelection(definition.plugin.manifest.id) : selectPlugin(definition.plugin.manifest.id)"
|
||||
>
|
||||
<span
|
||||
v-if="batchMode"
|
||||
class="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border transition-colors"
|
||||
:class="isInstalledSelected(definition.plugin.manifest.id) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 bg-background'"
|
||||
><Check v-if="isInstalledSelected(definition.plugin.manifest.id)" class="size-3"
|
||||
/></span>
|
||||
<Check v-else-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="marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, 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(marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, 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="mutationRunning" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
|
||||
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="mutationRunning" @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-sm" class="rounded-l-none border-l-0" :variant="selectedConnectionId === connection.id ? 'secondary' : 'outline'" :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>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -926,7 +1110,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing || urlInstalling" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="mutationRunning" @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>
|
||||
@@ -935,9 +1119,7 @@ onBeforeUnmount(() => {
|
||||
<Link2 class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input v-model="installUrl" class="h-8 pl-8 font-mono text-xs" type="url" :disabled="urlInstalling" :placeholder="t('pluginPlatform.installUrlPlaceholder')" @keyup.enter="installPluginFromUrl" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing || urlInstalling || !installUrl.trim()" @click="installPluginFromUrl"
|
||||
><Loader2 v-if="urlInstalling" class="size-3.5 animate-spin" /><Download v-else class="size-3.5" />{{ t("pluginPlatform.installFromUrl") }}</Button
|
||||
>
|
||||
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="mutationRunning || !installUrl.trim()" @click="installPluginFromUrl"><Loader2 v-if="urlInstalling" class="size-3.5 animate-spin" /><Download v-else class="size-3.5" />{{ t("pluginPlatform.installFromUrl") }}</Button>
|
||||
</div>
|
||||
<div v-if="urlInstalling" class="space-y-1.5">
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
@@ -965,15 +1147,15 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<Button v-if="!repository.managed" size="sm" variant="ghost" class="h-7" :disabled="mutationRunning" @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="mutationRunning" @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>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="mutationRunning" @click="saveRepository"><Plus class="size-3.5" />{{ t("pluginPlatform.addRepository") }}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1019,13 +1201,13 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<Button size="icon" variant="ghost" class="size-7 text-destructive" :disabled="mutationRunning" @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>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="mutationRunning" @click="saveTrustedKey"><ShieldCheck class="size-3.5" />{{ t("pluginPlatform.trustRepository") }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
|
||||
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listPlugins: vi.fn(),
|
||||
listPluginTrustedKeys: vi.fn(),
|
||||
listPluginRepositories: vi.fn(),
|
||||
fetchPluginMarketplaceCatalogs: vi.fn(),
|
||||
installMarketplacePlugin: vi.fn(),
|
||||
installPluginPackage: vi.fn(),
|
||||
installPluginPackageFromUrl: vi.fn(),
|
||||
uninstallPlugin: vi.fn(),
|
||||
rollbackPlugin: vi.fn(),
|
||||
savePluginRepository: vi.fn(),
|
||||
removePluginRepository: vi.fn(),
|
||||
savePluginTrustedKey: vi.fn(),
|
||||
removePluginTrustedKey: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => mocks);
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const { ref } = await import("vue");
|
||||
return { useI18n: () => ({ locale: ref("en"), t: (key: string, values = {}) => `${key}:${JSON.stringify(values)}` }) };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
|
||||
vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") }));
|
||||
vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") }));
|
||||
vi.mock("@/components/ui/label", async () => ({ Label: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Label", "label") }));
|
||||
vi.mock("@/components/ui/switch", async () => ({ Switch: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Switch") }));
|
||||
vi.mock("@/components/ui/select", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const stub = createPassthroughStub("Select");
|
||||
return { Select: stub, SelectContent: stub, SelectItem: stub, SelectTrigger: stub, SelectValue: stub };
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const stub = createPassthroughStub("Tabs");
|
||||
return { Tabs: stub, TabsContent: stub, TabsList: stub, TabsTrigger: stub };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
|
||||
const stub = createPassthroughStub("Tooltip");
|
||||
return { Tooltip: stub, TooltipContent: stub, TooltipTrigger: stub };
|
||||
});
|
||||
vi.mock("@/components/plugins/PluginIcon.vue", async () => ({ default: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("PluginIcon") }));
|
||||
|
||||
import PluginContributionsPanel from "@/components/plugins/PluginContributionsPanel.vue";
|
||||
|
||||
type PanelState = {
|
||||
batchRunning: boolean;
|
||||
batchMode: boolean;
|
||||
marketplaceViewMode: "grid" | "list";
|
||||
marketplaceRepositoryId: string;
|
||||
marketplaceListings: MarketplacePluginListing[];
|
||||
catalogResults: PluginRepositoryCatalogResult[];
|
||||
installedPlugins: InstalledPlugin[];
|
||||
selectedListingKeys: Set<string>;
|
||||
selectedInstalledIds: Set<string>;
|
||||
selectedPluginId: string;
|
||||
installUrl: string;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
repositoryCatalogUrl: string;
|
||||
trustedKeyId: string;
|
||||
trustedPublicKey: string;
|
||||
error: string;
|
||||
toggleListingSelection: (listing: MarketplacePluginListing) => void;
|
||||
selectAllUpdatable: () => void;
|
||||
runBatchInstallUpdate: () => Promise<void>;
|
||||
runBatchUninstall: () => Promise<void>;
|
||||
installMarketplaceListing: (listing: MarketplacePluginListing) => Promise<void>;
|
||||
installPlugin: (source: string) => Promise<void>;
|
||||
installPluginFromUrl: () => Promise<void>;
|
||||
uninstallSelectedPlugin: () => Promise<void>;
|
||||
rollbackSelectedPlugin: () => Promise<void>;
|
||||
saveRepository: () => Promise<void>;
|
||||
toggleRepository: (repository: PluginRepositoryCatalogResult["repository"]) => Promise<void>;
|
||||
removeRepository: (repository: PluginRepositoryCatalogResult["repository"]) => Promise<void>;
|
||||
saveTrustedKey: () => Promise<void>;
|
||||
removeTrustedKey: (keyId: string) => Promise<void>;
|
||||
toggleBatchMode: () => void;
|
||||
toggleInstalledSelection: (pluginId: string) => void;
|
||||
};
|
||||
|
||||
function installed(id: string, version = "1.0.0"): InstalledPlugin {
|
||||
return {
|
||||
manifest: { manifest_version: 1, id, name: id, version, publisher: "DBX", description: "", engines: { dbx: "", host_api: "" }, permissions: [], entrypoints: {}, contributions: [], drivers: [], protocol_version: 1 },
|
||||
compatibility: { compatible: true, errors: [], warnings: [], target: "darwin-arm64" },
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(repositoryId: string, ids: string[], version = "3.0.0"): PluginRepositoryCatalogResult {
|
||||
return {
|
||||
repository: { id: repositoryId, name: repositoryId, kind: "custom", enabled: true, managed: false },
|
||||
target: "darwin-arm64",
|
||||
catalog: {
|
||||
catalogVersion: 1,
|
||||
repository: { id: repositoryId, name: repositoryId },
|
||||
plugins: ids.map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
publisher: "DBX",
|
||||
verified: false,
|
||||
tags: [],
|
||||
permissions: [],
|
||||
latestVersion: version,
|
||||
versions: [{ version, artifacts: [{ target: "darwin-arm64", url: "https://example.invalid/plugin.dbxp", sha256: "a".repeat(64), signingKeyId: "test.key" }] }],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (cause: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let app: App;
|
||||
let host: HTMLElement;
|
||||
let state: PanelState;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 8; index++) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function button(key: string): HTMLButtonElement {
|
||||
const match = [...host.querySelectorAll("button")].find((element) => element.textContent?.includes(`pluginPlatform.${key}:`));
|
||||
expect(match, key).toBeDefined();
|
||||
return match!;
|
||||
}
|
||||
|
||||
const mutationApis = [mocks.installMarketplacePlugin, mocks.uninstallPlugin, mocks.rollbackPlugin, mocks.installPluginPackage, mocks.installPluginPackageFromUrl];
|
||||
|
||||
function mutationCount() {
|
||||
return mutationApis.reduce((count, mock) => count + mock.mock.calls.length, 0);
|
||||
}
|
||||
|
||||
async function expectBusyControls() {
|
||||
for (const view of ["grid", "list"] as const) {
|
||||
state.marketplaceViewMode = view;
|
||||
await nextTick();
|
||||
for (const key of ["batchInstallUpdate", "batchUninstall", "marketplaceStatus.update", "uninstall", "rollback", "installPackage", "installFromUrl"]) {
|
||||
expect(button(key).disabled, `${view}: ${key}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
localStorage.clear();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({}));
|
||||
vi.stubGlobal("confirm", vi.fn().mockReturnValue(true));
|
||||
mocks.listPlugins.mockResolvedValue([installed("a"), installed("b"), installed("c")]);
|
||||
mocks.listPluginTrustedKeys.mockResolvedValue([]);
|
||||
mocks.listPluginRepositories.mockResolvedValue([]);
|
||||
mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([catalog("first", ["a", "b", "c"])]);
|
||||
for (const mock of mutationApis) mock.mockResolvedValue({ plugin: installed("a", "3.0.0") });
|
||||
mocks.uninstallPlugin.mockResolvedValue([]);
|
||||
mocks.savePluginRepository.mockResolvedValue([]);
|
||||
mocks.removePluginRepository.mockResolvedValue([]);
|
||||
mocks.savePluginTrustedKey.mockResolvedValue([]);
|
||||
mocks.removePluginTrustedKey.mockResolvedValue([]);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
app = createApp(PluginContributionsPanel);
|
||||
const instance = app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } };
|
||||
state = instance.$.setupState;
|
||||
await flushUi();
|
||||
state.batchMode = true;
|
||||
state.selectedPluginId = "a";
|
||||
state.selectedListingKeys = new Set(["first:a"]);
|
||||
state.selectedInstalledIds = new Set(["a"]);
|
||||
state.installUrl = "https://example.invalid/plugin.dbxp";
|
||||
state.repositoryId = "custom";
|
||||
state.repositoryName = "Custom";
|
||||
state.repositoryCatalogUrl = "https://example.invalid/catalog.json";
|
||||
state.trustedKeyId = "custom";
|
||||
state.trustedPublicKey = "public-key";
|
||||
mocks.listPlugins.mockClear();
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
host?.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PluginContributionsPanel batch source validation", () => {
|
||||
it.each(["first", "second"])("does not silently resolve selected source conflicts by filtering to %s", async (repositoryId) => {
|
||||
state.catalogResults = [catalog("first", ["a"]), catalog("second", ["a"], "2.0.0")];
|
||||
state.selectAllUpdatable();
|
||||
state.marketplaceRepositoryId = repositoryId;
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(mutationCount()).toBe(0);
|
||||
expect(state.selectedListingKeys.size).toBe(2);
|
||||
state.toggleListingSelection(state.marketplaceListings.find((listing) => listing.repository.id !== repositoryId)!);
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId, pluginId: "a", version: repositoryId === "first" ? "3.0.0" : "2.0.0" });
|
||||
});
|
||||
|
||||
it.each(["2.0.0", "3.0.0"].flatMap((version) => ["manual", "all"].map((selection) => ({ version, selection }))))("rejects $selection duplicate sources at $version before any mutation", async ({ version, selection }) => {
|
||||
state.catalogResults = [catalog("first", ["a", "b"]), catalog("second", ["b"], version)];
|
||||
state.selectedListingKeys = new Set();
|
||||
if (selection === "all") state.selectAllUpdatable();
|
||||
else for (const listing of state.marketplaceListings) state.toggleListingSelection(listing);
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(mutationCount()).toBe(0);
|
||||
expect(mocks.listPlugins).not.toHaveBeenCalled();
|
||||
expect(state.selectedListingKeys.size).toBe(3);
|
||||
expect(state.batchRunning).toBe(false);
|
||||
expect(mocks.toast).toHaveBeenCalledWith(expect.stringContaining('pluginPlatform.batchDuplicateSources:{"names":"b"}'), 8000);
|
||||
|
||||
state.toggleListingSelection(state.marketplaceListings.find((listing) => listing.key === "first:b")!);
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(mocks.installMarketplacePlugin.mock.calls.map(([request]) => request)).toEqual([
|
||||
{ repositoryId: "first", pluginId: "a", version: "3.0.0" },
|
||||
{ repositoryId: "second", pluginId: "b", version },
|
||||
]);
|
||||
expect(state.selectedListingKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const batches = ["install", "uninstall"] as const;
|
||||
type Batch = (typeof batches)[number];
|
||||
const singles = ["marketplace", "uninstall", "rollback", "package", "url"] as const;
|
||||
type Single = (typeof singles)[number];
|
||||
|
||||
function startBatch(batch: Batch) {
|
||||
return batch === "install" ? state.runBatchInstallUpdate() : state.runBatchUninstall();
|
||||
}
|
||||
|
||||
function startSingle(single: Single) {
|
||||
switch (single) {
|
||||
case "marketplace":
|
||||
return state.installMarketplaceListing(state.marketplaceListings[0]);
|
||||
case "uninstall":
|
||||
return state.uninstallSelectedPlugin();
|
||||
case "rollback":
|
||||
return state.rollbackSelectedPlugin();
|
||||
case "package":
|
||||
return state.installPlugin("plugin.dbxp");
|
||||
case "url":
|
||||
return state.installPluginFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function singleApi(single: Single) {
|
||||
return { marketplace: mocks.installMarketplacePlugin, uninstall: mocks.uninstallPlugin, rollback: mocks.rollbackPlugin, package: mocks.installPluginPackage, url: mocks.installPluginPackageFromUrl }[single];
|
||||
}
|
||||
|
||||
describe("PluginContributionsPanel mutation exclusion", () => {
|
||||
it.each(singles.flatMap((single) => [false, true].map((reject) => ({ single, reject }))))("releases single $single exclusion after rejection=$reject", async ({ single, reject }) => {
|
||||
if (reject) singleApi(single).mockRejectedValueOnce(new Error("single denied"));
|
||||
await startSingle(single);
|
||||
expect(mutationCount()).toBe(1);
|
||||
if (reject) expect(mocks.toast).toHaveBeenLastCalledWith("single denied", expect.any(Number));
|
||||
await nextTick();
|
||||
expect(button("batchInstallUpdate").disabled).toBe(false);
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(mutationCount()).toBe(2);
|
||||
expect(state.batchRunning).toBe(false);
|
||||
});
|
||||
|
||||
it.each(batches.flatMap((batch) => singles.map((single) => ({ batch, single }))))("blocks $single while batch $batch is pending", async ({ batch, single }) => {
|
||||
const pending = deferred<unknown>();
|
||||
(batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise);
|
||||
const running = startBatch(batch);
|
||||
try {
|
||||
await startSingle(single);
|
||||
expect(mutationCount()).toBe(1);
|
||||
await expectBusyControls();
|
||||
} finally {
|
||||
pending.resolve(batch === "install" ? { plugin: installed("a") } : []);
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(singles.flatMap((single) => batches.map((batch) => ({ single, batch }))))("blocks batch $batch while single $single is pending", async ({ single, batch }) => {
|
||||
const pending = deferred<unknown>();
|
||||
singleApi(single).mockReturnValueOnce(pending.promise);
|
||||
const running = startSingle(single);
|
||||
try {
|
||||
await startBatch(batch);
|
||||
expect(mutationCount()).toBe(1);
|
||||
await expectBusyControls();
|
||||
} finally {
|
||||
pending.resolve(single === "uninstall" ? [] : { plugin: installed("a") });
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(batches.flatMap((first) => batches.map((second) => ({ first, second }))))("blocks batch $second while batch $first is pending", async ({ first, second }) => {
|
||||
const pending = deferred<unknown>();
|
||||
(first === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise);
|
||||
const running = startBatch(first);
|
||||
try {
|
||||
await startBatch(second);
|
||||
expect(mutationCount()).toBe(1);
|
||||
} finally {
|
||||
pending.resolve(first === "install" ? { plugin: installed("a") } : []);
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(batches)("keeps selections stable and blocks settings mutations during batch %s", async (batch) => {
|
||||
const pending = deferred<unknown>();
|
||||
(batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin).mockReturnValueOnce(pending.promise);
|
||||
const running = startBatch(batch);
|
||||
try {
|
||||
state.toggleBatchMode();
|
||||
state.selectAllUpdatable();
|
||||
state.toggleListingSelection(state.marketplaceListings[1]);
|
||||
state.toggleInstalledSelection("b");
|
||||
expect(state.batchMode).toBe(true);
|
||||
expect([...state.selectedListingKeys]).toEqual(["first:a"]);
|
||||
expect([...state.selectedInstalledIds]).toEqual(["a"]);
|
||||
await state.saveRepository();
|
||||
await state.toggleRepository(catalog("custom", []).repository);
|
||||
await state.removeRepository(catalog("custom", []).repository);
|
||||
await state.saveTrustedKey();
|
||||
await state.removeTrustedKey("custom");
|
||||
for (const mock of [mocks.savePluginRepository, mocks.removePluginRepository, mocks.savePluginTrustedKey, mocks.removePluginTrustedKey]) expect(mock).not.toHaveBeenCalled();
|
||||
await nextTick();
|
||||
for (const key of ["batchDone", "batchSelectAllUpdatable", "addRepository"]) expect(button(key).disabled).toBe(true);
|
||||
} finally {
|
||||
pending.resolve(batch === "install" ? { plugin: installed("a") } : []);
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(batches)("blocks batch %s while repository settings are pending", async (batch) => {
|
||||
const pending = deferred<[]>();
|
||||
mocks.savePluginRepository.mockReturnValueOnce(pending.promise);
|
||||
const running = state.saveRepository();
|
||||
try {
|
||||
await startBatch(batch);
|
||||
expect(mutationCount()).toBe(0);
|
||||
await expectBusyControls();
|
||||
} finally {
|
||||
pending.resolve([]);
|
||||
await running;
|
||||
}
|
||||
await startBatch(batch);
|
||||
expect(mutationCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginContributionsPanel completed batch outcomes", () => {
|
||||
it.each(batches.flatMap((batch) => [0, 1, 3].map((failures) => ({ batch, failures }))))("preserves batch $batch outcomes with $failures failures when refresh rejects", async ({ batch, failures }) => {
|
||||
state.selectAllUpdatable();
|
||||
state.selectedInstalledIds = new Set(["a", "b", "c"]);
|
||||
const mutation = batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin;
|
||||
for (let index = 0; index < failures; index++) mutation.mockRejectedValueOnce(index === 0 ? "denied" : new Error("denied"));
|
||||
const refresh = deferred<InstalledPlugin[]>();
|
||||
mocks.listPlugins.mockReturnValueOnce(refresh.promise);
|
||||
const running = startBatch(batch);
|
||||
await flushUi();
|
||||
const summary = `pluginPlatform.${failures ? "batchSummaryWithFailures" : "batchSummary"}:${JSON.stringify({ success: 3 - failures, failed: failures, names: ["a", "b", "c"].slice(0, failures).join("、") })}`;
|
||||
try {
|
||||
expect(mocks.toast).toHaveBeenLastCalledWith(summary, failures ? 8000 : 4000);
|
||||
expect(state.selectedListingKeys.size).toBe(0);
|
||||
expect(state.selectedInstalledIds.size).toBe(0);
|
||||
expect(state.batchRunning).toBe(true);
|
||||
await state.installMarketplaceListing(state.marketplaceListings[0]);
|
||||
expect(mutationCount()).toBe(3);
|
||||
} finally {
|
||||
refresh.reject(new Error("refresh offline"));
|
||||
await expect(running).resolves.toBeUndefined();
|
||||
}
|
||||
expect(mutation).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast).toHaveBeenLastCalledWith(summary, failures ? 8000 : 4000);
|
||||
expect(state.error).toBe('pluginPlatform.batchRefreshFailed:{"error":"refresh offline"}');
|
||||
await nextTick();
|
||||
expect(host.textContent).toContain(state.error);
|
||||
expect(state.batchRunning).toBe(false);
|
||||
state.selectedListingKeys = new Set(["first:a"]);
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(state.error).toBe("");
|
||||
});
|
||||
|
||||
it.each(batches)("runs batch %s in order, continues after rejection and refreshes once", async (batch) => {
|
||||
state.selectAllUpdatable();
|
||||
state.selectedInstalledIds = new Set(["a", "b", "c"]);
|
||||
const mutation = batch === "install" ? mocks.installMarketplacePlugin : mocks.uninstallPlugin;
|
||||
const first = deferred<unknown>();
|
||||
mutation.mockReturnValueOnce(first.promise).mockRejectedValueOnce(new Error("denied"));
|
||||
const running = startBatch(batch);
|
||||
await flushUi();
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listPlugins).not.toHaveBeenCalled();
|
||||
first.resolve(batch === "install" ? { plugin: installed("a") } : []);
|
||||
await running;
|
||||
expect(mutation.mock.calls.map(([request]) => (batch === "install" ? request.pluginId : request))).toEqual(["a", "b", "c"]);
|
||||
expect(mocks.listPlugins).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.batchSummaryWithFailures:{"success":2,"failed":1,"names":"b"}', 8000);
|
||||
expect(state.error).toBe("");
|
||||
expect(state.batchRunning).toBe(false);
|
||||
expect(state.selectedListingKeys.size).toBe(0);
|
||||
expect(state.selectedInstalledIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does nothing for empty selections or cancelled uninstall", async () => {
|
||||
state.selectedListingKeys = new Set();
|
||||
await state.runBatchInstallUpdate();
|
||||
vi.mocked(window.confirm).mockReturnValueOnce(false);
|
||||
await state.runBatchUninstall();
|
||||
expect(state.selectedInstalledIds.size).toBe(1);
|
||||
state.selectedInstalledIds = new Set();
|
||||
await state.runBatchUninstall();
|
||||
expect(mutationCount()).toBe(0);
|
||||
expect(mocks.listPlugins).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores installed and unsupported listings in single and batch handlers", async () => {
|
||||
state.catalogResults = [catalog("first", ["a"], "1.0.0"), { ...catalog("second", ["b"]), target: "unsupported" }];
|
||||
state.selectedListingKeys = new Set();
|
||||
for (const listing of state.marketplaceListings) {
|
||||
state.toggleListingSelection(listing);
|
||||
await state.installMarketplaceListing(listing);
|
||||
}
|
||||
state.selectAllUpdatable();
|
||||
await state.runBatchInstallUpdate();
|
||||
expect(state.selectedListingKeys.size).toBe(0);
|
||||
expect(mutationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("installs newly selected plugins through the rendered batch button", async () => {
|
||||
state.installedPlugins = [];
|
||||
state.selectedListingKeys = new Set();
|
||||
state.toggleListingSelection(state.marketplaceListings[0]);
|
||||
await nextTick();
|
||||
button("batchInstallUpdate").click();
|
||||
await flushUi();
|
||||
expect(mocks.installMarketplacePlugin).toHaveBeenCalledExactlyOnceWith({ repositoryId: "first", pluginId: "a", version: "3.0.0" });
|
||||
expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.batchSummary:{"success":1,"failed":0,"names":""}', 4000);
|
||||
expect(state.batchRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import az from "@/i18n/locales/az";
|
||||
import en from "@/i18n/locales/en";
|
||||
import es from "@/i18n/locales/es";
|
||||
import itLocale from "@/i18n/locales/it";
|
||||
import ja from "@/i18n/locales/ja";
|
||||
import ko from "@/i18n/locales/ko";
|
||||
import ptBR from "@/i18n/locales/pt-BR";
|
||||
import tr from "@/i18n/locales/tr";
|
||||
import zhCN from "@/i18n/locales/zh-CN";
|
||||
import zhTW from "@/i18n/locales/zh-TW";
|
||||
|
||||
describe("plugin batch messages", () => {
|
||||
it.each(Object.entries({ az, en, es, it: itLocale, ja, ko, "pt-BR": ptBR, tr, "zh-CN": zhCN, "zh-TW": zhTW }))("interpolates source conflicts and refresh errors in %s", (locale, messages) => {
|
||||
const pluginPlatform = messages.pluginPlatform as Pick<typeof en.pluginPlatform, "batchDuplicateSources" | "batchRefreshFailed">;
|
||||
const i18n = createI18n({ legacy: false, locale, messages: { [locale]: { pluginPlatform } } });
|
||||
expect(pluginPlatform.batchDuplicateSources.match(/\{[^}]+\}/g)).toEqual(["{names}"]);
|
||||
expect(pluginPlatform.batchRefreshFailed.match(/\{[^}]+\}/g)).toEqual(["{error}"]);
|
||||
expect(i18n.global.t("pluginPlatform.batchDuplicateSources", { names: "example.plugin" })).toContain("example.plugin");
|
||||
expect(i18n.global.t("pluginPlatform.batchRefreshFailed", { error: "refresh offline" })).toContain("refresh offline");
|
||||
i18n.dispose();
|
||||
});
|
||||
});
|
||||
@@ -257,6 +257,17 @@ export default {
|
||||
uninstallConfirm: "Uninstall {name}?",
|
||||
uninstallSuccess: "Uninstalled {name}",
|
||||
installSuccess: "Installed {name} {version}",
|
||||
batchManage: "Batch",
|
||||
batchDone: "Done",
|
||||
batchSelected: "{count} selected",
|
||||
batchSelectAllUpdatable: "Select all updatable",
|
||||
batchInstallUpdate: "Install / update",
|
||||
batchUninstall: "Uninstall selected",
|
||||
batchUninstallConfirm: "Uninstall {count} plugin(s)?\n{names}",
|
||||
batchSummary: "Done: {success} succeeded",
|
||||
batchSummaryWithFailures: "Done: {success} succeeded, {failed} failed ({names})",
|
||||
batchDuplicateSources: "Multiple sources selected for: {names}. Keep only one source selected per plugin before continuing.",
|
||||
batchRefreshFailed: "The batch has finished, but the plugin list could not be refreshed: {error}",
|
||||
selectConnectionProvider: "Select a connection provider first",
|
||||
connectionProvider: "Connection provider",
|
||||
newConnection: "New",
|
||||
|
||||
@@ -251,6 +251,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "¿Desinstalar {name}?",
|
||||
uninstallSuccess: "Se desinstaló {name}",
|
||||
installSuccess: "Se instaló {name} {version}",
|
||||
batchManage: "Lote",
|
||||
batchDone: "Listo",
|
||||
batchSelected: "{count} seleccionados",
|
||||
batchSelectAllUpdatable: "Seleccionar todos actualizables",
|
||||
batchInstallUpdate: "Instalar / actualizar",
|
||||
batchUninstall: "Desinstalar seleccionados",
|
||||
batchUninstallConfirm: "¿Desinstalar {count} complemento(s)? {names}",
|
||||
batchSummary: "Listo: {success} con éxito",
|
||||
batchSummaryWithFailures: "Listo: {success} con éxito, {failed} con error ({names})",
|
||||
batchDuplicateSources: "Se seleccionaron varias fuentes para: {names}. Mantén seleccionada solo una fuente por complemento antes de continuar.",
|
||||
batchRefreshFailed: "El lote ha finalizado, pero no se pudo actualizar la lista de complementos: {error}",
|
||||
selectConnectionProvider: "Selecciona primero un proveedor de conexión",
|
||||
connectionProvider: "Proveedor de conexión",
|
||||
newConnection: "Nueva",
|
||||
|
||||
@@ -250,6 +250,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "Disinstallare {name}?",
|
||||
uninstallSuccess: "{name} disinstallato",
|
||||
installSuccess: "Installato {name} {version}",
|
||||
batchManage: "Batch",
|
||||
batchDone: "Fine",
|
||||
batchSelected: "{count} selezionati",
|
||||
batchSelectAllUpdatable: "Seleziona tutti aggiornabili",
|
||||
batchInstallUpdate: "Installa / aggiorna",
|
||||
batchUninstall: "Disinstalla selezionati",
|
||||
batchUninstallConfirm: "Disinstallare {count} plug-in? {names}",
|
||||
batchSummary: "Completato: {success} riusciti",
|
||||
batchSummaryWithFailures: "Completato: {success} riusciti, {failed} falliti ({names})",
|
||||
batchDuplicateSources: "Sono state selezionate più fonti per: {names}. Mantieni selezionata una sola fonte per plug-in prima di continuare.",
|
||||
batchRefreshFailed: "Le operazioni in gruppo sono terminate, ma non è stato possibile aggiornare l'elenco dei plug-in: {error}",
|
||||
selectConnectionProvider: "Seleziona prima un provider di connessione",
|
||||
connectionProvider: "Provider di connessione",
|
||||
newConnection: "Nuova",
|
||||
|
||||
@@ -251,6 +251,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "{name} をアンインストールしますか?",
|
||||
uninstallSuccess: "{name} をアンインストールしました",
|
||||
installSuccess: "{name} {version} をインストールしました",
|
||||
batchManage: "一括",
|
||||
batchDone: "完了",
|
||||
batchSelected: "{count} 件選択中",
|
||||
batchSelectAllUpdatable: "更新可能をすべて選択",
|
||||
batchInstallUpdate: "インストール / 更新",
|
||||
batchUninstall: "選択をアンインストール",
|
||||
batchUninstallConfirm: "{count} 個のプラグインをアンインストールしますか? {names}",
|
||||
batchSummary: "完了:{success} 件成功",
|
||||
batchSummaryWithFailures: "完了:{success} 件成功、{failed} 件失敗({names})",
|
||||
batchDuplicateSources: "複数の配布元が選択されています:{names}。続行する前に、各プラグインの配布元を1つだけ選択してください。",
|
||||
batchRefreshFailed: "一括処理は完了しましたが、プラグイン一覧を更新できませんでした:{error}",
|
||||
selectConnectionProvider: "先に接続プロバイダーを選択してください",
|
||||
connectionProvider: "接続プロバイダー",
|
||||
newConnection: "新規",
|
||||
|
||||
@@ -259,6 +259,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "{name}을 제거하시겠습니까?",
|
||||
uninstallSuccess: "{name}을 제거했습니다",
|
||||
installSuccess: "{name} {version}을 설치했습니다",
|
||||
batchManage: "일괄",
|
||||
batchDone: "완료",
|
||||
batchSelected: "{count}개 선택됨",
|
||||
batchSelectAllUpdatable: "업데이트 가능한 항목 모두 선택",
|
||||
batchInstallUpdate: "설치 / 업데이트",
|
||||
batchUninstall: "선택 항목 제거",
|
||||
batchUninstallConfirm: "{count}개 플러그인을 제거할까요? {names}",
|
||||
batchSummary: "완료: {success}개 성공",
|
||||
batchSummaryWithFailures: "완료: {success}개 성공, {failed}개 실패 ({names})",
|
||||
batchDuplicateSources: "여러 소스가 선택되었습니다: {names}. 계속하기 전에 플러그인마다 하나의 소스만 선택해 주세요.",
|
||||
batchRefreshFailed: "일괄 작업은 완료되었지만 플러그인 목록을 새로 고칠 수 없습니다: {error}",
|
||||
selectConnectionProvider: "먼저 연결 제공자를 선택하세요",
|
||||
connectionProvider: "연결 제공자",
|
||||
newConnection: "새 연결",
|
||||
|
||||
@@ -251,6 +251,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "Desinstalar {name}?",
|
||||
uninstallSuccess: "{name} desinstalado",
|
||||
installSuccess: "{name} {version} instalado",
|
||||
batchManage: "Lote",
|
||||
batchDone: "Concluído",
|
||||
batchSelected: "{count} selecionados",
|
||||
batchSelectAllUpdatable: "Selecionar todos atualizáveis",
|
||||
batchInstallUpdate: "Instalar / atualizar",
|
||||
batchUninstall: "Desinstalar selecionados",
|
||||
batchUninstallConfirm: "Desinstalar {count} plugin(s)? {names}",
|
||||
batchSummary: "Concluído: {success} com sucesso",
|
||||
batchSummaryWithFailures: "Concluído: {success} com sucesso, {failed} com falha ({names})",
|
||||
batchDuplicateSources: "Várias fontes selecionadas para: {names}. Mantenha apenas uma fonte selecionada por plugin antes de continuar.",
|
||||
batchRefreshFailed: "O lote foi concluído, mas não foi possível atualizar a lista de plugins: {error}",
|
||||
selectConnectionProvider: "Selecione primeiro um provedor de conexão",
|
||||
connectionProvider: "Provedor de conexão",
|
||||
newConnection: "Nova",
|
||||
|
||||
@@ -173,6 +173,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "卸载 {name}?",
|
||||
uninstallSuccess: "已卸载 {name}",
|
||||
installSuccess: "已安装 {name} {version}",
|
||||
batchManage: "批量管理",
|
||||
batchDone: "完成",
|
||||
batchSelected: "已选 {count} 项",
|
||||
batchSelectAllUpdatable: "全选可更新",
|
||||
batchInstallUpdate: "安装 / 更新",
|
||||
batchUninstall: "卸载所选",
|
||||
batchUninstallConfirm: "确定卸载 {count} 个插件?\n{names}",
|
||||
batchSummary: "完成:成功 {success} 个",
|
||||
batchSummaryWithFailures: "完成:成功 {success} 个,失败 {failed} 个({names})",
|
||||
batchDuplicateSources: "以下插件选择了多个来源:{names}。请为每个插件仅保留一个来源后继续。",
|
||||
batchRefreshFailed: "批量操作已完成,但插件列表刷新失败:{error}",
|
||||
selectConnectionProvider: "请先选择连接提供者",
|
||||
connectionProvider: "连接提供者",
|
||||
newConnection: "新建",
|
||||
|
||||
@@ -251,6 +251,17 @@ export default withEnglishFallback({
|
||||
uninstallConfirm: "解除安裝 {name}?",
|
||||
uninstallSuccess: "已解除安裝 {name}",
|
||||
installSuccess: "已安裝 {name} {version}",
|
||||
batchManage: "批次管理",
|
||||
batchDone: "完成",
|
||||
batchSelected: "已選 {count} 項",
|
||||
batchSelectAllUpdatable: "全選可更新",
|
||||
batchInstallUpdate: "安裝 / 更新",
|
||||
batchUninstall: "解除安裝所選",
|
||||
batchUninstallConfirm: "確定解除安裝 {count} 個外掛? {names}",
|
||||
batchSummary: "完成:成功 {success} 個",
|
||||
batchSummaryWithFailures: "完成:成功 {success} 個,失敗 {failed} 個({names})",
|
||||
batchDuplicateSources: "以下外掛選取了多個來源:{names}。請為每個外掛僅保留一個來源後繼續。",
|
||||
batchRefreshFailed: "批次操作已完成,但外掛清單重新整理失敗:{error}",
|
||||
selectConnectionProvider: "請先選擇連線提供者",
|
||||
connectionProvider: "連線提供者",
|
||||
newConnection: "新增",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch";
|
||||
|
||||
describe("runBatch", () => {
|
||||
it("runs items sequentially and records all successes", async () => {
|
||||
const order: string[] = [];
|
||||
const outcome = await runBatch(
|
||||
[{ id: "a" }, { id: "b" }, { id: "c" }],
|
||||
(item) => item.id,
|
||||
async (item) => {
|
||||
order.push(item.id);
|
||||
},
|
||||
);
|
||||
expect(order).toEqual(["a", "b", "c"]);
|
||||
expect(outcome.succeeded).toEqual(["a", "b", "c"]);
|
||||
expect(outcome.failed).toEqual([]);
|
||||
});
|
||||
|
||||
it("captures a failing item, continues, and does not roll back", async () => {
|
||||
const ran: string[] = [];
|
||||
const outcome = await runBatch(
|
||||
[{ id: "a" }, { id: "b" }, { id: "c" }],
|
||||
(item) => item.id,
|
||||
async (item) => {
|
||||
ran.push(item.id);
|
||||
if (item.id === "b") throw new Error("boom");
|
||||
},
|
||||
);
|
||||
expect(ran).toEqual(["a", "b", "c"]);
|
||||
expect(outcome.succeeded).toEqual(["a", "c"]);
|
||||
expect(outcome.failed).toEqual([{ name: "b", error: "boom" }]);
|
||||
});
|
||||
|
||||
it("returns an empty outcome for empty input", async () => {
|
||||
const action = vi.fn();
|
||||
const outcome = await runBatch([], (item: { id: string }) => item.id, action);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
expect(outcome.succeeded).toEqual([]);
|
||||
expect(outcome.failed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBatchSelectableListing", () => {
|
||||
it("allows install and update, blocks installed and unsupported", () => {
|
||||
expect(isBatchSelectableListing("install")).toBe(true);
|
||||
expect(isBatchSelectableListing("update")).toBe(true);
|
||||
expect(isBatchSelectableListing("installed")).toBe(false);
|
||||
expect(isBatchSelectableListing("unsupported")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Pure, testable helpers for batch plugin operations (install / update / uninstall).
|
||||
// The component owns selection state and UI; this module owns the sequential execution
|
||||
// and result aggregation so partial failures can be reported without aborting the batch.
|
||||
|
||||
export interface BatchItemOutcome {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BatchOutcome {
|
||||
results: BatchItemOutcome[];
|
||||
succeeded: string[];
|
||||
failed: { name: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `action` over `items` sequentially, recording per-item success/failure. A failing item is
|
||||
* captured and the batch continues; nothing is rolled back. `nameOf` supplies a human label used
|
||||
* in the summary. Empty input yields an empty outcome.
|
||||
*/
|
||||
export async function runBatch<T>(items: readonly T[], nameOf: (item: T) => string, action: (item: T) => Promise<void>): Promise<BatchOutcome> {
|
||||
const results: BatchItemOutcome[] = [];
|
||||
for (const item of items) {
|
||||
const name = nameOf(item);
|
||||
try {
|
||||
await action(item);
|
||||
results.push({ name, ok: true });
|
||||
} catch (cause) {
|
||||
results.push({ name, ok: false, error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
}
|
||||
return {
|
||||
results,
|
||||
succeeded: results.filter((result) => result.ok).map((result) => result.name),
|
||||
failed: results.filter((result) => !result.ok).map((result) => ({ name: result.name, error: result.error ?? "" })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which marketplace listing statuses are actionable in a batch (a selectable plugin that is not yet
|
||||
* installed, or has an update). Installed / unsupported listings are not batch-selectable.
|
||||
*/
|
||||
export function isBatchSelectableListing(status: string): boolean {
|
||||
return status === "install" || status === "update";
|
||||
}
|
||||
Reference in New Issue
Block a user