diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 706614794..7a9c72e39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,7 +245,9 @@ jobs: # The published index's revocations, earlier keys and community plugins carry over. curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \ -o "$out/plugins-index.json" || rm -f "$out/plugins-index.json" - "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" + # Downloads point at this release: older versions kept for older IDEs stay where they were published. + "$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" \ + --download-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG" "$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea || echo "::warning::the community plugins were not refreshed" gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz" diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index 0b70494b8..c793e0c60 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -9,9 +9,11 @@ //! proves who published the bytes; whether to trust that publisher is the //! index's call. //! - **Index** (`index.json`): the publishers and their keys (the trust root), -//! the published plugins, and revocations. A publisher owns the id prefix -//! `.`; a revoked key invalidates everything it signed, a -//! revoked plugin version is disabled where it is installed. +//! the published plugin versions, and revocations. A publisher owns the id +//! prefix `.`; a revoked key invalidates everything it signed, a +//! revoked plugin version is disabled where it is installed. A plugin may be +//! listed in several versions, each with the plugin API it needs: the IDE +//! offers the newest one it can load. use crate::MANIFEST_FILE; use base64::Engine as _; @@ -281,6 +283,31 @@ pub struct IndexEntry { pub digest: String, #[serde(default)] pub capabilities: Vec, + /// The plugin API versions it loads in (a semver requirement): its + /// manifest's `engines.openidea` and the API its component was built for. + /// Empty: any. + #[serde(default)] + pub engine: String, +} + +impl IndexEntry { + /// Whether this IDE can load the version. + pub fn compatible(&self) -> bool { + self.engine.is_empty() || crate::engine_compatible(&self.engine, false) + } + + fn semver(&self) -> Option { + semver::Version::parse(&self.version).ok() + } +} + +/// `engines.openidea` of a manifest narrowed to the API its component was built +/// for (`major.minor`, if it has code): the requirement an index lists. +pub fn engine_requirement(manifest_engine: &str, built_for: Option<&semver::Version>) -> String { + match built_for { + Some(v) => format!("{manifest_engine}, ^{}.{}", v.major, v.minor), + None => manifest_engine.to_owned(), + } } /// A plugin (all versions when `versions` is empty) that must not run. @@ -302,6 +329,43 @@ impl Index { Ok(index) } + /// The version of each plugin to offer, and whether this IDE can load it: + /// the newest it can load, else the newest. Revoked versions are skipped; + /// plugins come in the order they are first listed. + pub fn offered(&self) -> Vec<(&IndexEntry, bool)> { + let mut ids: Vec<&str> = Vec::new(); + for e in &self.plugins { + if !ids.contains(&e.id.as_str()) { + ids.push(&e.id); + } + } + ids.into_iter() + .filter_map(|id| { + let versions: Vec<&IndexEntry> = + self.plugins.iter().filter(|e| e.id == id && self.revocation(id, &e.version).is_none()).collect(); + let newest = |loadable: bool| { + versions.iter().copied().filter(|e| !loadable || e.compatible()).max_by_key(|e| e.semver()) + }; + newest(true).map(|e| (e, true)).or_else(|| newest(false).map(|e| (e, false))) + }) + .collect() + } + + /// Drops the versions no IDE would pick: revoked ones, and of each plugin + /// all but the newest version per API requirement. + pub fn prune_versions(&mut self) { + let plugins = std::mem::take(&mut self.plugins); + let mut kept: Vec = Vec::new(); + for e in plugins.into_iter().filter(|e| self.revocation(&e.id, &e.version).is_none()) { + match kept.iter_mut().find(|k| k.id == e.id && k.engine == e.engine) { + Some(k) if e.semver() > k.semver() => *k = e, + Some(_) => {} + None => kept.push(e), + } + } + self.plugins = kept; + } + /// The revocation that applies to `id` at `version`. pub fn revocation(&self, id: &str, version: &str) -> Option<&Revocation> { self.revoked.iter().find(|r| r.id == id && (r.versions.is_empty() || r.versions.iter().any(|v| v == version))) @@ -457,9 +521,54 @@ mod tests { download: format!("https://example.com/{id}.oiplugin"), digest: "d".into(), capabilities: Vec::new(), + engine: String::new(), } } + fn version(id: &str, version: &str, engine: &str) -> IndexEntry { + IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") } + } + + #[test] + fn the_ide_is_offered_the_newest_version_it_can_load() { + let host = crate::ENGINE_VERSION; + let newer = semver::Version::parse(host).unwrap().major + 1; + let mut index = Index { + format: INDEX_FORMAT, + plugins: vec![ + version("acme.a", "1.0.0", &format!("^{host}")), + version("acme.a", "1.2.0", &format!("^{host}")), + version("acme.a", "2.0.0", &format!("^{newer}.0")), + version("acme.b", "0.1.0", &format!("^{newer}.0")), + version("acme.b", "0.2.0", &format!("^{newer}.1")), + version("acme.c", "1.0.0", ""), + version("acme.c", "1.1.0", ""), + ], + revoked: vec![Revocation { id: "acme.c".into(), versions: vec!["1.1.0".into()], reason: "r".into() }], + ..Index::default() + }; + let offered = |index: &Index| { + index.offered().into_iter().map(|(e, ok)| (e.id.clone(), e.version.clone(), ok)).collect::>() + }; + let expected = [ + ("acme.a".to_owned(), "1.2.0".to_owned(), true), + ("acme.b".to_owned(), "0.2.0".to_owned(), false), + ("acme.c".to_owned(), "1.0.0".to_owned(), true), + ]; + assert_eq!(offered(&index), expected); + + // Pruning keeps the newest version per requirement, which changes nothing offered. + index.prune_versions(); + let left: Vec<_> = index.plugins.iter().map(|e| format!("{} {}", e.id, e.version)).collect(); + assert_eq!(left, ["acme.a 1.2.0", "acme.a 2.0.0", "acme.b 0.1.0", "acme.b 0.2.0", "acme.c 1.0.0"]); + assert_eq!(offered(&index), expected); + + let built_for = semver::Version::new(1, 3, 0); + assert_eq!(engine_requirement("^1.0", Some(&built_for)), "^1.0, ^1.3"); + assert_eq!(engine_requirement("^1.0", None), "^1.0"); + assert!(semver::VersionReq::parse(&engine_requirement("^1.0", Some(&built_for))).is_ok()); + } + fn publisher(id: &str) -> Publisher { Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry { key: format!("{id}-key"), revoked: false }] } } diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index 2d54c607e..481a5ed24 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -41,10 +41,11 @@ commands: keygen create a publisher signing key (keep the file secret) pack [dir] [--key F --publisher ID] [--out FILE] write -.oiplugin, signed with the key - index [--publisher ID --name NAME --key F] [--out FILE] + index [--publisher ID --name NAME --key F] [--out FILE] [--download-base URL] write the marketplace index.json for the packages in dir: with a - key, register it and replace that publisher's plugins; without, - replace all plugins, each checked against the index's publishers + key, register it and list that publisher's plugins (earlier + versions stay for older IDEs); without, list only these packages, + each checked against the index's publishers merge --keep ID [--out FILE] replace every publisher but ID in index with those of other-index (a path or URL), their plugins and revocations @@ -335,17 +336,16 @@ fn check_path(path: &Path) -> Result<(), String> { if !path.is_file() { return check(path); } - let scratch = std::env::temp_dir().join(format!("openidea-plugin-unpack-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&scratch); - let result = package::unpack(path, &scratch).and_then(|_| check(&scratch)); - let _ = std::fs::remove_dir_all(&scratch); - result + let tmp = scratch_dir("unpack")?; + let dir = tmp.path().join("package"); + package::unpack(path, &dir).and_then(|_| check(&dir)) } fn check(dir: &Path) -> Result<(), String> { let dir = canonical(dir)?; - let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id())); - let result = (|| { + let tmp = scratch_dir("check")?; + let scratch = tmp.path().join("host"); + (|| { let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .map_err(|e| e.to_string())?; let preview = host.preview_install(&dir).map_err(|e| e.to_string())?; @@ -380,9 +380,7 @@ fn check(dir: &Path) -> Result<(), String> { } println!("ok"); Ok(()) - })(); - let _ = std::fs::remove_dir_all(&scratch); - result + })() } fn human_size(bytes: u64) -> String { @@ -498,14 +496,17 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> { Ok(()) } +/// A fresh scratch directory, removed when dropped. +fn scratch_dir(what: &str) -> Result { + tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string()) +} + /// A scratch host to list a package's files as the IDE would. fn with_scratch_host(f: impl FnOnce(&PluginHost) -> Result) -> Result { - let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id())); - let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()) + let tmp = scratch_dir("host")?; + PluginHost::new(tmp.path().join("host"), Arc::new(|_: Option<&str>, _| {}), Limits::default()) .map_err(|e| e.to_string()) - .and_then(|h| f(&h)); - let _ = std::fs::remove_dir_all(&scratch); - result + .and_then(|h| f(&h)) } fn cmd_pack(args: &[String]) -> Result<(), String> { @@ -541,9 +542,10 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { } fn cmd_index(args: &[String]) -> Result<(), String> { - let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?; + let a = Args::parse(args, &[], &["publisher", "name", "key", "out", "download-base"])?; let dir = canonical(&a.dir()?)?; let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json")); + let base = a.value("download-base").map(|b| b.trim_end_matches('/')); // Revocations and other publishers of an existing index are kept. let mut index = match std::fs::read_to_string(&out) { Ok(text) => Index::parse(&text)?, @@ -565,13 +567,9 @@ fn cmd_index(args: &[String]) -> Result<(), String> { let keys: Vec = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); - index.plugins.retain(|e| e.publisher != publisher); Some(publisher) } - (None, None) => { - index.plugins.clear(); - None - } + (None, None) => None, _ => return Err("--publisher and --key go together".into()), }; let mut packages: Vec = std::fs::read_dir(&dir) @@ -581,9 +579,11 @@ fn cmd_index(args: &[String]) -> Result<(), String> { .filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION)) .collect(); packages.sort(); + let engine = PluginEngine::new()?; + let mut added: Vec = Vec::new(); for path in packages { - let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&scratch); + let tmp = scratch_dir("index")?; + let scratch = tmp.path().join("package"); let entry = (|| { let unpacked = package::unpack(&path, &scratch)?; let m = Manifest::load(&scratch)?; @@ -594,9 +594,17 @@ fn cmd_index(args: &[String]) -> Result<(), String> { if publisher.is_some_and(|p| p != signer) { return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default())); } - if index.plugins.iter().any(|e| e.id == m.id) { - return Err(format!("another package of {} is in the folder", m.id)); + if added.iter().any(|e| (&e.id, &e.version) == (&m.id, &m.version)) { + return Err(format!("another package of {} {} is in the folder", m.id, m.version)); } + if m.legacy { + return Err("plugin.toml uses the 0.1 format; see plugins/README.md".into()); + } + let built_for = match &m.wasm { + Some(wasm) => engine.check_component(&engine.compile(&scratch.join(wasm))?)?, + None => None, + }; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); Ok::<_, String>(IndexEntry { id: m.id.clone(), name: m.name.clone(), @@ -604,16 +612,28 @@ fn cmd_index(args: &[String]) -> Result<(), String> { description: m.description.clone(), authors: m.authors.clone(), publisher: signer, - download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + download: base.map_or_else(|| file.clone(), |b| format!("{b}/{file}")), digest, capabilities: m.capabilities.all().map(|c| c.to_string()).collect(), + engine: package::engine_requirement(&m.engine, built_for.as_ref()), }) })(); - let _ = std::fs::remove_dir_all(&scratch); let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?; - println!(" {} {}", entry.id, entry.version); - index.plugins.push(entry); + println!(" {} {} (plugin API {})", entry.id, entry.version, entry.engine); + added.push(entry); } + // A publisher's earlier versions of these plugins stay for IDEs that can't load the new + // ones (pruned below); plugins no longer in the folder go. + index.plugins.retain(|e| match publisher { + Some(p) => { + e.publisher != p + || (added.iter().any(|n| n.id == e.id) + && !added.iter().any(|n| (&n.id, &n.version) == (&e.id, &e.version))) + } + None => false, + }); + index.plugins.extend(added); + index.prune_versions(); write_index(&index, &out) } @@ -662,22 +682,66 @@ mod tests { /// A theme package `id` signed by `publisher` with `key`, packed into `out`. fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) { - let src = tmp.join("src").join(id); + signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out); + } + + fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) { + let src = tmp.join("src").join(format!("{id}-{version}")); let vars = templates::Vars { id, name: id, sdk: None }; for (rel, content) in templates::render(templates::Template::Theme, &vars) { let path = src.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let content = content + .replace("version = \"0.1.0\"", &format!("version = \"{version}\"")) + .replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\"")); std::fs::write(path, content).unwrap(); } std::fs::create_dir_all(out).unwrap(); let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"] .into_iter() .map(String::from) - .chain([out.join(format!("{id}-0.1.0.oiplugin")).to_string_lossy().into_owned()]) + .chain([out.join(format!("{id}-{version}.oiplugin")).to_string_lossy().into_owned()]) .collect::>(); cmd_pack(&args).unwrap(); } + #[test] + fn a_release_keeps_the_versions_older_ides_need() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + let key = t.join("acme.key"); + std::fs::write(&key, PublisherKey::from_seed([5; 32]).seed_base64()).unwrap(); + let key = key.to_string_lossy().into_owned(); + let index_file = t.join("index.json"); + let release = |n: usize, packages: &[(&str, &str, &str)]| { + let dir = t.join(format!("release-{n}")); + for (id, version, engine) in packages { + signed_theme_version(t, id, version, engine, "acme", &key, &dir); + } + let base = format!("https://example.com/download/v{n}"); + let args = [dir.to_str().unwrap(), "--publisher", "acme", "--key", &key, "--download-base", &base]; + cmd_index(&strings(&[&args[..], &["--out", index_file.to_str().unwrap()]].concat())).unwrap(); + let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap(); + index.plugins.iter().map(|e| format!("{} {} {}", e.id, e.version, e.download)).collect::>() + }; + release(1, &[("acme.a", "1.0.0", "^1.0"), ("acme.b", "1.0.0", "^1.0")]); + // Same requirement: the newer version replaces it; acme.b is gone from the release. + assert_eq!( + release(2, &[("acme.a", "1.1.0", "^1.0")]), + ["acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin"] + ); + // Another API requirement: the last version for the earlier one stays, where it was published. + assert_eq!( + release(3, &[("acme.a", "2.0.0", ">=1.0, <2")]), + [ + "acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin", + "acme.a 2.0.0 https://example.com/download/v3/acme.a-2.0.0.oiplugin", + ] + ); + let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap(); + assert_eq!(index.offered()[0].0.version, "2.0.0", "the newest this IDE can load"); + } + fn strings(args: &[&str]) -> Vec { args.iter().map(|a| a.to_string()).collect() } diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index 4ab77fdb1..2444260e5 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -31,6 +31,8 @@ pub struct MarketPlugin { pub entry: IndexEntry, /// Installed version, if any. pub installed: Option, + /// Whether this IDE can load `entry` (the newest version it can, else the newest). + pub compatible: bool, } /// Reads `url`: `http(s)://…`, `file://…` or a local path. @@ -121,16 +123,14 @@ impl PluginHost { let Some(index) = self.market.read().clone() else { return Vec::new() }; let plugins = self.plugins.read(); index - .plugins - .iter() - .filter(|e| index.revocation(&e.id, &e.version).is_none()) - .cloned() - .map(|entry| { + .offered() + .into_iter() + .map(|(entry, compatible)| { let installed = plugins .get(&entry.id) .filter(|p| p.source == PluginSource::Installed) .map(|p| p.manifest().version.clone()); - MarketPlugin { entry, installed } + MarketPlugin { entry: entry.clone(), installed, compatible } }) .collect() } @@ -141,12 +141,18 @@ impl PluginHost { pub fn download_market_plugin(&self, id: &str) -> IdeResult { let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?; - let entry = index - .plugins - .iter() - .find(|e| e.id == id) - .cloned() + let (entry, compatible) = index + .offered() + .into_iter() + .find(|(e, _)| e.id == id) + .map(|(e, ok)| (e.clone(), ok)) .ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?; + if !compatible { + return Err(IdeError::unavailable(format!( + "{} {} needs plugin API {}: update OpenIDEA", + entry.name, entry.version, entry.engine + ))); + } let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?; let downloads = self.plugins_dir.join(DOWNLOADS); std::fs::create_dir_all(&downloads)?; diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index 540073a57..b6966c6ad 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -150,6 +150,7 @@ fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost download: out.file_name().unwrap().to_string_lossy().into_owned(), digest, capabilities: vec![], + engine: "^1.0".into(), }; (out, entry) } @@ -260,3 +261,28 @@ fn package_files_install_from_disk_unsigned() { let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap(); assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None)); } + +#[test] +fn the_marketplace_offers_the_newest_version_this_ide_can_load() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let key = PublisherKey::from_seed([3; 32]); + let (_, old) = signed_hello(&market, "1.0.0", &key, &f.host); + let (_, new) = signed_hello(&market, "2.0.0", &key, &f.host); + let next_major = ide_extension::ENGINE_VERSION.split('.').next().unwrap().parse::().unwrap() + 1; + let new = IndexEntry { engine: format!("^{next_major}.0"), ..new }; + f.host.set_market_url(Some(write_index(&market, &key, vec![old.clone(), new.clone()], vec![], false))); + + let listed = f.host.refresh_market().unwrap(); + assert_eq!((listed.len(), listed[0].entry.version.as_str(), listed[0].compatible), (1, "1.0.0", true)); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0"); + + // Only a version for a newer IDE: shown as such, not downloaded. + write_index(&market, &key, vec![new], vec![], false); + let listed = f.host.refresh_market().unwrap(); + assert_eq!((listed[0].entry.version.as_str(), listed[0].compatible), ("2.0.0", false)); + let err = f.host.download_market_plugin("acme.hello").unwrap_err(); + assert!(err.message.contains("update OpenIDEA"), "{}", err.message); +} diff --git a/plugins/README.md b/plugins/README.md index 85c9e91cb..54ea96ae0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -778,9 +778,20 @@ and the publisher's key, then shows the install page. their code could run. A publisher rotates keys by adding the new one; older keys stay valid until revoked. -`index` with a key registers the publisher and replaces its plugins, keeping -the other publishers'; without one, it lists every package in the folder, -each checked against the publishers the index already has. `merge` takes the +**Versions.** Each index entry records the plugin API it needs (`engine`: the +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, the newest version per API requirement, so users of an older IDE keep +the last version built for them. + +`index` with a key registers the publisher and lists the folder's packages +as its plugins (their earlier versions stay as above; plugins no longer in the +folder go), keeping the other publishers'; `--download-base` makes downloads +absolute, so a version stays where it was first published. Without a key, it +lists every package in the folder, each checked against the publishers the +index already has. `merge` takes the publishers, plugins and revocations of another index (downloads made absolute) in place of every publisher but `--keep`; `check` also takes a `.oiplugin`. diff --git a/src/bindings.ts b/src/bindings.ts index c78b7e459..0c7576fe7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4139,6 +4139,12 @@ export type IndexEntry = { /** [`package_digest`] of its contents. */ digest: string, capabilities?: string[], + /** + * The plugin API versions it loads in (a semver requirement): its + * manifest's `engines.openidea` and the API its component was built for. + * Empty: any. + */ + engine?: string, }; export type IndexStatus = { @@ -4871,6 +4877,8 @@ export type MarkdownSettings = { export type MarketPlugin = { /** Installed version, if any. */ installed: string | null, + /** Whether this IDE can load `entry` (the newest version it can, else the newest). */ + compatible: boolean, } & IndexEntry; export type Markup = { diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 33a2065a9..502791a37 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -597,7 +597,7 @@ function MarketUrlBar() { } function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) { - const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + const update = entry.compatible && entry.installed && compareVersions(entry.version, entry.installed) > 0; return (
  • ); @@ -627,7 +629,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) { const installing = usePlugins((s) => s.installing); - const update = entry.installed && compareVersions(entry.version, entry.installed) > 0; + const update = entry.compatible && entry.installed && compareVersions(entry.version, entry.installed) > 0; const install = async () => { const id = await installFromMarket(entry.id); if (id) onInstalled(id); @@ -645,7 +647,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
    - {(!entry.installed || update) && ( + {entry.compatible && (!entry.installed || update) && ( @@ -653,6 +655,12 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
    + {!entry.compatible && ( +

    + + {tx`Needs plugin API ${entry.engine}. Update OpenIDEA to install it.`} +

    + )} {entry.description &&

    {entry.description}

    }
    diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index ad701931c..78c500715 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -621,7 +621,9 @@ describe("marketplace", () => { download: "hello.oiplugin", digest: "x", capabilities: ["editor.write"], + engine: "^1.0", installed: null, + compatible: true, }, ], // The memory backend installs its sample packages by path. @@ -644,6 +646,37 @@ describe("marketplace", () => { expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" }); }); + it("shows a plugin this IDE can't load without installing it", async () => { + setPluginsBackend({ + ...createMemoryPluginsBackend([], sampleCatalog()), + marketUrl: async () => "https://plugins.example/index.json", + market: async () => [ + { + id: "acme.next", + name: "Next", + version: "2.0.0", + description: "", + authors: [], + publisher: "acme", + download: "next.oiplugin", + digest: "x", + capabilities: [], + engine: "^9.0", + installed: null, + compatible: false, + }, + ], + }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("tab", { name: /Available/ })); + const list = await screen.findByRole("list", { name: "Available plugins" }); + await waitFor(() => expect(within(list).getByText("Incompatible")).toBeInTheDocument()); + await user.click(within(list).getByText("Next")); + expect(screen.getByRole("note")).toHaveTextContent("Needs plugin API ^9.0. Update OpenIDEA to install it."); + expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument(); + }); + it("offers to roll back an update", async () => { const base = createMemoryPluginsBackend([ plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8bf614a02..763c306df 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6061,5 +6061,7 @@ "Make abstract": "设为抽象", "Keep abstract": "保留抽象声明", "Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。", - "The file changed; open the dialog again": "文件已更改,请重新打开对话框" + "The file changed; open the dialog again": "文件已更改,请重新打开对话框", + "Incompatible": "不兼容", + "Needs plugin API {engine}. Update OpenIDEA to install it.": "需要插件 API {engine}。请更新 OpenIDEA 后再安装。" }