feat(plugins): polish plugin marketplace listing display

This commit is contained in:
zipg authored and GitHub committed 2026-09-15 22:13:13 +08:00
1 parent 300fc029a9
commit 4c80d16e26
3 files changed
+63 -13

No files matched your search

@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
@@ -15,13 +16,14 @@ import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { beaconPluginInstall, buildMarketplacePluginListings, filterMarketplacePluginListings, listingRepositoryCanVerify, marketplaceHomepageUrl, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import { formatBytes } from "@/lib/database/serverMetrics";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { InstalledPlugin, PluginInstallResult, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
import { useI18n } from "vue-i18n";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
const props = defineProps<{
focusTarget?: PluginCenterFocus | null;
@@ -47,6 +49,7 @@ const GithubIcon = defineComponent({
});
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
const MARKETPLACE_VIEW_MODE_STORAGE_KEY = "dbx-plugin-marketplace-view-mode";
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
@@ -87,7 +90,7 @@ const repositoryName = ref("");
const repositoryCatalogUrl = ref("");
const marketplaceQuery = ref("");
const marketplaceRepositoryId = ref("all");
const marketplaceViewMode = ref<"grid" | "list">("grid");
const marketplaceViewMode = ref<"grid" | "list">(safeLocalStorageGet(MARKETPLACE_VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "grid");
const webFileInput = ref<HTMLInputElement | null>(null);
const panelRootRef = ref<HTMLElement | null>(null);
const draggingPackage = ref(false);
@@ -113,6 +116,12 @@ const customRepositories = computed(() => repositories.value.filter((repository)
const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0);
const pluginDevelopmentDocsUrl = computed(() => `https://dbxio.com/${appLocale.value.startsWith("zh") ? "cn" : "en"}/docs/plugin-development`);
function marketplaceActionClass(listing: MarketplacePluginListing): string {
if (listing.status === "update") return "text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700";
if (listing.status === "install") return "text-foreground hover:bg-gray-200 dark:hover:bg-gray-700";
return "cursor-default text-gray-600 dark:text-gray-400";
}
function openExternal(url?: string) {
const target = url?.trim();
if (!target) return;
@@ -578,6 +587,7 @@ onMounted(() => {
void refresh();
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
});
watch(marketplaceViewMode, (mode) => safeLocalStorageSet(MARKETPLACE_VIEW_MODE_STORAGE_KEY, mode));
onBeforeUnmount(() => {
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
});
@@ -681,12 +691,12 @@ onBeforeUnmount(() => {
<GithubIcon icon-class="size-3.5" />
</button>
<button
v-if="listing.plugin.homepage"
v-if="marketplaceHomepageUrl(listing.plugin.source, listing.plugin.homepage)"
type="button"
class="rounded p-0.5 opacity-60 transition-opacity [will-change:opacity] hover:opacity-100"
:title="t('pluginPlatform.pluginHomepage')"
:aria-label="t('pluginPlatform.pluginHomepage')"
@click.stop="openExternal(listing.plugin.homepage)"
@click.stop="openExternal(marketplaceHomepageUrl(listing.plugin.source, listing.plugin.homepage))"
>
<Globe class="size-3.5" />
</button>
@@ -697,7 +707,12 @@ onBeforeUnmount(() => {
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}</Badge>
</div>
<p class="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
<Tooltip :delay-duration="700">
<TooltipTrigger as-child>
<p class="mt-3 line-clamp-3 cursor-help text-xs leading-5 text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-md whitespace-pre-wrap break-words">{{ listing.description || t("pluginPlatform.noDescription") }}</TooltipContent>
</Tooltip>
<div class="mt-auto flex items-center justify-between gap-3 pt-4">
<div class="text-[11px] text-muted-foreground">
<span v-if="listing.status === 'unsupported'">{{ t("pluginPlatform.unsupportedTarget", { target: listing.target }) }}</span>
@@ -707,7 +722,7 @@ onBeforeUnmount(() => {
<button
type="button"
class="inline-flex h-7 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
:class="marketplaceActionClass(listing)"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
@click="installMarketplaceListing(listing)"
>
@@ -734,12 +749,12 @@ onBeforeUnmount(() => {
<GithubIcon icon-class="size-3.5" />
</button>
<button
v-if="listing.plugin.homepage"
v-if="marketplaceHomepageUrl(listing.plugin.source, listing.plugin.homepage)"
type="button"
class="shrink-0 rounded p-0.5 opacity-60 transition-opacity [will-change:opacity] hover:opacity-100"
:title="t('pluginPlatform.pluginHomepage')"
:aria-label="t('pluginPlatform.pluginHomepage')"
@click.stop="openExternal(listing.plugin.homepage)"
@click.stop="openExternal(marketplaceHomepageUrl(listing.plugin.source, listing.plugin.homepage))"
>
<Globe class="size-3.5" />
</button>
@@ -749,7 +764,12 @@ onBeforeUnmount(() => {
<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>
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
<Tooltip :delay-duration="700">
<TooltipTrigger as-child>
<p class="mt-1 cursor-help truncate text-xs text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
</TooltipTrigger>
<TooltipContent side="bottom" class="max-w-md whitespace-pre-wrap break-words">{{ listing.description || t("pluginPlatform.noDescription") }}</TooltipContent>
</Tooltip>
</div>
<div class="hidden max-w-52 shrink-0 gap-1.5 lg:flex">
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
@@ -757,7 +777,7 @@ onBeforeUnmount(() => {
<button
type="button"
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
:class="marketplaceActionClass(listing)"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
@click="installMarketplaceListing(listing)"
>
@@ -820,12 +840,12 @@ onBeforeUnmount(() => {
<GithubIcon />
</button>
<button
v-if="selectedDefinition.plugin.manifest.homepage"
v-if="marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, selectedDefinition.plugin.manifest.homepage)"
type="button"
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
:title="t('pluginPlatform.pluginHomepage')"
:aria-label="t('pluginPlatform.pluginHomepage')"
@click="openExternal(selectedDefinition.plugin.manifest.homepage)"
@click="openExternal(marketplaceHomepageUrl(selectedDefinition.plugin.manifest.source, selectedDefinition.plugin.manifest.homepage))"
>
<Globe class="size-3" />
</button>
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, selectMarketplaceArtifact } from "./pluginMarketplace";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, marketplaceHomepageUrl, selectMarketplaceArtifact } from "./pluginMarketplace";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
const result: PluginRepositoryCatalogResult = {
@@ -81,4 +81,9 @@ describe("plugin marketplace listings", () => {
expect(selectMarketplaceArtifact(artifacts, "darwin-arm64")?.target).toBe("darwin-arm64");
});
it("treats a repository URL and its homepage URL as one link", () => {
expect(marketplaceHomepageUrl("https://github.com/dbxio/example/", "https://github.com/dbxio/example#readme")).toBeUndefined();
expect(marketplaceHomepageUrl("https://github.com/dbxio/example", "https://dbxio.com/plugins/example")).toBe("https://dbxio.com/plugins/example");
});
});
@@ -17,6 +17,18 @@ export interface MarketplacePluginListing {
status: MarketplacePluginStatus;
}
/**
* Returns the homepage only when it points somewhere different from the
* source repository. Marketplace metadata often repeats the repository URL in
* both fields, which otherwise renders two identical links.
*/
export function marketplaceHomepageUrl(source?: string, homepage?: string): string | undefined {
const normalizedSource = normalizeExternalUrl(source);
const normalizedHomepage = normalizeExternalUrl(homepage);
if (!normalizedHomepage || normalizedHomepage === normalizedSource) return undefined;
return homepage?.trim() || undefined;
}
export function buildMarketplacePluginListings(results: readonly PluginRepositoryCatalogResult[], installedPlugins: readonly InstalledPlugin[], locale: string): MarketplacePluginListing[] {
const installedById = new Map(installedPlugins.map((plugin) => [plugin.manifest.id, plugin]));
return results
@@ -104,3 +116,16 @@ function parseVersion(version: string): [number, number, number, string] | null
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || "~"];
}
function normalizeExternalUrl(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
try {
const parsed = new URL(trimmed);
parsed.hash = "";
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`;
} catch {
return trimmed.replace(/\/+$/, "").toLowerCase();
}
}