fix(plugins): list the marketplace and install plugins from the welcome screen

The Plugins page on the welcome screen showed "[object Object]" and "There
is no online plugin repository" (even with a marketplace configured), and
Install did nothing.

- Marketplace and package commands (index, download, unpack, install
  preview, theme import) only touch the IDE's own state and are allowed
  without a trusted project.
- Installing, uninstalling, enabling and granting plugins are allowed
  where no project is open; an untrusted project still blocks them.
- Errors rejected as plain backend error objects show their message, and
  a marketplace that couldn't be read shows the error instead of the
  "no online repository" note.
This commit is contained in:
l0ng-ai
2026-11-04 19:55:50 +08:00
parent 82fe52f595
commit 3dc85d4ffe
5 changed files with 63 additions and 5 deletions
+29 -1
View File
@@ -274,6 +274,15 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "plugin_set_theme_frame"
| "plugin_catalog"
| "plugin_assets"
| "plugin_market"
| "plugin_market_url"
| "plugin_set_market_url"
| "plugin_market_download"
| "plugin_unpack_package"
| "plugin_stage_package"
| "plugin_preview_install"
| "plugin_read_intellij_theme"
| "plugin_installed_intellij_themes"
| "notebook_open"
| "notebook_get"
| "notebook_edit"
@@ -319,12 +328,31 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
) || cmd.starts_with("plugin:")
}
/// Commands that change which plugin code runs: allowed where no project is open (the
/// welcome screen), otherwise only in a trusted project.
pub fn projectless_command_allowed(cmd: &str) -> bool {
matches!(
cmd,
"plugin_install_dir"
| "plugin_uninstall"
| "plugin_set_enabled"
| "plugin_grant"
| "plugin_set_capability"
| "plugin_roll_back"
| "plugin_reload"
)
}
impl crate::Host {
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
if restricted_command_allowed(command) {
return Ok(());
}
self.project(window)?.require_trust()
match self.project(window) {
Ok(project) => project.require_trust(),
Err(_) if projectless_command_allowed(command) => Ok(()),
Err(error) => Err(error),
}
}
}
+11 -2
View File
@@ -58,6 +58,11 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"vcs_write_patch",
"remote_bootstrap_list",
"deploy_test_connection",
"plugin_market",
"plugin_market_url",
"plugin_set_market_url",
"plugin_market_download",
"plugin_preview_install",
] {
assert!(ide_host::trust::restricted_command_allowed(command), "{command}");
for window in ["main", "welcome"] {
@@ -72,8 +77,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"tools_list",
"tools_install",
"tools_uninstall",
"plugin_install_dir",
"plugin_reload",
"vcs_git_version",
"docker_compose_op",
"db_execute",
@@ -93,6 +96,12 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
assert!(host.authorize_command(window, command).is_err(), "{window}: {command}");
}
}
// Plugin installs run plugin code: from the welcome screen, but not in an untrusted project.
for command in ["plugin_install_dir", "plugin_uninstall", "plugin_set_enabled", "plugin_reload"] {
assert!(!ide_host::trust::restricted_command_allowed(command), "{command}");
assert!(host.authorize_command("main", command).is_err(), "{command}");
assert!(host.authorize_command("welcome", command).is_ok(), "{command}");
}
// These requests validate the vendor before any network access, independently of project trust.
for window in ["main", "welcome"] {
let error = ide_host::ops::tools::jdk_download_releases(
+1 -1
View File
@@ -924,7 +924,7 @@ export function PluginsPage() {
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
</li>
)}
{!marketUrl && (
{!marketUrl && !marketError && (
<li className={styles.notice} role="note">
<Icon name="info" size={14} />
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
@@ -570,6 +570,21 @@ describe("PluginsPage", () => {
expect(within(list).getAllByText("Installed").length).toBeGreaterThan(0);
expect(notes.at(-1)).toMatchObject({ title: "Installed Toy Language 0.1.0" });
});
it("shows why the marketplace couldn't be read instead of claiming there is none", async () => {
// Commands without a result type reject with the backend's error object, not an Error.
setPluginsBackend({
...createMemoryPluginsBackend([], sampleCatalog()),
marketUrl: () => Promise.reject({ kind: "unavailable", message: "Index unreachable", data: null }),
});
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
expect(await within(list).findByText("Index unreachable")).toBeInTheDocument();
expect(within(list).queryByText("[object Object]")).not.toBeInTheDocument();
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
});
});
describe("secrets", () => {
+7 -1
View File
@@ -54,7 +54,13 @@ export const usePlugins = create<PluginsState>(() => ({
marketError: null,
}));
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** A rejection's message: an `Error`, or an `IdeError` object from a command without a result type. */
export const errorText = (e: unknown): string =>
e instanceof Error
? e.message
: typeof e === "object" && e !== null && "message" in e && typeof e.message === "string"
? e.message
: String(e);
/** Statuses whose contributions are in effect. */
export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";