feat(plugins): polish plugin marketplace listing display
This commit is contained in:
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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user