дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
e0982cc51d
коміт
95047cefb3
3 files changed
+98
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача