feat(plugins): file marketplace plugins under categories

`plugin.toml` takes `categories` (up to 3 of languages, themes, icon-themes,
linters, vcs, ai, frameworks, tools, other). A plugin that declares none is
filed under what its contributions imply (themes, icon themes, languages),
else under other; categories an IDE doesn't know are ignored, and `check`
warns about them. Index entries carry the categories, and Settings › Plugins
› Available filters by them and shows them in a plugin's details. The
official plugins declare theirs.
This commit is contained in:
l0ng-ai
2026-11-04 17:46:51 +08:00
parent af0200e2e7
commit 53aa2d5704
46 changed files with 245 additions and 18 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest,
check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
+61
View File
@@ -35,6 +35,11 @@ pub const MANIFEST_FILE: &str = "plugin.toml";
pub const ENGINE_VERSION: &str = "1.0.0";
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
pub const CATEGORIES: &[&str] =
&["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
/// What a manifest may declare in `categories`.
const MAX_CATEGORIES: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
@@ -47,6 +52,8 @@ pub struct Manifest {
pub license: Option<String>,
/// Release notes of this version (Settings › Plugins › What's New).
pub change_notes: Option<String>,
/// Marketplace categories as declared (see [`Manifest::categories`]).
pub declared_categories: Vec<String>,
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
pub engine: String,
/// Written in the 0.1 format.
@@ -166,6 +173,8 @@ struct Raw {
#[serde(default)]
change_notes: Option<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
engines: Option<Engines>,
/// 0.1 format.
#[serde(default)]
@@ -243,6 +252,7 @@ impl Manifest {
homepage: raw.homepage,
license: raw.license,
change_notes: raw.change_notes,
declared_categories: raw.categories,
engine,
legacy,
wasm: raw.wasm,
@@ -290,6 +300,12 @@ impl Manifest {
return Err(format!("duplicate activation event {event}"));
}
}
if self.declared_categories.len() > MAX_CATEGORIES {
return Err(format!("`categories` lists at most {MAX_CATEGORIES}"));
}
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
for (point, decls) in &self.contributes {
validate_point_id(point)?;
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
@@ -325,6 +341,33 @@ impl Manifest {
}
}
impl Manifest {
/// Its marketplace categories, in [`CATEGORIES`] order: those declared that
/// this IDE knows; without any, those its contributions imply (themes,
/// icon themes, languages), else `other`.
pub fn categories(&self) -> Vec<String> {
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
let mut out: Vec<String> = pick(&|c| self.declared_categories.iter().any(|d| d == c));
if out.is_empty() {
out = pick(&|c| match c {
"themes" => self.contributes.contains_key("themes"),
"icon-themes" => self.contributes.contains_key("iconThemes"),
"languages" => self.contributes.contains_key("languages"),
_ => false,
});
}
if out.is_empty() {
out.push("other".into());
}
out
}
/// Declared categories this IDE doesn't know (ignored).
pub fn unknown_categories(&self) -> Vec<&str> {
self.declared_categories.iter().map(String::as_str).filter(|c| !CATEGORIES.contains(c)).collect()
}
}
/// Whether this host can load a plugin written for `engine`.
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
if legacy {
@@ -417,6 +460,24 @@ mod tests {
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
}
#[test]
fn categories_are_declared_or_implied() {
let head = "id = \"acme.x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
let m = |rest: &str| Manifest::parse(&format!("{head}{rest}"));
// Declared, in the catalogue's order; unknown ones (a newer IDE's) are ignored.
let declared = m("categories = [\"tools\", \"vcs\", \"robots\"]\n").unwrap();
assert_eq!(declared.categories(), ["vcs", "tools"]);
assert_eq!(declared.unknown_categories(), ["robots"]);
// None declared: implied by the contributions, else `other`.
let theme = m("[[contributes.themes]]\nid = \"t\"\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
assert_eq!(theme.categories(), ["languages", "themes"]);
let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
assert_eq!(declared_wins.categories(), ["tools"]);
assert_eq!(m("").unwrap().categories(), ["other"]);
assert!(m("categories = [\"a\", \"b\", \"c\", \"d\"]\n").unwrap_err().contains("at most 3"));
assert!(m("categories = [\"Bad Name\"]\n").is_err());
}
#[test]
fn parses_the_02_format() {
let m = Manifest::parse(FULL).unwrap();
+4
View File
@@ -288,6 +288,9 @@ pub struct IndexEntry {
/// Empty: any.
#[serde(default)]
pub engine: String,
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
#[serde(default)]
pub categories: Vec<String>,
}
impl IndexEntry {
@@ -522,6 +525,7 @@ mod tests {
digest: "d".into(),
capabilities: Vec::new(),
engine: String::new(),
categories: Vec::new(),
}
}
@@ -364,6 +364,10 @@ fn check(dir: &Path) -> Result<(), String> {
} else {
println!(" code: none (contributions only)");
}
println!(" categories: {}", manifest.categories().join(", "));
for c in manifest.unknown_categories() {
println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", "));
}
if !manifest.activation.is_empty() {
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
@@ -616,6 +620,7 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
digest,
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
categories: m.categories(),
})
})();
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
@@ -790,6 +795,7 @@ mod tests {
let index = Index::parse(&std::fs::read_to_string(&official_index).unwrap()).unwrap();
let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect();
assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]);
assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme");
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
@@ -82,6 +82,9 @@ name = "__NAME__"
version = "0.1.0"
description = "Adds two sample commands."
authors = []
# Marketplace categories (up to 3): languages, themes, icon-themes, linters,
# vcs, ai, frameworks, tools, other.
categories = ["tools"]
engines.openidea = "^1.0"
# Built by `openidea-plugin build`. The code is loaded the first time one of
# its commands runs; the commands below show in Find Action before that.
+1
View File
@@ -2214,6 +2214,7 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
homepage: None,
license: None,
change_notes: None,
declared_categories: Vec::new(),
engine: ide_extension::ENGINE_VERSION.into(),
legacy: false,
wasm: None,
+1
View File
@@ -151,6 +151,7 @@ fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost
digest,
capabilities: vec![],
engine: "^1.0".into(),
categories: vec!["tools".into()],
};
(out, entry)
}
+9 -1
View File
@@ -79,6 +79,7 @@ authors = ["You <you@example.com>"]
homepage = "https://example.com" # optional
license = "MIT" # optional
change-notes = "1.2.0 — …" # shown under What's New
categories = ["tools"] # marketplace categories, see below
engines.openidea = "^1.0" # the plugin API this package needs
wasm = "dist/plugin.wasm" # omit for data-only plugins
activation = ["on-language:markdown"] # optional, see below
@@ -94,6 +95,12 @@ category = "Word Tools" # group in Search Everywhere / Keymap
when = "editorFocus" # optional: same expressions as built-in actions
```
`categories` (up to 3) file the plugin in the marketplace: `languages`,
`themes`, `icon-themes`, `linters` (linters and formatters), `vcs`, `ai`,
`frameworks`, `tools`, `other`. Without them, a plugin contributing themes,
icon themes or languages is filed under those, any other under `other`.
Categories an IDE doesn't know are ignored (`check` warns about them).
All paths are relative to the package directory and may not leave it. A
manifest is validated as a whole: if one contribution is invalid, nothing is
installed or loaded. Contribution types the IDE does not know (a newer API) are
@@ -782,7 +789,8 @@ and the publisher's key, then shows the install page.
manifest's `engines.openidea`, narrowed to the API its component was built
for), and a plugin may be listed in several versions. Settings › Plugins
offers the newest version this IDE can load; when there is none, it shows the
plugin as incompatible and says which API it needs. Indexing keeps, of each
plugin as incompatible and says which API it needs. Entries also carry the
plugin's categories, which Settings › Plugins › Available filters by. Indexing keeps, of each
plugin, the newest version per API requirement, so users of an older IDE keep
the last version built for them.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283
aa09a92c0ba9d85a01c56bbf4455e40ae44f005b443ce1002bbe8011408ff975
@@ -5,6 +5,7 @@ description = "Inline code suggestions (ghost text) from a local model (Ollama)
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: local (Ollama) and OpenAI backends; the key stays in the system keychain."
categories = ["ai"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853
908c2fd177ad2bb9a8a8492505151c3b81579191156c5ec53f705127f50a8ee9
+1
View File
@@ -5,6 +5,7 @@ description = "Checks Java files with Checkstyle when you save them: problems in
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: check on save (a newer save cancels the running check), Google style by default, quick fixes."
categories = ["linters"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-event:file-saved", "on-command:*"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
64270fa0f5b3084c78764f09b8ce161742fdbff9c28fb90590da39594e558db1
+1
View File
@@ -5,6 +5,7 @@ description = "Edit CSV and TSV files as a table. Edits go to the file's text, s
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: table editor for .csv and .tsv with cell editing, inserting and deleting rows; large files load the visible rows only."
categories = ["tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:table"]
+1
View File
@@ -5,6 +5,7 @@ description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is t
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: the Excalidraw canvas next to (or instead of) the file's text, kept in step both ways; export to PNG and SVG through the save dialog; a file icon and a New › Excalidraw Drawing template. Based on the Excalidraw editor proposed by Deng Liu (#738)."
categories = ["tools"]
engines.openidea = "^1.0"
[capabilities]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083
22876f842f727a2b9aa292986df3393f5b5f30c5c34319f5665f7d2701c8accf
+1
View File
@@ -5,6 +5,7 @@ description = "Fetches your repositories in the background and shows the branch
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: auto-fetch every 5 minutes, ahead/behind in the status bar, Fetch Now."
categories = ["vcs"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# Fetches on schedule (also after being reclaimed while idle) and refreshes
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
14c60782eca79a25cf2c38d58c05be0eb97c4afc607d4cc83302d1445d094aab
+1
View File
@@ -5,6 +5,7 @@ description = "Graphviz DOT files (.dot, .gv) with a live preview beside the tex
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: live preview (Graphviz compiled to WebAssembly), layout engines, zoom, errors as problems, SVG and PNG export."
categories = ["languages", "tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:preview"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980
7f5aacde2b8a8bcee5365c8cbda91045195688899c6537dc2159037cca74a672
@@ -5,6 +5,7 @@ description = "Shows the keyboard shortcut of an action you ran with the mouse,
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: shortcut tips, statistics, per-action \"don't show again\"."
categories = ["tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# Actions arrive in batches (a few seconds apart while the plugin is idle),
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3
45a7ee993b5c234b0d25e5b05ef60e714fa3edf63d6bd0d6a29b13587c8d09ce
@@ -5,6 +5,7 @@ description = "Keeps a copyright and SPDX license header at the top of source fi
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: missing headers reported on open and save, Add License Header quick fix and command, project scan in the background."
categories = ["tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616
d5cf81455ae8b2b0fd6d1402d8775913bd7999db62b9bdf87675890ce52b1e80
+1
View File
@@ -5,6 +5,7 @@ description = "Jumps between MyBatis mapper interfaces and their XML statements
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: navigation both ways, line markers, id and parameter completion."
categories = ["frameworks"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d2e35f3e4ee6b699899ea43fdf96d5ec99f0730c502f94fd6f3246e605be99a7
227c8150c6ad7a5a20236764f149c876cbbc3b082763bf2e7fc05de99726b740
@@ -5,6 +5,7 @@ description = "Try a regular expression against sample text: matches highlighted
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: live matching with flags, capture groups, replacement preview, the editor's selection as test text."
categories = ["tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:regex", "on-command:*"]
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682
bbadcaa7a86a3e0a3f8be6d86aed2229d5cdfd128fda3435b4d64c7e19ad1df2
+1
View File
@@ -5,6 +5,7 @@ description = "Solidity support: file detection, highlighting, structure view an
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: .sol detection, highlighting, structure view, folding; language server via @nomicfoundation/solidity-language-server or solc."
categories = ["languages"]
engines.openidea = "^1.0"
# Highlighting, structure and folding come from this component; it is loaded
# when the first Solidity file is opened.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
a88c39d5782369329efa55b12330daa906e5c8d91252bd57c7343648840bac79
09cc92e8e731f8f59f3857a71f9a30cc3d9e04a1c32092920f573b0f26592855
@@ -5,6 +5,7 @@ description = "Case conversion (camelCase, snake_case, kebab-case…), encoding
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: 26 commands in four groups."
categories = ["tools"]
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# The commands are declared below, so Find Action and the keymap list them
+2
View File
@@ -4145,6 +4145,8 @@ export type IndexEntry = {
* Empty: any.
*/
engine?: string,
/** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */
categories?: string[],
};
export type IndexStatus = {
+84 -3
View File
@@ -18,6 +18,7 @@ import {
IconButton,
Input,
SearchField,
Select,
Spinner,
Tabs,
showContextMenu,
@@ -572,6 +573,71 @@ function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuIte
];
}
const CATEGORIES = [
"languages",
"themes",
"icon-themes",
"linters",
"vcs",
"ai",
"frameworks",
"tools",
"other",
] as const;
type Category = (typeof CATEGORIES)[number];
function categoryLabel(c: Category): string {
switch (c) {
case "languages":
return tx("Languages");
case "themes":
return tx("Themes");
case "icon-themes":
return tx("Icon Themes");
case "linters":
return tx("Linters & Formatters");
case "vcs":
return tx("Version Control");
case "ai":
return tx("AI");
case "frameworks":
return tx("Frameworks");
case "tools":
return tx("Tools");
case "other":
return tx("Other");
}
}
/** A marketplace plugin's categories this IDE knows; `other` without any. */
function categoriesOf(m: MarketPlugin): Category[] {
const known = CATEGORIES.filter((c) => m.categories?.includes(c));
return known.length > 0 ? known : ["other"];
}
/** Filters the marketplace by category; lists the categories that have plugins. */
function CategoryFilter({
market,
value,
onChange,
}: {
market: readonly MarketPlugin[];
value: Category | "all";
onChange: (value: Category | "all") => void;
}) {
const counts = new Map<Category, number>();
for (const m of market) for (const c of categoriesOf(m)) counts.set(c, (counts.get(c) ?? 0) + 1);
const options = [
{ value: "all" as const, label: tx("All Categories"), detail: String(market.length) },
...CATEGORIES.filter((c) => counts.has(c)).map((c) => ({
value: c,
label: categoryLabel(c),
detail: String(counts.get(c)),
})),
];
return <Select aria-label={tx("Category")} value={value} options={options} onChange={onChange} />;
}
/** The marketplace index URL, editable in place. */
function MarketUrlBar() {
const current = usePlugins((s) => s.marketUrl);
@@ -667,6 +733,10 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
<T>Id</T>
</dt>
<dd className={styles.mono}>{entry.id}</dd>
<dt>
<T>Category</T>
</dt>
<dd>{categoriesOf(entry).map(categoryLabel).join(", ")}</dd>
{entry.installed && (
<>
<dt>
@@ -706,13 +776,15 @@ export function PluginsPage() {
void refreshMarket(false);
}, []);
const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null);
const [category, setCategory] = useState<Category | "all">("all");
const marketShown = useMemo(
() =>
(market ?? []).filter((m) => {
const q = query.trim().toLowerCase();
if (category !== "all" && !categoriesOf(m).includes(category)) return false;
return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q);
}),
[market, query],
[market, query, category],
);
const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null;
@@ -722,7 +794,11 @@ export function PluginsPage() {
const linked = shown.filter((p) => p.source === "linked");
const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null;
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
// Local packages have no categories: shown only without a category filter.
const catalogShown = useMemo(
() => (category === "all" ? filterCatalog(catalog ?? [], query) : []),
[catalog, query, category],
);
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
const showInstalled = (id: string | null) => {
@@ -827,6 +903,11 @@ export function PluginsPage() {
<li className={styles.marketItem}>
<MarketUrlBar />
</li>
{(market?.length ?? 0) > 0 && (
<li className={styles.marketItem}>
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
</li>
)}
{!marketUrl && (
<li className={styles.notice} role="note">
<Icon name="info" size={14} />
@@ -855,7 +936,7 @@ export function PluginsPage() {
}}
/>
))}
{catalog && catalogShown.length === 0 && (
{catalog && category === "all" && catalogShown.length === 0 && (
<li className={styles.listEmpty}>
{catalog.length === 0 ? tx("No local plugin packages.") : tx("No plugins match.")}
</li>
@@ -646,6 +646,48 @@ describe("marketplace", () => {
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
});
it("filters the marketplace by category", async () => {
const entry = (id: string, name: string, categories: string[]) => ({
id,
name,
version: "1.0.0",
description: "",
authors: [],
publisher: "acme",
download: `${id}.oiplugin`,
digest: "x",
capabilities: [],
engine: "^1.0",
categories,
installed: null,
compatible: true,
});
setPluginsBackend({
...createMemoryPluginsBackend([], sampleCatalog()),
marketUrl: async () => "https://plugins.example/index.json",
market: async () => [
entry("acme.dusk", "Dusk", ["themes"]),
entry("acme.lint", "Lint", ["linters", "tools"]),
entry("acme.misc", "Misc", ["robots"]),
],
});
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Dusk")).toBeInTheDocument());
await user.click(within(list).getByRole("button", { name: "Category" }));
const options = within(screen.getByRole("listbox"))
.getAllByRole("option")
.map((o) => o.textContent);
expect(options).toEqual(["All Categories3", "Themes1", "Linters & Formatters1", "Tools1", "Other1"]);
await user.click(within(screen.getByRole("listbox")).getByText("Tools"));
expect(within(list).queryByText("Dusk")).not.toBeInTheDocument();
expect(within(list).queryByText("Local")).not.toBeInTheDocument();
await user.click(within(list).getByText("Lint"));
expect(screen.getByText("Linters & Formatters, Tools")).toBeInTheDocument();
});
it("shows a plugin this IDE can't load without installing it", async () => {
setPluginsBackend({
...createMemoryPluginsBackend([], sampleCatalog()),
+7 -1
View File
@@ -6063,5 +6063,11 @@
"Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。",
"The file changed; open the dialog again": "文件已更改,请重新打开对话框",
"Incompatible": "不兼容",
"Needs plugin API {engine}. Update OpenIDEA to install it.": "需要插件 API {engine}。请更新 OpenIDEA 后再安装。"
"Needs plugin API {engine}. Update OpenIDEA to install it.": "需要插件 API {engine}。请更新 OpenIDEA 后再安装。",
"Icon Themes": "图标主题",
"Linters & Formatters": "检查与格式化",
"AI": "AI",
"Frameworks": "框架",
"All Categories": "全部分类",
"Category": "分类"
}