feat(plugins): add marketplace details and workbench launch

This commit is contained in:
二丫讲梵
2026-10-11 23:31:22 +08:00
committed by GitHub
parent b3fc86ac3b
commit f73114a381
16 changed files with 984 additions and 33 deletions
+10 -1
View File
@@ -4925,7 +4925,16 @@ onUnmounted(() => {
:focus-target="driverStoreFocus"
@update-count-change="updateAgentDriverUpdateCount"
/>
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" :install-url-request="pluginCenterInstallRequest" @new-connection="openPluginConnectionDialog" @plugin-runtime-replaced="refreshPluginWorkbenches" />
<PluginCenterPage
v-if="pluginCenterTabOpen"
v-show="pluginCenterActive"
class="flex-1 min-h-0"
:focus-target="pluginCenterFocus"
:install-url-request="pluginCenterInstallRequest"
@opened="activateMainContentSurface('query')"
@new-connection="openPluginConnectionDialog"
@plugin-runtime-replaced="refreshPluginWorkbenches"
/>
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, watch, TransitionGroup } from "vue";
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch, TransitionGroup } from "vue";
import { ArrowUp, BadgeCheck, Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, Info, LayoutGrid, Link2, List, Loader2, PackageCheck, Pencil, Pin, PinOff, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2, X } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { isSensitivePluginPermission } from "@/lib/plugins/pluginPermissions";
@@ -22,14 +22,15 @@ import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { collectPluginShortcuts, type PluginShortcutEntry } from "@/lib/plugins/pluginShortcuts";
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
import {
beaconPluginInstall,
buildInstalledUpdateIndex,
buildMarketplacePluginListings,
compareVersions,
filterMarketplacePluginListings,
formatMarketplaceReleasedDate,
listingRepositoryCanVerify,
marketplaceHomepageUrl,
pluginSourceChange,
sortMarketplacePluginListings,
@@ -57,6 +58,7 @@ const props = defineProps<{
const emit = defineEmits<{
newConnection: [pluginId: string, providerId: string];
opened: [];
pluginRuntimeReplaced: [pluginId: string];
}>();
@@ -146,7 +148,10 @@ const repositoryId = ref("");
const repositoryName = ref("");
const repositoryCatalogUrl = ref("");
const marketplaceQuery = ref("");
const marketplacePublisher = ref("");
const marketplaceRepositoryId = ref("all");
const marketplaceDetailKey = ref("");
const marketplaceDetailOpen = ref(false);
const marketplaceViewMode = ref<"grid" | "list">(safeLocalStorageGet(MARKETPLACE_VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "grid");
const MARKETPLACE_SORT_MODES: MarketplacePluginSortMode[] = ["name", "recently-updated", "recently-listed", "updates-first"];
// "name" stays the default: the batch specs select listings positionally, so a persisted
@@ -191,9 +196,86 @@ 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 filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value).filter((listing) => !marketplacePublisher.value || listing.plugin.publisher === marketplacePublisher.value));
// Render order only: batch selection and update execution keep the builder's name order.
const sortedMarketplaceListings = computed(() => sortMarketplacePluginListings(filteredMarketplaceListings.value, marketplaceSortMode.value));
const selectedMarketplaceListing = computed(() => marketplaceListings.value.find((listing) => listing.key === marketplaceDetailKey.value) || null);
const detailOpening = ref(false);
const detailChooseEntry = ref(false);
const detailChooseConnection = ref(false);
let detailSessionGeneration = 0;
const detailShortcuts = computed(() => collectPluginShortcuts(registry.value, true).filter((entry) => entry.pluginId === selectedMarketplaceListing.value?.installed?.manifest.id && entry.command?.action.presentation !== "panel"));
const detailProviders = computed(() => {
const installed = selectedMarketplaceListing.value?.installed;
if (!installed?.compatibility.compatible) return [];
return registry.value.listConnectionProviders().filter((entry) => entry.plugin.manifest.id === installed.manifest.id && (entry.contribution.workbench || entry.contribution.filesystem_provider));
});
const detailConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "plugin" && detailProviders.value.some((entry) => entry.plugin.manifest.id === connection.plugin_id && entry.contribution.id === connection.plugin_connection_provider)));
const detailCanOpen = computed(() => detailShortcuts.value.length > 0 || detailProviders.value.length > 0);
async function launchDetailEntry(entry: PluginShortcutEntry | string) {
if (detailOpening.value || mutationRunning.value) return;
detailOpening.value = true;
const detailKey = marketplaceDetailKey.value;
// Close the modal before connecting so password prompts can receive focus.
closeMarketplaceDetails("none");
const detailSession = detailSessionGeneration;
await nextTick();
try {
if (typeof entry === "string") {
if (!detailConnections.value.some((connection) => connection.id === entry)) return;
await queryStore.openPluginConnection(entry);
} else {
const current = detailShortcuts.value.find((item) => item.id === entry.id);
if (!current || current.disabled) return;
if (current.kind === "command") {
const result = executePluginCommand(registry.value, queryStore, current.pluginId, current.targetId);
if (result.error) throw new Error(result.error);
} else if (current.kind === "workbench") {
queryStore.openPluginWorkbench(current.pluginId, current.targetId, { title: current.label });
} else {
queryStore.openPluginFilesystem(current.pluginId, current.targetId, { title: current.label, rootUri: current.rootUri });
}
}
emit("opened");
} catch (cause) {
toast(translateBackendError(t, cause), 8000);
if (detailSessionGeneration === detailSession && marketplaceDetailKey.value === detailKey && selectedMarketplaceListing.value && activeSection.value === "marketplace") {
detailCloseFocus = "trigger";
marketplaceDetailOpen.value = true;
}
} finally {
detailOpening.value = false;
}
}
function createDetailConnection(providerId: string) {
const entry = detailProviders.value.find((item) => item.contribution.id === providerId);
if (!entry || mutationRunning.value || detailOpening.value) return;
closeMarketplaceDetails("none");
emit("newConnection", entry.plugin.manifest.id, providerId);
}
const detailOpenLabel = computed(() => {
if (detailShortcuts.value.length) return detailShortcuts.value.length === 1 ? "openWorkbench" : "chooseWorkbench";
if (!detailConnections.value.length) return "newConnection";
return detailConnections.value.length === 1 ? "openWorkbench" : "chooseConnection";
});
function openDetailPlugin() {
if (detailShortcuts.value.length) {
if (detailShortcuts.value.length === 1) {
if (!detailShortcuts.value[0].disabled) void launchDetailEntry(detailShortcuts.value[0]);
} else detailChooseEntry.value = !detailChooseEntry.value;
return;
}
if (detailConnections.value.length === 1) void launchDetailEntry(detailConnections.value[0].id);
else if (!detailConnections.value.length && detailProviders.value.length === 1) createDetailConnection(detailProviders.value[0].contribution.id);
else detailChooseConnection.value = !detailChooseConnection.value;
}
const selectedMarketplaceVersions = computed(() => {
const versions = selectedMarketplaceListing.value?.plugin.versions || [];
return [...versions].sort((left, right) => compareVersions(right.version, left.version));
});
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)));
@@ -243,6 +325,53 @@ function marketplaceActionClass(listing: MarketplacePluginListing): string {
return "cursor-default bg-muted text-muted-foreground";
}
let detailTrigger: HTMLElement | null = null;
let detailCloseFocus: "trigger" | "search" | "none" = "trigger";
function openMarketplaceDetails(listing: MarketplacePluginListing, event?: Event): void {
detailSessionGeneration += 1;
detailTrigger = event?.currentTarget instanceof HTMLElement ? event.currentTarget : document.activeElement instanceof HTMLElement ? document.activeElement : null;
detailCloseFocus = "trigger";
detailChooseEntry.value = false;
detailChooseConnection.value = false;
marketplaceDetailKey.value = listing.key;
marketplaceDetailOpen.value = true;
}
function filterByPublisher(publisher: string): void {
marketplacePublisher.value = publisher;
marketplaceQuery.value = "";
marketplaceRepositoryId.value = "all";
activeSection.value = "marketplace";
if (marketplaceDetailOpen.value) closeMarketplaceDetails("search");
else void nextTick(focusMarketplaceSearch);
}
function focusMarketplaceSearch(): void {
panelRootRef.value?.querySelector<HTMLInputElement>("[data-plugin-marketplace-search]")?.focus({ preventScroll: true });
}
function closeMarketplaceDetails(focus: "trigger" | "search" | "none" = "trigger"): void {
detailSessionGeneration += 1;
detailCloseFocus = focus;
marketplaceDetailOpen.value = false;
}
function restoreMarketplaceDetailFocus(event: Event): void {
// There is no DialogTrigger: cards open this shared dialog programmatically.
event.preventDefault();
if (detailCloseFocus === "none" || marketplaceDetailOpen.value) return;
if (detailCloseFocus === "trigger" && detailTrigger?.isConnected) detailTrigger.focus({ preventScroll: true });
else focusMarketplaceSearch();
}
function handleMarketplaceCardKeydown(event: KeyboardEvent, listing: MarketplacePluginListing): void {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openMarketplaceDetails(listing, event);
}
// Badge row budget: tags first, then permissions, then one trailing "+N" counter. The row must
// stay single-line; everything past the limit rides in the "+N" tooltip, and when the overflow
// hides a sensitive permission the "+N" badge itself takes the destructive variant so the risk
@@ -1067,6 +1196,13 @@ async function uninstallSelectedPlugin() {
watch(connectionProviders, (providers) => {
if (!providers.some((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value)) selectFirstProvider(selectedPluginId.value);
});
watch(
activeSection,
() => {
detailSessionGeneration += 1;
},
{ flush: "sync" },
);
watch(providerConnections, (connections) => {
if (selectedConnectionId.value && connections.some((connection) => connection.id === selectedConnectionId.value)) return;
selectedConnectionId.value = connections[0]?.id || "";
@@ -1108,6 +1244,7 @@ onMounted(() => {
watch(marketplaceViewMode, (mode) => safeLocalStorageSet(MARKETPLACE_VIEW_MODE_STORAGE_KEY, mode));
watch(marketplaceSortMode, (mode) => safeLocalStorageSet(MARKETPLACE_SORT_MODE_STORAGE_KEY, mode));
onBeforeUnmount(() => {
detailSessionGeneration += 1;
window.clearTimeout(recentlyCompletedTimer);
window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated);
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
@@ -1145,19 +1282,35 @@ onBeforeUnmount(() => {
<span class="mx-1.5 text-border">·</span>{{ t("pluginPlatform.marketplaceGuideDescription") }}
</div>
</div>
<!-- 吸顶:矮窗滚动时搜索/排序控件始终可达。
行/列切换按「面板宽度」(@container) 而非视口断点:窗口 minWidth=900 时 sm: 恒为真,
而分屏/侧栏挤压下的商店面板可远窄于窗口,视口断点会让整行溢出、批量管理/刷新被推出可视区(#10582)。
54rem(容器 864px) 高于实测下限(容器 781–800px:控制簇 580px + 搜索框 180–199px + 内边距);
flex-wrap 则兜住比 54rem 更窄时的极端情况。 -->
<div data-plugin-marketplace-toolbar class="sticky top-0 z-20 flex w-full flex-col flex-wrap gap-2 rounded-xl border bg-background/95 p-3 backdrop-blur-sm @min-[54rem]:flex-row @min-[54rem]:items-center">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input data-plugin-marketplace-search v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs @min-[54rem]:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
<!-- Search and controls share a row whenever the panel has room; narrow panes wrap naturally. -->
<div data-plugin-marketplace-toolbar class="sticky top-0 z-20 flex w-full flex-wrap items-center gap-2 rounded-xl border bg-background/95 p-3 backdrop-blur-sm">
<div class="flex h-8 min-w-0 basis-64 flex-1 items-center gap-1.5 rounded-md border bg-background px-2.5 focus-within:ring-2 focus-within:ring-ring">
<Search class="size-3.5 shrink-0 text-muted-foreground" />
<Input
data-plugin-marketplace-search
v-model="marketplaceQuery"
class="h-7 min-w-0 flex-1 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
:aria-label="t('pluginPlatform.searchMarketplace')"
:placeholder="t('pluginPlatform.searchMarketplaceCount', { count: marketplaceLoading || marketplaceUnavailable ? '—' : marketplaceListings.length })"
/>
<Button v-if="marketplacePublisher" variant="secondary" size="sm" class="h-6 max-w-24 shrink-0 gap-1 px-1.5 text-xs" :title="marketplacePublisher" :aria-label="t('common.clear') + ': ' + marketplacePublisher" @click="marketplacePublisher = ''">
<span class="truncate">{{ marketplacePublisher }}</span
><X class="size-3 shrink-0" />
</Button>
<span
v-if="marketplaceQuery || marketplacePublisher || marketplaceRepositoryId !== 'all'"
class="shrink-0 text-[10px] tabular-nums text-muted-foreground"
aria-live="polite"
:aria-label="
t('pluginPlatform.marketplaceFiltered', { count: marketplaceLoading || marketplaceUnavailable ? '—' : sortedMarketplaceListings.length }) + ' · ' + t('pluginPlatform.marketplaceTotal', { count: marketplaceLoading || marketplaceUnavailable ? '—' : marketplaceListings.length })
"
>
{{ marketplaceLoading || marketplaceUnavailable ? "—" : sortedMarketplaceListings.length }} / {{ marketplaceLoading || marketplaceUnavailable ? "—" : marketplaceListings.length }}
</span>
</div>
<div data-plugin-marketplace-controls class="flex min-w-0 flex-wrap items-center gap-2 @min-[54rem]:ml-auto">
<div data-plugin-marketplace-controls class="flex min-w-0 flex-wrap items-center justify-end gap-2">
<Select v-model="marketplaceSortMode">
<SelectTrigger data-plugin-marketplace-sort class="h-8 min-w-0 flex-1 text-xs @min-[54rem]:w-40 @min-[54rem]:flex-none" :aria-label="t('pluginPlatform.sortBy')"><SelectValue /></SelectTrigger>
<SelectTrigger data-plugin-marketplace-sort class="h-8 min-w-0 flex-1 text-xs @min-[54rem]:w-32 @min-[54rem]:flex-none" :aria-label="t('pluginPlatform.sortBy')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="name">{{ t("pluginPlatform.sortByName") }}</SelectItem>
<SelectItem value="recently-updated">{{ t("pluginPlatform.sortByRecentlyUpdated") }}</SelectItem>
@@ -1166,7 +1319,7 @@ onBeforeUnmount(() => {
</SelectContent>
</Select>
<Select v-model="marketplaceRepositoryId">
<SelectTrigger data-plugin-marketplace-repository class="h-8 min-w-0 flex-1 text-xs @min-[54rem]:w-52 @min-[54rem]:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
<SelectTrigger data-plugin-marketplace-repository class="h-8 min-w-0 flex-1 text-xs @min-[54rem]:w-36 @min-[54rem]: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>
@@ -1222,14 +1375,26 @@ onBeforeUnmount(() => {
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
</div>
<TransitionGroup v-else-if="marketplaceViewMode === 'grid'" name="marketplace-cards" tag="div" class="grid w-full grid-cols-[repeat(auto-fill,minmax(min(100%,19rem),1fr))] gap-3">
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="group marketplace-card flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
<article
v-for="listing in sortedMarketplaceListings"
:key="listing.key"
class="group marketplace-card flex min-w-0 min-h-48 cursor-pointer flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button"
tabindex="0"
:aria-label="t('pluginPlatform.openPluginDetails', { name: listing.name })"
@click="openMarketplaceDetails(listing, $event)"
@keydown="handleMarketplaceCardKeydown($event, listing)"
>
<div class="flex min-w-0 items-start gap-3">
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-10 shrink-0 rounded-lg border bg-background p-1.5" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold" :title="listing.name">{{ listing.name }}</div>
<div class="mt-1 flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<BadgeCheck v-if="listingRepositoryCanVerify(listing.repository)" class="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" :title="t('pluginPlatform.verified')" :aria-label="t('pluginPlatform.verified')" />
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
<BadgeCheck v-if="listing.verified" class="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" :title="t('pluginPlatform.verified')" :aria-label="t('pluginPlatform.verified')" />
<span class="min-w-0 truncate"
><button type="button" class="rounded-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click.stop="filterByPublisher(listing.plugin.publisher)">{{ listing.plugin.publisher }}</button> ·
{{ listing.repository.name }}</span
>
</div>
</div>
<div class="flex shrink-0 items-center gap-0.5">
@@ -1316,7 +1481,7 @@ onBeforeUnmount(() => {
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
:class="marketplaceActionClass(listing)"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning"
@click="installMarketplaceListing(listing)"
@click.stop="installMarketplaceListing(listing)"
>
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
<Check v-else-if="listing.status === 'installed' && recentlyCompletedKey === listing.key" class="marketplace-check-pop size-3.5" />
@@ -1326,7 +1491,16 @@ onBeforeUnmount(() => {
</article>
</TransitionGroup>
<TransitionGroup v-else name="marketplace-cards" tag="div" class="flex w-full flex-col gap-2">
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="flex min-w-0 flex-wrap items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
<article
v-for="listing in sortedMarketplaceListings"
:key="listing.key"
class="flex min-w-0 cursor-pointer flex-wrap items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button"
tabindex="0"
:aria-label="t('pluginPlatform.openPluginDetails', { name: listing.name })"
@click="openMarketplaceDetails(listing, $event)"
@keydown="handleMarketplaceCardKeydown($event, listing)"
>
<button
v-if="batchMode && isBatchSelectableListing(listing.status)"
type="button"
@@ -1343,10 +1517,13 @@ onBeforeUnmount(() => {
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1">
<span class="truncate text-sm font-semibold" :title="listing.name">{{ listing.name }}</span>
<BadgeCheck v-if="listingRepositoryCanVerify(listing.repository)" class="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" :title="t('pluginPlatform.verified')" :aria-label="t('pluginPlatform.verified')" />
<BadgeCheck v-if="listing.verified" class="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" :title="t('pluginPlatform.verified')" :aria-label="t('pluginPlatform.verified')" />
</div>
<div class="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
<span class="min-w-0 truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
<span class="min-w-0 truncate"
><button type="button" class="rounded-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click.stop="filterByPublisher(listing.plugin.publisher)">{{ listing.plugin.publisher }}</button> ·
{{ listing.repository.name }}</span
>
<span class="shrink-0">· v{{ listing.plugin.latestVersion }}</span>
<span v-if="listing.latestVersionReleasedAt" class="shrink-0">· {{ formatMarketplaceReleasedDate(listing.latestVersionReleasedAt, appLocale) }}</span>
<button
@@ -1391,7 +1568,7 @@ onBeforeUnmount(() => {
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
:class="marketplaceActionClass(listing)"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || mutationRunning"
@click="installMarketplaceListing(listing)"
@click.stop="installMarketplaceListing(listing)"
>
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
<Check v-else-if="listing.status === 'installed' && recentlyCompletedKey === listing.key" class="marketplace-check-pop size-3.5" />
@@ -1761,6 +1938,163 @@ onBeforeUnmount(() => {
</TabsContent>
</Tabs>
<Dialog :open="marketplaceDetailOpen" @update:open="(open: boolean) => (open ? (marketplaceDetailOpen = true) : closeMarketplaceDetails())">
<DialogContent class="max-h-[min(90vh,52rem)] max-w-3xl gap-0 overflow-hidden p-0" @close-auto-focus="restoreMarketplaceDetailFocus">
<template v-if="selectedMarketplaceListing">
<DialogHeader class="@container border-b px-6 py-3 pr-14 text-left">
<div class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-4 @min-[34rem]:grid-cols-[auto_minmax(0,1fr)_auto]">
<PluginIcon :plugin-id="selectedMarketplaceListing.plugin.id" :icon="selectedMarketplaceListing.plugin.icon" class="size-12 shrink-0 rounded-xl border bg-background p-2" />
<div class="min-w-0 flex-1">
<DialogTitle class="flex min-w-0 items-baseline gap-2 text-base">
<span class="truncate">{{ selectedMarketplaceListing.name }}</span>
<span class="shrink-0 text-xs font-normal text-muted-foreground">v{{ selectedMarketplaceListing.installed?.manifest.version || selectedMarketplaceListing.plugin.latestVersion }}</span>
</DialogTitle>
<DialogDescription class="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
<BadgeCheck v-if="selectedMarketplaceListing.verified" class="size-3.5 text-emerald-600 dark:text-emerald-400" :title="t('pluginPlatform.verified')" />
<button type="button" class="rounded-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @click.stop="filterByPublisher(selectedMarketplaceListing.plugin.publisher)">
{{ selectedMarketplaceListing.plugin.publisher }}
</button>
<span aria-hidden="true">·</span>
<span>{{ selectedMarketplaceListing.repository.name }}</span>
<span class="font-mono text-[10px]">{{ selectedMarketplaceListing.plugin.id }}</span>
</DialogDescription>
</div>
<div class="col-span-2 flex flex-wrap items-center gap-2 @min-[34rem]:col-span-1 @min-[34rem]:justify-end">
<Button v-if="detailCanOpen" size="sm" class="h-8 shrink-0 gap-1.5 rounded-md px-3 text-xs" :disabled="detailOpening || mutationRunning || (detailShortcuts.length === 1 && detailShortcuts[0].disabled)" @click="openDetailPlugin">
<ExternalLink class="size-3.5" />{{ t("pluginPlatform." + detailOpenLabel) }}
</Button>
<Button v-if="detailProviders.length && (detailShortcuts.length || detailConnections.length === 1)" variant="outline" size="sm" class="h-8 shrink-0 rounded-md px-3 text-xs" :disabled="detailOpening || mutationRunning" @click="detailChooseConnection = !detailChooseConnection">{{
t("pluginPlatform.chooseConnection")
}}</Button>
<Button
v-if="selectedMarketplaceListing.status !== 'installed'"
size="sm"
class="h-8 shrink-0 gap-1.5 rounded-md px-3 text-xs"
:class="marketplaceActionClass(selectedMarketplaceListing)"
:disabled="selectedMarketplaceListing.status === 'unsupported' || mutationRunning"
@click.stop="installMarketplaceListing(selectedMarketplaceListing)"
>
<Loader2 v-if="marketplaceInstallingKey === selectedMarketplaceListing.key" class="size-3.5 animate-spin" />
{{ t(`pluginPlatform.marketplaceStatus.${selectedMarketplaceListing.status}`) }}
</Button>
</div>
</div>
</DialogHeader>
<div class="min-h-0 overflow-y-auto px-6 py-4">
<div v-if="detailChooseEntry" class="mb-4 flex flex-col gap-2 rounded-lg border p-3">
<Button v-for="entry in detailShortcuts" :key="entry.id" variant="outline" class="justify-start" :disabled="entry.disabled || detailOpening || mutationRunning" @click="launchDetailEntry(entry)">{{ entry.label }}</Button>
</div>
<div v-if="detailChooseConnection" class="mb-4 flex flex-col gap-2 rounded-lg border p-3">
<div class="text-sm font-medium">{{ t("pluginPlatform.chooseConnection") }}</div>
<Button v-for="connection in detailConnections" :key="connection.id" variant="outline" class="justify-start" :disabled="detailOpening || mutationRunning" @click="launchDetailEntry(connection.id)">{{ connection.name }}</Button>
<Button v-for="entry in detailProviders" :key="entry.contribution.id" variant="outline" class="justify-start" :disabled="detailOpening || mutationRunning" @click="createDetailConnection(entry.contribution.id)"
><Plus class="size-3.5" />{{ t("pluginPlatform.newConnection") }} · {{ entry.contribution.label }}</Button
>
</div>
<Tabs default-value="overview" class="w-full">
<TabsList class="mb-4 grid h-auto w-full grid-cols-4">
<TabsTrigger value="overview" class="text-xs">{{ t("pluginPlatform.pluginDetailsOverview") }}</TabsTrigger>
<TabsTrigger value="updates" class="text-xs">{{ t("pluginPlatform.pluginDetailsUpdates") }}</TabsTrigger>
<TabsTrigger value="permissions" class="text-xs">{{ t("pluginPlatform.pluginDetailsPermissions") }}</TabsTrigger>
<TabsTrigger value="repository" class="text-xs">{{ t("pluginPlatform.pluginDetailsRepository") }}</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-0 space-y-4">
<section class="space-y-2">
<h3 class="text-sm font-semibold">{{ t("pluginPlatform.pluginDetailsIntroduction") }}</h3>
<p class="whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{{ selectedMarketplaceListing.description || t("pluginPlatform.noDescription") }}</p>
</section>
<div class="grid gap-3 text-xs @min-[40rem]:grid-cols-3">
<div class="rounded-lg border p-3">
<div class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsVersion") }}</div>
<div class="mt-1 font-medium">v{{ selectedMarketplaceListing.plugin.latestVersion }}</div>
</div>
<div class="rounded-lg border p-3">
<div class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsLicense") }}</div>
<div class="mt-1 font-medium">{{ selectedMarketplaceListing.plugin.license || t("pluginPlatform.licenseUnknown") }}</div>
</div>
<div class="rounded-lg border p-3">
<div class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsReleaseDate") }}</div>
<div class="mt-1 font-medium">{{ formatMarketplaceReleasedDate(selectedMarketplaceListing.latestVersionReleasedAt, appLocale) || "—" }}</div>
</div>
</div>
<div v-if="selectedMarketplaceListing.plugin.tags.length" class="flex flex-wrap gap-1.5">
<Badge v-for="tag in selectedMarketplaceListing.plugin.tags" :key="tag" variant="outline" class="text-[11px]">{{ tag }}</Badge>
</div>
<div class="flex flex-wrap gap-2">
<Button v-if="selectedMarketplaceListing.plugin.source" variant="outline" size="sm" class="h-8 gap-1.5 text-xs" @click.stop="openExternal(selectedMarketplaceListing.plugin.source)"><GithubIcon />{{ t("pluginPlatform.sourceRepository") }}</Button>
<Button
v-if="marketplaceHomepageUrl(selectedMarketplaceListing.plugin.source, selectedMarketplaceListing.plugin.homepage)"
variant="outline"
size="sm"
class="h-8 gap-1.5 text-xs"
@click.stop="openExternal(marketplaceHomepageUrl(selectedMarketplaceListing.plugin.source, selectedMarketplaceListing.plugin.homepage))"
><Globe class="size-3.5" />{{ t("pluginPlatform.pluginHomepage") }}</Button
>
</div>
</TabsContent>
<TabsContent value="updates" class="mt-0 space-y-3">
<div v-if="!selectedMarketplaceVersions.length" class="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground">{{ t("pluginPlatform.noVersionHistory") }}</div>
<div v-for="version in selectedMarketplaceVersions" :key="version.version" class="rounded-lg border p-3">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-medium">v{{ version.version }}</span
><span class="text-xs text-muted-foreground">{{ formatMarketplaceReleasedDate(version.releasedAt, appLocale) || "—" }}</span>
</div>
<p class="mt-2 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">{{ version.releaseNotes || t("pluginPlatform.noReleaseNotes") }}</p>
<div v-if="version.artifacts.length" class="mt-2 flex flex-wrap gap-1.5">
<Badge v-for="artifact in version.artifacts" :key="artifact.target" variant="secondary" class="font-mono text-[10px]">{{ artifact.target }}</Badge>
</div>
</div>
</TabsContent>
<TabsContent value="permissions" class="mt-0 space-y-4">
<section class="space-y-2">
<h3 class="text-sm font-semibold">{{ t("pluginPlatform.pluginDetailsPermissions") }}</h3>
<div v-if="selectedMarketplaceListing.plugin.permissions.length" class="flex flex-wrap gap-1.5">
<Badge v-for="permission in selectedMarketplaceListing.plugin.permissions" :key="permission" :variant="isSensitivePluginPermission(permission) ? 'destructive' : 'outline'" class="font-mono text-[10px]">{{ permission }}</Badge>
</div>
<p v-else class="text-xs text-muted-foreground">{{ t("pluginPlatform.noPermissions") }}</p>
</section>
<section class="space-y-2">
<h3 class="text-sm font-semibold">{{ t("pluginPlatform.pluginDetailsCompatibility") }}</h3>
<div class="rounded-lg border p-3 text-xs">
<div class="flex justify-between gap-3">
<span class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsCurrentTarget") }}</span
><span class="font-mono">{{ selectedMarketplaceListing.target }}</span>
</div>
<div class="mt-2 flex justify-between gap-3">
<span class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsStatus") }}</span
><span>{{ t(`pluginPlatform.marketplaceStatus.${selectedMarketplaceListing.status}`) }}</span>
</div>
</div>
</section>
</TabsContent>
<TabsContent value="repository" class="mt-0 space-y-3">
<dl class="divide-y rounded-lg border text-xs">
<div class="flex justify-between gap-3 px-3 py-2.5">
<dt class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsRepository") }}</dt>
<dd class="font-medium">{{ selectedMarketplaceListing.repository.name }}</dd>
</div>
<div class="flex justify-between gap-3 px-3 py-2.5">
<dt class="text-muted-foreground">{{ t("pluginPlatform.pluginDetailsPublisher") }}</dt>
<dd>{{ selectedMarketplaceListing.plugin.publisher }}</dd>
</div>
<div class="flex justify-between gap-3 px-3 py-2.5">
<dt class="text-muted-foreground">{{ t("pluginPlatform.verified") }}</dt>
<dd>{{ selectedMarketplaceListing.verified ? t("pluginPlatform.verified") : t("pluginPlatform.unverified") }}</dd>
</div>
</dl>
<Button v-if="selectedMarketplaceListing.repositoryHomepage" variant="outline" size="sm" class="h-8 gap-1.5 text-xs" @click.stop="openExternal(selectedMarketplaceListing.repositoryHomepage)"><Globe class="size-3.5" />{{ t("pluginPlatform.pluginDetailsRepositoryHomepage") }}</Button>
</TabsContent>
</Tabs>
</div>
</template>
</DialogContent>
</Dialog>
<Dialog
:open="!!pendingSourceChange"
@update:open="
@@ -7,6 +7,10 @@ import { formatMarketplaceReleasedDate, type MarketplacePluginListing } from "@/
import { COMPONENT_PLUGINS_UPDATED_EVENT, COMPONENT_UPDATES_CHANGED_EVENT } from "@/lib/updates/componentUpdateEvents";
const mocks = vi.hoisted(() => ({
connections: [] as import("@/types/database").ConnectionConfig[],
newConnection: vi.fn(),
openPluginConnection: vi.fn(),
openPluginWorkbench: vi.fn(),
listPlugins: vi.fn(),
listPluginTrustedKeys: vi.fn(),
listPluginRepositories: vi.fn(),
@@ -28,8 +32,8 @@ const mocks = vi.hoisted(() => ({
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("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: mocks.connections }) }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench, openPluginConnection: mocks.openPluginConnection }) }));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({ editorSettings: { pluginGraphicsEngineIds: [] }, updateEditorSettings: vi.fn() }),
}));
@@ -72,11 +76,27 @@ import PluginContributionsPanel from "@/components/plugins/PluginContributionsPa
type PanelState = {
activeSection: "marketplace" | "installed" | "settings";
detailCanOpen: boolean;
detailChooseEntry: boolean;
detailChooseConnection: boolean;
detailOpenLabel: string;
detailOpening: boolean;
marketplaceDetailOpen: boolean;
selectedMarketplaceListing: MarketplacePluginListing | null;
detailShortcuts: import("@/lib/plugins/pluginShortcuts").PluginShortcutEntry[];
openMarketplaceDetails: (listing: MarketplacePluginListing) => void;
closeMarketplaceDetails: (focus?: "trigger" | "search" | "none") => void;
launchDetailEntry: (entry: string) => Promise<void>;
openDetailPlugin: () => void;
batchRunning: boolean;
batchMode: boolean;
marketplaceViewMode: "grid" | "list";
marketplaceSortMode: string;
marketplaceRepositoryId: string;
marketplaceQuery: string;
marketplacePublisher: string;
filteredMarketplaceListings: MarketplacePluginListing[];
filterByPublisher: (publisher: string) => void;
marketplaceListings: MarketplacePluginListing[];
catalogResults: PluginRepositoryCatalogResult[];
installedPlugins: InstalledPlugin[];
@@ -208,6 +228,7 @@ async function expectBusyControls() {
beforeEach(async () => {
vi.resetAllMocks();
mocks.connections.length = 0;
mocks.isTauriRuntime.mockReturnValue(false);
localStorage.clear();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({}));
@@ -224,7 +245,7 @@ beforeEach(async () => {
mocks.removePluginTrustedKey.mockResolvedValue([]);
host = document.createElement("div");
document.body.append(host);
app = createApp(PluginContributionsPanel, { onPluginRuntimeReplaced: mocks.refreshPluginWorkbenches });
app = createApp(PluginContributionsPanel, { onPluginRuntimeReplaced: mocks.refreshPluginWorkbenches, onNewConnection: mocks.newConnection });
const instance = app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } };
state = instance.$.setupState;
await flushUi();
@@ -1300,25 +1321,25 @@ describe("PluginContributionsPanel narrow-pane layout contracts", () => {
// overflowed its scroller and 批量管理 / 刷新 left the visible area (measured 155px overflow at a 650px
// pane). happy-dom cannot measure layout, so this guards the class contract the measurement rig
// validates — same style as the #10444 grid guard above.
it("keys the marketplace toolbar off the panel width instead of the viewport", () => {
it("keeps the marketplace toolbar compact and wraps within the panel", () => {
const toolbar = host.querySelector<HTMLElement>("[data-plugin-marketplace-toolbar]");
expect(toolbar, "marketplace toolbar").not.toBeNull();
expect(toolbar!.classList.contains("sm:flex-row"), "row switch must not use the viewport breakpoint (#10582)").toBe(false);
expect(toolbar!.classList.contains("sm:items-center"), "row switch must not use the viewport breakpoint (#10582)").toBe(false);
expect(toolbar!.classList.contains("@min-[54rem]:flex-row"), "row switch tracks the panel width").toBe(true);
expect(toolbar!.classList.contains("items-center"), "search and controls align in one row").toBe(true);
expect(toolbar!.classList.contains("flex-wrap"), "an overflowing control set must wrap, never leave the pane").toBe(true);
const controls = host.querySelector<HTMLElement>("[data-plugin-marketplace-controls]");
expect(controls, "marketplace control cluster").not.toBeNull();
expect(controls!.classList.contains("flex-wrap"), "control cluster wraps").toBe(true);
expect(controls!.classList.contains("sm:ml-auto"), "auto margin must not use the viewport breakpoint").toBe(false);
expect(controls!.classList.contains("@min-[54rem]:ml-auto"), "auto margin tracks the panel width").toBe(true);
expect(controls!.classList.contains("justify-end"), "controls stay aligned to the toolbar edge").toBe(true);
// App.vue focuses this input by attribute for Mod+F, so the hook has to survive layout changes.
const search = host.querySelector<HTMLInputElement>("[data-plugin-marketplace-search]");
expect(search, "marketplace search input hook").not.toBeNull();
expect(search!.classList.contains("sm:w-[min(100%,28rem)]")).toBe(false);
expect(search!.classList.contains("@min-[54rem]:w-[min(100%,28rem)]")).toBe(true);
expect(search!.classList.contains("@min-[54rem]:w-[min(100%,28rem)]")).toBe(false);
// The Select family shares one passthrough stub here, so the triggers are located by their own
// data hooks (same convention as data-plugin-marketplace-search) instead of by a layout class.
@@ -1366,3 +1387,332 @@ describe("PluginContributionsPanel narrow-pane layout contracts", () => {
expect(trustRow.className, "trusted-key grid tracks the panel width").toContain("@min-[42rem]:grid-cols-[180px_minmax(260px,1fr)_auto]");
});
});
describe("marketplace verification badges", () => {
it.each(["official", "enterprise", "custom"] as const)("uses canonical verification for %s repository entries", async (kind) => {
for (const verified of [false, true]) {
const result = catalog("first", ["a"]);
result.repository.kind = kind;
result.catalog!.plugins[0].verified = verified;
state.catalogResults = [result];
const expectedVerified = verified && kind !== "custom";
expect(state.marketplaceListings[0].verified).toBe(expectedVerified);
for (const view of ["grid", "list"] as const) {
state.marketplaceViewMode = view;
await flushUi();
const card = host.querySelector("article")!;
expect(card.querySelector('svg[title="pluginPlatform.verified:{}"]') !== null, `${view}, verified=${verified}`).toBe(expectedVerified);
}
state.openMarketplaceDetails(state.marketplaceListings[0]);
await flushUi();
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.querySelector('svg[title="pluginPlatform.verified:{}"]') !== null, `detail, verified=${verified}`).toBe(expectedVerified);
const verificationRow = [...dialog.querySelectorAll("dl > div")].find((row) => row.querySelector("dt")?.textContent === "pluginPlatform.verified:{}");
expect(verificationRow?.querySelector("dd")?.textContent).toBe(`pluginPlatform.${expectedVerified ? "verified" : "unverified"}:{}`);
state.closeMarketplaceDetails();
await flushUi();
}
});
});
describe("marketplace detail launch", () => {
it("prioritizes the independent workbench when saved connections also exist", async () => {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "connection-provider", id: "server", label: "Server", database_type: "test", fields: [], workbench: "remote" },
{ type: "workbench", id: "remote", label: "Remote" },
{ type: "workbench", id: "local", label: "Local" },
];
mocks.connections.push({ id: "saved", name: "Saved", db_type: "plugin", plugin_id: "a", plugin_connection_provider: "server" } as import("@/types/database").ConnectionConfig);
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
expect(state.detailOpenLabel).toBe("openWorkbench");
state.openDetailPlugin();
await flushUi();
expect(mocks.openPluginWorkbench).toHaveBeenCalledWith("a", "local", { title: "Local" });
expect(mocks.openPluginConnection).not.toHaveBeenCalled();
});
it("keeps multiple saved connections in their own chooser", () => {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "connection-provider", id: "server", label: "Server", database_type: "test", fields: [], workbench: "remote" },
{ type: "workbench", id: "remote", label: "Remote" },
];
for (const id of ["one", "two"]) mocks.connections.push({ id, name: id, db_type: "plugin", plugin_id: "a", plugin_connection_provider: "server" } as import("@/types/database").ConnectionConfig);
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
expect(state.detailOpenLabel).toBe("chooseConnection");
state.openDetailPlugin();
expect(state.detailChooseConnection).toBe(true);
expect(state.detailChooseEntry).toBe(false);
expect(mocks.openPluginConnection).not.toHaveBeenCalled();
});
it("offers creation for a connection plugin with no saved connection", () => {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "connection-provider", id: "server", label: "Server", database_type: "test", fields: [], workbench: "ui" },
{ type: "workbench", id: "ui", label: "UI" },
];
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
state.openDetailPlugin();
expect(mocks.newConnection).toHaveBeenCalledWith("a", "server");
expect(mocks.openPluginConnection).not.toHaveBeenCalled();
expect(state.marketplaceDetailOpen).toBe(false);
});
it("opens a saved plugin connection through the standard connection flow", async () => {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "connection-provider", id: "server", label: "Server", database_type: "test", fields: [], workbench: "ui" },
{ type: "workbench", id: "ui", label: "UI" },
];
mocks.connections.push({ id: "saved", name: "Saved server", db_type: "plugin", plugin_id: "a", plugin_connection_provider: "server" } as import("@/types/database").ConnectionConfig);
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
state.openDetailPlugin();
await flushUi();
expect(mocks.openPluginConnection).toHaveBeenCalledWith("saved");
expect(mocks.openPluginWorkbench).not.toHaveBeenCalled();
expect(state.marketplaceDetailOpen).toBe(false);
});
it("restores detail after an opening failure", async () => {
const plugin = installed("a");
plugin.manifest.contributions = [{ type: "workbench", id: "todo", label: "Todo" }];
state.installedPlugins = [plugin];
mocks.openPluginWorkbench.mockImplementation(() => {
throw new Error("Cannot open");
});
state.openMarketplaceDetails(state.marketplaceListings[0]);
state.openDetailPlugin();
await flushUi();
expect(state.marketplaceDetailOpen).toBe(true);
expect(mocks.toast).toHaveBeenCalled();
});
async function startDelayedConnectionLaunch() {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "connection-provider", id: "server", label: "Server", database_type: "test", fields: [], workbench: "ui" },
{ type: "workbench", id: "ui", label: "UI" },
];
mocks.connections.push({ id: "saved", name: "Saved", db_type: "plugin", plugin_id: "a", plugin_connection_provider: "server" } as import("@/types/database").ConnectionConfig);
state.installedPlugins = [plugin];
const pending = deferred<void>();
mocks.openPluginConnection.mockReturnValue(pending.promise);
state.openMarketplaceDetails(state.marketplaceListings.find((listing) => listing.plugin.id === "a")!);
await flushUi();
const opening = state.launchDetailEntry("saved");
await flushUi();
expect(mocks.openPluginConnection).toHaveBeenCalledExactlyOnceWith("saved");
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.detailOpening).toBe(true);
return { pending, opening };
}
it("restores the originating detail after a delayed launch failure", async () => {
const { pending, opening } = await startDelayedConnectionLaunch();
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(true);
expect(state.selectedMarketplaceListing?.plugin.id).toBe("a");
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
it.each(["a", "b"])("does not reopen dismissed %s details after an earlier launch rejects", async (pluginId) => {
const { pending, opening } = await startDelayedConnectionLaunch();
state.openMarketplaceDetails(state.marketplaceListings.find((listing) => listing.plugin.id === pluginId)!);
await flushUi();
state.closeMarketplaceDetails();
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.selectedMarketplaceListing?.plugin.id).toBe(pluginId);
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it.each(["a", "b"])("keeps the newer %s detail session open after an earlier launch rejects", async (pluginId) => {
const { pending, opening } = await startDelayedConnectionLaunch();
state.openMarketplaceDetails(state.marketplaceListings.find((listing) => listing.plugin.id === pluginId)!);
await flushUi();
const dialog = document.querySelector('[role="dialog"]');
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(true);
expect(state.selectedMarketplaceListing?.plugin.id).toBe(pluginId);
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
expect(document.querySelector('[role="dialog"]')).toBe(dialog);
});
it("does not restore details after an explicit close during a delayed launch", async () => {
const { pending, opening } = await startDelayedConnectionLaunch();
state.closeMarketplaceDetails();
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
});
it("does not restore details after unmount during a delayed launch", async () => {
const { pending, opening } = await startDelayedConnectionLaunch();
app.unmount();
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
});
it("does not restore details after navigating away and back during a delayed launch", async () => {
const { pending, opening } = await startDelayedConnectionLaunch();
state.activeSection = "installed";
await flushUi();
state.activeSection = "marketplace";
await flushUi();
pending.reject(new Error("Connection failed"));
await opening;
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.detailOpening).toBe(false);
expect(mocks.toast).toHaveBeenCalledWith("Connection failed", 8000);
});
it("opens a single standalone workbench and closes details", async () => {
const plugin = installed("a");
plugin.manifest.contributions = [{ type: "workbench", id: "todo", label: "Todo" }];
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
expect(state.detailCanOpen).toBe(true);
state.openDetailPlugin();
await flushUi();
expect(mocks.openPluginWorkbench).toHaveBeenCalledWith("a", "todo", { title: "Todo" });
expect(state.marketplaceDetailOpen).toBe(false);
});
it("asks users to choose when the plugin has several workbenches", () => {
const plugin = installed("a");
plugin.manifest.contributions = [
{ type: "workbench", id: "one", label: "One" },
{ type: "workbench", id: "two", label: "Two" },
];
state.installedPlugins = [plugin];
state.openMarketplaceDetails(state.marketplaceListings[0]);
state.openDetailPlugin();
expect(state.detailChooseEntry).toBe(true);
expect(mocks.openPluginWorkbench).not.toHaveBeenCalled();
});
it("does not offer launch for incompatible or non-UI plugins", () => {
state.openMarketplaceDetails(state.marketplaceListings[0]);
expect(state.detailCanOpen).toBe(false);
const plugin = installed("a");
plugin.compatibility.compatible = false;
plugin.manifest.contributions = [{ type: "workbench", id: "todo", label: "Todo" }];
state.installedPlugins = [plugin];
expect(state.detailCanOpen).toBe(false);
});
});
describe("marketplace author filtering", () => {
it("shows exact author matches across repositories and clears prior filters", async () => {
const first = catalog("first", ["a", "b", "c"]);
const second = catalog("second", ["d"]);
first.catalog!.plugins[0].publisher = "Alice";
first.catalog!.plugins[1].publisher = "Alice Tools";
first.catalog!.plugins[2].description = "By Alice fans";
second.catalog!.plugins[0].publisher = "Alice";
state.catalogResults = [first, second];
state.marketplaceQuery = "unrelated search";
state.marketplaceRepositoryId = "first";
state.openMarketplaceDetails(state.marketplaceListings[0]);
await flushUi();
const author = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')).find((button) => button.textContent === "Alice");
expect(author).toBeDefined();
author!.click();
await flushUi();
expect(state.marketplaceDetailOpen).toBe(false);
expect(state.marketplaceQuery).toBe("");
expect(state.marketplaceRepositoryId).toBe("all");
expect(state.filteredMarketplaceListings.map((listing) => listing.plugin.id)).toEqual(["a", "d"]);
expect(state.marketplaceListings).toHaveLength(4);
const clear = host.querySelector<HTMLButtonElement>('button[aria-label="common.clear:{}: Alice"]');
expect(clear).not.toBeNull();
clear!.click();
await flushUi();
expect(state.filteredMarketplaceListings).toHaveLength(4);
});
it.each(["grid", "list"] as const)("filters from a %s card without opening details", async (mode) => {
state.marketplaceViewMode = mode;
await flushUi();
const author = Array.from(host.querySelectorAll<HTMLButtonElement>("article button")).find((button) => button.textContent === "DBX");
expect(author).toBeDefined();
author!.click();
await flushUi();
expect(state.marketplacePublisher).toBe("DBX");
expect(state.marketplaceDetailOpen).toBe(false);
});
});
describe("marketplace detail focus", () => {
async function settleDialog() {
await flushUi();
await new Promise((resolve) => setTimeout(resolve, 30));
await flushUi();
}
it.each(["Enter", " "])("returns focus to the card after %s then Escape", async (key) => {
const card = host.querySelector<HTMLElement>('article[role="button"]')!;
card.focus();
card.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
await settleDialog();
expect(state.marketplaceDetailOpen).toBe(true);
expect(document.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(true);
document.activeElement!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await settleDialog();
expect(state.marketplaceDetailOpen).toBe(false);
expect(document.activeElement).toBe(card);
});
it("focuses search after author filtering closes details", async () => {
const card = host.querySelector<HTMLElement>('article[role="button"]')!;
card.click();
await settleDialog();
const author = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button')).find((item) => item.textContent === "DBX")!;
author.click();
await settleDialog();
expect(document.activeElement).toBe(host.querySelector("[data-plugin-marketplace-search]"));
});
it("does not steal focus from the opened workbench", async () => {
const plugin = installed("a");
plugin.manifest.contributions = [{ type: "workbench", id: "todo", label: "Todo" }];
state.installedPlugins = [plugin];
const destination = document.createElement("button");
host.append(destination);
mocks.openPluginWorkbench.mockImplementation(() => destination.focus());
state.openMarketplaceDetails(state.marketplaceListings[0]);
await settleDialog();
state.openDetailPlugin();
await settleDialog();
expect(document.activeElement).toBe(destination);
});
});
+24
View File
@@ -277,6 +277,30 @@ export default {
update: "Update",
unsupported: "Unsupported",
},
openWorkbench: "Open workbench",
chooseWorkbench: "Choose workbench",
chooseConnection: "Choose connection",
searchMarketplaceCount: "Search {count} plugins…",
marketplaceTotal: "Listed {count}",
marketplaceFiltered: "Showing {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "Updated {name} to {version}",
installedUpdatesAvailableTitle: "Plugin updates available ({count})",
installedUpdatesAvailableDescription: "Updates verify signatures and checksums, and keep the previous version for one-click rollback.",
+24
View File
@@ -274,6 +274,30 @@ export default withEnglishFallback({
sourceRepository: "Repositorio de código fuente",
pluginHomepage: "Página principal",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Actualizar", unsupported: "No compatible" },
openWorkbench: "Abrir espacio de trabajo",
chooseWorkbench: "Elegir espacio de trabajo",
chooseConnection: "Elegir conexión",
searchMarketplaceCount: "Buscar entre {count} plugins…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "Se actualizó {name} a {version}",
installedUpdatesAvailableTitle: "Actualizaciones de plugins disponibles ({count})",
installedUpdatesAvailableDescription: "Las actualizaciones verifican firmas y sumas de comprobación, y conservan la versión anterior para revertir con un clic.",
+24
View File
@@ -175,6 +175,30 @@ export default withEnglishFallback({
update: "Perbarui",
unsupported: "Tidak didukung",
},
openWorkbench: "Buka ruang kerja",
chooseWorkbench: "Pilih ruang kerja",
chooseConnection: "Pilih koneksi",
searchMarketplaceCount: "Cari {count} plugin…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} diperbarui ke {version}",
installedUpdatesAvailableTitle: "Pembaruan plugin tersedia ({count})",
installedUpdatesAvailableDescription: "Pembaruan memverifikasi tanda tangan dan checksum, serta menyimpan versi sebelumnya untuk rollback sekali klik.",
+24
View File
@@ -272,6 +272,30 @@ export default withEnglishFallback({
sourceRepository: "Repository del codice sorgente",
pluginHomepage: "Sito web",
marketplaceStatus: { install: "Installa", installed: "Installato", update: "Aggiorna", unsupported: "Non supportato" },
openWorkbench: "Apri area di lavoro",
chooseWorkbench: "Scegli area di lavoro",
chooseConnection: "Scegli connessione",
searchMarketplaceCount: "Cerca tra {count} plugin…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} aggiornato alla versione {version}",
installedUpdatesAvailableTitle: "Aggiornamenti plugin disponibili ({count})",
installedUpdatesAvailableDescription: "Gli aggiornamenti verificano firme e checksum e conservano la versione precedente per il rollback con un clic.",
+24
View File
@@ -273,6 +273,30 @@ export default withEnglishFallback({
sourceRepository: "ソースリポジトリ",
pluginHomepage: "ホームページ",
marketplaceStatus: { install: "インストール", installed: "インストール済み", update: "更新", unsupported: "非対応" },
openWorkbench: "ワークベンチを開く",
chooseWorkbench: "ワークベンチを選択",
chooseConnection: "接続を選択",
searchMarketplaceCount: "{count} 個のプラグインを検索…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} を {version} に更新しました",
installedUpdatesAvailableTitle: "プラグインの更新があります ({count})",
installedUpdatesAvailableDescription: "更新前には署名とチェックサムを検証し、旧バージョンを保持してワンクリックでロールバックできます。",
+24
View File
@@ -277,6 +277,30 @@ export default withEnglishFallback({
update: "업데이트",
unsupported: "지원되지 않음",
},
openWorkbench: "워크벤치 열기",
chooseWorkbench: "워크벤치 선택",
chooseConnection: "연결 선택",
searchMarketplaceCount: "플러그인 {count}개 검색…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} 업데이트 완료: {version}",
installedUpdatesAvailableTitle: "플러그인 업데이트 있음 ({count})",
installedUpdatesAvailableDescription: "업데이트 전 서명과 체크섬을 검증하고, 이전 버전을 보관하여 한 번에 롤백할 수 있습니다.",
+24
View File
@@ -273,6 +273,30 @@ export default withEnglishFallback({
sourceRepository: "Repositório do código-fonte",
pluginHomepage: "Página inicial",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Atualizar", unsupported: "Não compatível" },
openWorkbench: "Abrir área de trabalho",
chooseWorkbench: "Escolher área de trabalho",
chooseConnection: "Escolher conexão",
searchMarketplaceCount: "Buscar {count} plugins…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} atualizado para {version}",
installedUpdatesAvailableTitle: "Atualizações de plugins disponíveis ({count})",
installedUpdatesAvailableDescription: "As atualizações verificam assinaturas e checksums e mantêm a versão anterior para reversão com um clique.",
+24
View File
@@ -246,6 +246,30 @@ export default withEnglishFallback({
update: "Обновить",
unsupported: "Не поддерживается",
},
openWorkbench: "Открыть рабочую область",
chooseWorkbench: "Выбрать рабочую область",
chooseConnection: "Выбрать подключение",
searchMarketplaceCount: "Поиск среди {count} плагинов…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "{name} обновлён до {version}",
installedUpdatesAvailableTitle: "Доступны обновления плагинов ({count})",
installedUpdatesAvailableDescription: "Обновления проверяют подписи и контрольные суммы, а предыдущая версия сохраняется для отката в один клик.",
+24
View File
@@ -218,6 +218,30 @@ export default withEnglishFallback({
sourceRepository: "源码仓库",
pluginHomepage: "主页",
marketplaceStatus: { install: "安装", installed: "已安装", update: "更新", unsupported: "不支持" },
openWorkbench: "打开工作台",
chooseWorkbench: "选择工作台",
chooseConnection: "选择连接",
searchMarketplaceCount: "搜索 {count} 款插件…",
marketplaceTotal: "上架 {count} 个",
marketplaceFiltered: "显示 {count} 个",
openPluginDetails: "打开 {name} 详情",
pluginDetailsOverview: "介绍",
pluginDetailsUpdates: "更新历史",
pluginDetailsPermissions: "权限与兼容性",
pluginDetailsRepository: "仓库信息",
pluginDetailsIntroduction: "插件介绍",
pluginDetailsVersion: "最新版本",
pluginDetailsLicense: "许可证",
pluginDetailsReleaseDate: "发布日期",
noVersionHistory: "暂无版本历史。",
noReleaseNotes: "开发者未提供更新说明。",
noPermissions: "未声明特殊权限。",
pluginDetailsCompatibility: "兼容性",
pluginDetailsCurrentTarget: "当前目标平台",
pluginDetailsStatus: "状态",
pluginDetailsPublisher: "发布者",
unverified: "未认证",
pluginDetailsRepositoryHomepage: "仓库主页",
updateSuccess: "已将 {name} 更新到 {version}",
installedUpdatesAvailableTitle: "插件更新可用 ({count})",
installedUpdatesAvailableDescription: "更新前将校验包签名与校验和;自动保留上一版本,支持一键回滚。",
+24
View File
@@ -275,6 +275,30 @@ export default withEnglishFallback({
sourceRepository: "原始碼倉庫",
pluginHomepage: "主頁",
marketplaceStatus: { install: "安裝", installed: "已安裝", update: "更新", unsupported: "不支援" },
openWorkbench: "開啟工作台",
chooseWorkbench: "選擇工作台",
chooseConnection: "選擇連線",
searchMarketplaceCount: "搜尋 {count} 款外掛…",
marketplaceTotal: "All listed: {count}",
marketplaceFiltered: "Showing: {count}",
openPluginDetails: "Open details for {name}",
pluginDetailsOverview: "Overview",
pluginDetailsUpdates: "Update history",
pluginDetailsPermissions: "Permissions",
pluginDetailsRepository: "Repository",
pluginDetailsIntroduction: "Introduction",
pluginDetailsVersion: "Latest version",
pluginDetailsLicense: "License",
pluginDetailsReleaseDate: "Release date",
noVersionHistory: "No version history available.",
noReleaseNotes: "No release notes provided.",
noPermissions: "No special permissions declared.",
pluginDetailsCompatibility: "Compatibility",
pluginDetailsCurrentTarget: "Current target",
pluginDetailsStatus: "Status",
pluginDetailsPublisher: "Publisher",
unverified: "Not verified",
pluginDetailsRepositoryHomepage: "Repository homepage",
updateSuccess: "已將 {name} 更新至 {version}",
installedUpdatesAvailableTitle: "外掛更新可用 ({count})",
installedUpdatesAvailableDescription: "更新前會校驗套件簽章與校驗和;自動保留上一版本,支援一鍵回復。",
+12
View File
@@ -79,6 +79,18 @@ function repositoryResult(repositoryId: string, repositoryName: string, latestVe
}
describe("plugin marketplace listings", () => {
it("uses the catalog repository homepage without falling back to its JSON URL", () => {
const catalog = structuredClone(result);
catalog.repository.catalogUrl = "https://plugins.example.com/catalog.json";
catalog.catalog!.repository.homepage = "https://plugins.example.com/";
expect(buildMarketplacePluginListings([catalog], [], "en")[0].repositoryHomepage).toBe("https://plugins.example.com/");
delete catalog.catalog!.repository.homepage;
expect(buildMarketplacePluginListings([catalog], [], "en")[0].repositoryHomepage).toBeUndefined();
catalog.catalog!.repository.homepage = " ";
expect(buildMarketplacePluginListings([catalog], [], "en")[0].repositoryHomepage).toBeUndefined();
});
it("localizes, detects updates, and filters by repository", () => {
const listings = buildMarketplacePluginListings([result], [installed("1.0.0")], "zh-CN");
@@ -10,6 +10,7 @@ export const UNIVERSAL_PLUGIN_TARGET = "universal";
export interface MarketplacePluginListing {
key: string;
repository: PluginRepository;
repositoryHomepage?: string;
plugin: PluginMarketplacePlugin;
name: string;
description: string;
@@ -48,6 +49,7 @@ export function buildMarketplacePluginListings(results: readonly PluginRepositor
return {
key: `${result.repository.id}:${plugin.id}`,
repository: result.repository,
repositoryHomepage: result.catalog?.repository.homepage?.trim() || undefined,
plugin,
name: localized.name,
description: localized.description,
@@ -61,7 +61,7 @@ export interface PluginShortcutEntry {
disabled: boolean;
}
export function collectPluginShortcuts(registry: FrontendPluginRegistry): PluginShortcutEntry[] {
export function collectPluginShortcuts(registry: FrontendPluginRegistry, includeUnboundWorkbenches = false): PluginShortcutEntry[] {
const result = new Map<string, PluginShortcutEntry>();
const add = (pluginId: string, kind: PluginShortcutEntry["kind"], targetId: string, label: string, icon?: string, command?: PluginCommandContribution, rootUri?: string) => {
const plugin = registry.findPlugin(pluginId)?.plugin;
@@ -92,7 +92,11 @@ export function collectPluginShortcuts(registry: FrontendPluginRegistry): Plugin
const connected = new Set(registry.listConnectionProviders().map(({ plugin }) => plugin.manifest.id));
for (const { plugin, contribution } of registry.listWorkbenches()) {
const pluginId = plugin.manifest.id;
if (connected.has(pluginId)) continue;
if (connected.has(pluginId)) {
// Detail launch may expose an independent workbench alongside connection-bound ones.
const bound = registry.listConnectionProviders().some((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.workbench === contribution.id);
if (!includeUnboundWorkbenches || bound) continue;
}
const command = registry.findCommandTargetingWorkbench(pluginId, contribution.id);
if (command) {
// A declared toolbar visibility rule must not be bypassed by the legacy fallback.