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();