mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugins): add marketplace details and workbench launch
This commit is contained in:
@@ -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="
|
||||
|
||||
+357
-7
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "更新前には署名とチェックサムを検証し、旧バージョンを保持してワンクリックでロールバックできます。",
|
||||
|
||||
@@ -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: "업데이트 전 서명과 체크섬을 검증하고, 이전 버전을 보관하여 한 번에 롤백할 수 있습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "Обновления проверяют подписи и контрольные суммы, а предыдущая версия сохраняется для отката в один клик.",
|
||||
|
||||
@@ -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: "更新前将校验包签名与校验和;自动保留上一版本,支持一键回滚。",
|
||||
|
||||
@@ -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: "更新前會校驗套件簽章與校驗和;自動保留上一版本,支援一鍵回復。",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user