diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index 08b9a7a238..13c05c8683 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -63,6 +63,7 @@ import { formatMarketplaceReleasedDate, marketplaceHomepageUrl, pluginSourceChange, + resolveMarketplaceReleaseUrl, sortMarketplacePluginListings, type InstalledPluginUpdateEntry, type MarketplacePluginListing, @@ -333,10 +334,7 @@ function marketplaceVersionPublishedLabel(version: PluginMarketplaceVersion): st // The expanded entry links to the release page for that exact version in the // plugin's source repository, mirroring the 更新管理 panel's GitHub Release button. function marketplaceVersionReleaseUrl(version: PluginMarketplaceVersion): string { - const source = selectedMarketplaceListing.value?.plugin.source?.trim(); - if (!source) return ""; - const tag = version.version.startsWith("v") ? version.version : `v${version.version}`; - return `${source.replace(/\/+$/, "")}/releases/tag/${encodeURIComponent(tag)}`; + return resolveMarketplaceReleaseUrl(selectedMarketplaceListing.value?.plugin.source, 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))); diff --git a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts index 2201a852a7..292deb0155 100644 --- a/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts +++ b/apps/desktop/src/components/plugins/__tests__/PluginContributionsPanelBatch.spec.ts @@ -1700,6 +1700,23 @@ describe("marketplace detail update history", () => { expect(openSpy).toHaveBeenCalledWith("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0", "_blank", "noopener,noreferrer"); openSpy.mockRestore(); }); + + it("links expanded version to GitHub release page when source has a branch tree URL", async () => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const cat = multiVersionCatalog(); + cat.catalog!.plugins[0].source = "https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3"; + state.catalogResults = [cat]; + await flushUi(); + state.openMarketplaceDetails(state.marketplaceListings[0]); + await flushUi(); + const dialog = document.querySelector('[role="dialog"]')!; + const releaseButton = [...dialog.querySelectorAll("button")].find((element) => element.textContent?.includes("pluginPlatform.pluginDetailsOpenRelease"))!; + expect(releaseButton).toBeDefined(); + releaseButton.click(); + await flushUi(); + expect(openSpy).toHaveBeenCalledWith("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v3.0.0", "_blank", "noopener,noreferrer"); + openSpy.mockRestore(); + }); }); describe("marketplace author filtering", () => { diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts index ce2c7eaa94..1c5f80c4a4 100644 --- a/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginMarketplace.spec.ts @@ -3,10 +3,12 @@ import { buildInstalledUpdateIndex, buildMarketplacePluginListings, compareVersions, + extractRepositoryBaseUrl, filterMarketplacePluginListings, formatMarketplaceReleasedDate, marketplaceHomepageUrl, pluginSourceChange, + resolveMarketplaceReleaseUrl, selectMarketplaceArtifact, sortMarketplacePluginListings, type MarketplacePluginListing, @@ -136,6 +138,72 @@ describe("plugin marketplace listings", () => { 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"); }); + + describe("resolveMarketplaceReleaseUrl", () => { + it("resolves GitHub branch and tag tree URLs to root repository release tag", () => { + // Regression for #12516: source with /tree/ should not result in /tree//releases/tag/... + expect(resolveMarketplaceReleaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3"); + + expect(resolveMarketplaceReleaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3/", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3"); + + expect(resolveMarketplaceReleaseUrl("https://github.com/t8y2/dbx/tree/main/plugins/examples/hello-workbench", "0.1.0")).toBe("https://github.com/t8y2/dbx/releases/tag/v0.1.0"); + }); + + it("resolves standard repository URLs with and without trailing slash or .git", () => { + expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0"); + expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin/", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0"); + expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin.git", "3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0"); + expect(resolveMarketplaceReleaseUrl("https://github.com/jinpy/dbx-ssh-plugin", "v3.0.0")).toBe("https://github.com/jinpy/dbx-ssh-plugin/releases/tag/v3.0.0"); + }); + + it("resolves blob, releases, and ssh URLs", () => { + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/blob/main/README.md", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0"); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/releases", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0"); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo/releases/tag/v0.1.0", "1.0.0")).toBe("https://github.com/owner/repo/releases/tag/v1.0.0"); + expect(resolveMarketplaceReleaseUrl("git@github.com:Ezreal-byte/dbx-plugin-xxl-job-console.git", "0.3.3")).toBe("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/releases/tag/v0.3.3"); + }); + + it("supports Gitee, GitLab, and CNB repository formats", () => { + expect(resolveMarketplaceReleaseUrl("https://gitee.com/someone/my-plugin/tree/master", "1.0.0")).toBe("https://gitee.com/someone/my-plugin/releases/tag/v1.0.0"); + expect(resolveMarketplaceReleaseUrl("https://gitlab.com/group/subgroup/my-plugin/-/tree/main", "1.0.0")).toBe("https://gitlab.com/group/subgroup/my-plugin/-/releases/v1.0.0"); + expect(resolveMarketplaceReleaseUrl("https://cnb.cool/dbxio.com/my-plugin/-/tree/main", "1.0.0")).toBe("https://cnb.cool/dbxio.com/my-plugin/-/releases/tag/v1.0.0"); + }); + + it("accepts PluginMarketplaceVersion objects and handles empty inputs gracefully", () => { + expect( + resolveMarketplaceReleaseUrl("https://github.com/owner/repo", { + version: "2.1.0", + artifacts: [], + }), + ).toBe("https://github.com/owner/repo/releases/tag/v2.1.0"); + + expect(resolveMarketplaceReleaseUrl("", "1.0.0")).toBe(""); + expect(resolveMarketplaceReleaseUrl(" ", "1.0.0")).toBe(""); + expect(resolveMarketplaceReleaseUrl(undefined, "1.0.0")).toBe(""); + expect(resolveMarketplaceReleaseUrl(null, "1.0.0")).toBe(""); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", "")).toBe(""); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", " ")).toBe(""); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", undefined)).toBe(""); + expect(resolveMarketplaceReleaseUrl("https://github.com/owner/repo", { version: "", artifacts: [] })).toBe(""); + }); + + it("extracts base repository URL from various git URL formats", () => { + expect(extractRepositoryBaseUrl("https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console/tree/v0.3.3")).toEqual({ + baseUrl: "https://github.com/Ezreal-byte/dbx-plugin-xxl-job-console", + host: "github.com", + }); + expect(extractRepositoryBaseUrl("https://github.com/owner/repo.git")).toEqual({ + baseUrl: "https://github.com/owner/repo", + host: "github.com", + }); + expect(extractRepositoryBaseUrl("git@github.com:owner/repo.git")).toEqual({ + baseUrl: "https://github.com/owner/repo", + host: "github.com", + }); + expect(extractRepositoryBaseUrl("")).toBeNull(); + expect(extractRepositoryBaseUrl(" ")).toBeNull(); + }); + }); }); describe("buildInstalledUpdateIndex", () => { diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.ts index bea401d768..90917a575f 100644 --- a/apps/desktop/src/lib/plugins/pluginMarketplace.ts +++ b/apps/desktop/src/lib/plugins/pluginMarketplace.ts @@ -1,4 +1,4 @@ -import type { InstalledPlugin, PluginMarketplaceArtifact, PluginMarketplacePlugin, PluginRepository, PluginRepositoryCatalogResult } from "@/types/database"; +import type { InstalledPlugin, PluginMarketplaceArtifact, PluginMarketplacePlugin, PluginMarketplaceVersion, PluginRepository, PluginRepositoryCatalogResult } from "@/types/database"; import { uuid } from "@/lib/common/utils"; export type MarketplacePluginStatus = "install" | "installed" | "update" | "unsupported"; @@ -36,6 +36,99 @@ export function marketplaceHomepageUrl(source?: string, homepage?: string): stri return homepage?.trim() || undefined; } +/** + * Resolves the external release URL for a specific plugin version. + * + * Plugin catalogs often provide repository URLs pointing to a specific branch, + * tag, or subfolder (e.g. `https://github.com/owner/repo/tree/v0.3.3` or + * `https://github.com/owner/repo/tree/main/packages/plugin`), or git clone URLs + * (`.git`). This extracts the base repository root and appends the appropriate + * release path for the given version tag. + */ +export function resolveMarketplaceReleaseUrl(source?: string | null, version?: string | PluginMarketplaceVersion | null): string { + const trimmedSource = source?.trim(); + const rawVersion = typeof version === "string" ? version.trim() : (version?.version?.trim() ?? ""); + if (!trimmedSource || !rawVersion) return ""; + + const tag = rawVersion.startsWith("v") || rawVersion.startsWith("V") ? rawVersion : `v${rawVersion}`; + const repo = extractRepositoryBaseUrl(trimmedSource); + if (!repo) return ""; + + if (repo.host === "cnb.cool" || repo.host.endsWith(".cnb.cool")) { + return `${repo.baseUrl}/-/releases/tag/${encodeURIComponent(tag)}`; + } + if (repo.host === "gitlab.com" || repo.host.endsWith(".gitlab.com")) { + return `${repo.baseUrl}/-/releases/${encodeURIComponent(tag)}`; + } + return `${repo.baseUrl}/releases/tag/${encodeURIComponent(tag)}`; +} + +export function extractRepositoryBaseUrl(source: string): { baseUrl: string; host: string } | null { + const trimmed = source.trim(); + if (!trimmed) return null; + + // Handle SSH URLs: git@github.com:owner/repo.git or ssh://git@github.com/owner/repo.git + const sshMatch = /^(?:ssh:\/\/)?git@([^/:]+)[:/](.+)$/i.exec(trimmed); + let normalized = trimmed; + if (sshMatch) { + const host = sshMatch[1]; + const path = sshMatch[2].replace(/\.git$/i, "").replace(/^\/+/, ""); + normalized = `https://${host}/${path}`; + } else if (!/^https?:\/\//i.test(normalized)) { + normalized = `https://${normalized}`; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + const cleaned = trimmed + .replace(/\/(?:-?\/)?(?:tree|blob|releases|src)(?:\/.*)?$/i, "") + .replace(/\.git$/i, "") + .replace(/\/+$/, ""); + return cleaned ? { baseUrl: cleaned, host: "" } : null; + } + + const host = parsed.hostname.toLowerCase(); + const origin = parsed.origin; + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length === 0) return null; + + // On GitHub and Gitee, repositories are strictly /. + // Any trailing segment (e.g. /tree/, /blob/, /releases) is not part of the repository. + if (host === "github.com" || host.endsWith(".github.com") || host === "gitee.com" || host.endsWith(".gitee.com")) { + if (segments.length >= 2) { + const owner = segments[0]; + const repoName = segments[1].replace(/\.git$/i, ""); + return { baseUrl: `${origin}/${owner}/${repoName}`, host }; + } + return { baseUrl: `${origin}/${segments[0].replace(/\.git$/i, "")}`, host }; + } + + // On GitLab, CNB, etc., UI routes (tree, blob, releases, etc.) follow a "/-/" segment: + // e.g. https://gitlab.com/group/subgroup/project/-/tree/main + // e.g. https://cnb.cool/org/project/-/releases/tag/v1.0.0 + const dashIndex = segments.indexOf("-"); + if (dashIndex > 0) { + const repoSegments = segments.slice(0, dashIndex); + repoSegments[repoSegments.length - 1] = repoSegments[repoSegments.length - 1].replace(/\.git$/i, ""); + return { baseUrl: `${origin}/${repoSegments.join("/")}`, host }; + } + + // Generic git hosts (Gitea, Bitbucket, self-hosted, etc.): + // Strip trailing branches/trees/blobs/releases/src if present + const cutIndex = segments.findIndex((seg) => ["tree", "blob", "releases", "src"].includes(seg.toLowerCase())); + if (cutIndex > 0) { + const repoSegments = segments.slice(0, cutIndex); + repoSegments[repoSegments.length - 1] = repoSegments[repoSegments.length - 1].replace(/\.git$/i, ""); + return { baseUrl: `${origin}/${repoSegments.join("/")}`, host }; + } + + // Default: strip .git from the last segment + segments[segments.length - 1] = segments[segments.length - 1].replace(/\.git$/i, ""); + return { baseUrl: `${origin}/${segments.join("/")}`, host }; +} + 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