mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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:*"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853
|
||||
908c2fd177ad2bb9a8a8492505151c3b81579191156c5ec53f705127f50a8ee9
|
||||
|
||||
@@ -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:*"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
|
||||
64270fa0f5b3084c78764f09b8ce161742fdbff9c28fb90590da39594e558db1
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083
|
||||
22876f842f727a2b9aa292986df3393f5b5f30c5c34319f5665f7d2701c8accf
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
|
||||
14c60782eca79a25cf2c38d58c05be0eb97c4afc607d4cc83302d1445d094aab
|
||||
|
||||
@@ -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"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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 @@
|
||||
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:*"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616
|
||||
d5cf81455ae8b2b0fd6d1402d8775913bd7999db62b9bdf87675890ce52b1e80
|
||||
|
||||
@@ -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:*"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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:*"]
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
3893bd8ad06d3e19c0112081c6643b3bf3c67f5a184098432a6fd338426cd682
|
||||
bbadcaa7a86a3e0a3f8be6d86aed2229d5cdfd128fda3435b4d64c7e19ad1df2
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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": "分类"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user