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 <[email protected]>
Цей коміт міститься в:
Deng Liuтаdengliu authored and GitHub committed 2026-11-09 04:57:43 +08:00
1 джерело e0982cc51d
коміт 95047cefb3
3 files changed
+98 -1

No files matched your search

+11
Переглянути файл
@@ -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);
}
+38 -1
Переглянути файл
@@ -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 (
<dd className={clsx(styles.mono, styles.homepage)}>
{isWebUrl ? (
<a
href={value}
className={styles.homepageLink}
onClick={(event) => {
event.preventDefault();
void openExternalUrl(value);
}}
>
{value}
</a>
) : (
value
)}
</dd>
);
}
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 }) {
<dt>
<T>Homepage</T>
</dt>
<dd className={styles.mono}>{plugin.homepage}</dd>
<Homepage value={plugin.homepage} />
</>
)}
</dl>
@@ -503,6 +532,14 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
<T>Code</T>
</dt>
<dd>{entry.hasCode ? tx("WebAssembly component (sandboxed)") : tx("None — contributions only")}</dd>
{entry.homepage && (
<>
<dt>
<T>Homepage</T>
</dt>
<Homepage value={entry.homepage} />
</>
)}
{contributes.length > 0 && (
<>
<dt>
+49
Переглянути файл
@@ -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(<PluginsPage />);
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:[email protected]"])(
"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();