From 95047cefb395c06ce2d2a09a70a2af6005f53e39 Mon Sep 17 00:00:00 2001 From: Deng Liu Date: Sun, 8 Nov 2026 12:57:43 -0800 Subject: [PATCH] feat(plugins): make Homepage a clickable hyperlink (#2195) A plugin's homepage was plain text in Settings > Plugins, so it could not be opened. It is now a link in both Installed and Available details that opens in the system browser. Only http and https URLs become links; other or malformed values stay plain text, since the value comes unvalidated from the plugin manifest. Long URLs wrap. Co-authored-by: dengliu <5108773+dengliu@users.noreply.github.com> --- src/features/plugins/PluginsPage.module.css | 11 +++++ src/features/plugins/PluginsPage.tsx | 39 ++++++++++++++- .../plugins/__tests__/plugins.test.tsx | 49 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/features/plugins/PluginsPage.module.css b/src/features/plugins/PluginsPage.module.css index 6d1e5b97a..305ad6483 100644 --- a/src/features/plugins/PluginsPage.module.css +++ b/src/features/plugins/PluginsPage.module.css @@ -282,6 +282,17 @@ font-family: var(--font-mono); font-size: var(--fs-small); } +.facts .homepage { + white-space: normal; + overflow-wrap: anywhere; +} +.homepageLink { + color: var(--link); + text-decoration: none; +} +.homepageLink:hover { + text-decoration: underline; +} .muted { color: var(--fg-muted); } diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index a6a8cf584..ec1f9546c 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -55,6 +55,7 @@ import { usePlugins, } from "./store"; import { useProject } from "@/platform/project"; +import { openExternalUrl } from "@/platform/openUrl"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { promptInput } from "@/workbench/inputDialogs"; import styles from "./PluginsPage.module.css"; @@ -328,6 +329,34 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) ); } +function Homepage({ value }: { value: string }) { + let isWebUrl = false; + try { + const { protocol } = new URL(value); + isWebUrl = protocol === "http:" || protocol === "https:"; + } catch { + // Malformed manifest values remain readable as plain text. + } + return ( +
+ {isWebUrl ? ( + { + event.preventDefault(); + void openExternalUrl(value); + }} + > + {value} + + ) : ( + value + )} +
+ ); +} + function InstalledDetails({ plugin }: { plugin: PluginInfo }) { const busy = usePlugins((s) => !!s.busy[plugin.id]); const status = STATUS[plugin.status]; @@ -362,7 +391,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
Homepage
-
{plugin.homepage}
+ )} @@ -503,6 +532,14 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) { Code
{entry.hasCode ? tx("WebAssembly component (sandboxed)") : tx("None — contributions only")}
+ {entry.homepage && ( + <> +
+ Homepage +
+ + + )} {contributes.length > 0 && ( <>
diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 6d23025b5..3a79818ca 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -705,6 +705,55 @@ describe("PluginsPage", () => { expect(screen.getByLabelText("Iconic details").querySelector("circle")).not.toBeNull(); }); + describe.each(["installed", "catalog"] as const)("%s homepage", (source) => { + async function showHomepage(homepage: string) { + const entry = plugin({ id: "homepage", name: "Homepage Example", homepage }); + const catalog = { ...sampleCatalog()[0]!, id: entry.id, name: entry.name, homepage }; + setPluginsBackend(createMemoryPluginsBackend(source === "installed" ? [entry] : [], [catalog])); + const user = userEvent.setup(); + render(); + if (source === "catalog") await user.click(screen.getByRole("tab", { name: /Available/ })); + await screen.findByRole("heading", { level: 2, name: entry.name }); + return user; + } + + it.each(["https://example.com/plugin", "http://example.com/plugin", "HTTPS://example.com/plugin"])( + "opens a web homepage %s with a click or Enter", + async (homepage) => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + const user = await showHomepage(homepage); + const link = screen.getByRole("link", { name: homepage }); + expect(link).toHaveAttribute("href", homepage); + await user.click(link); + expect(open).toHaveBeenCalledWith(homepage, "_blank", "noopener"); + open.mockClear(); + link.focus(); + expect(link).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(open).toHaveBeenCalledWith(homepage, "_blank", "noopener"); + } finally { + open.mockRestore(); + } + }, + ); + + it.each(["not a URL", "https://", "/relative", "file:///tmp/plugin", "javascript:alert(1)", "mailto:a@b.com"])( + "keeps an unsupported or malformed homepage %s as plain text", + async (homepage) => { + const open = vi.spyOn(window, "open").mockImplementation(() => null); + try { + const user = await showHomepage(homepage); + expect(screen.queryByRole("link", { name: homepage })).not.toBeInTheDocument(); + await user.click(screen.getByText(homepage)); + expect(open).not.toHaveBeenCalled(); + } finally { + open.mockRestore(); + } + }, + ); + }); + it("restarts a failed plugin instead of disabling it", async () => { setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })])); const user = userEvent.setup();