mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c71cf8b6a | ||
|
|
e3c7f362c4 | ||
|
|
02addfa2c5 | ||
|
|
82fe52f595 | ||
|
|
d94a117d69 | ||
|
|
b6b0ce46c3 | ||
|
|
953e844541 |
No files matched your search
@@ -0,0 +1,46 @@
|
||||
name: Plugin index
|
||||
|
||||
# Refreshes the community plugins in the latest release's plugins-index.json
|
||||
# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The
|
||||
# official plugins, their key and revocations stay as the release wrote them.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
# Sent by the registry when its index changes.
|
||||
repository_dispatch:
|
||||
types: [community-plugins]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: plugins-index
|
||||
|
||||
env:
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RUNNER_TEMP"
|
||||
tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
|
||||
tool=openidea-plugin-linux-x86_64
|
||||
if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then
|
||||
echo "::notice::$tag has no plugin index or openidea-plugin tool yet"
|
||||
exit 0
|
||||
fi
|
||||
gunzip "$tool.gz" && chmod +x "$tool"
|
||||
cp plugins-index.json before.json
|
||||
"./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea
|
||||
if cmp -s before.json plugins-index.json; then
|
||||
echo "unchanged"
|
||||
else
|
||||
gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
fi
|
||||
@@ -18,6 +18,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -35,6 +37,20 @@ jobs:
|
||||
echo "::error::tag $TAG does not match tauri.conf.json version $app"
|
||||
exit 1
|
||||
fi
|
||||
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
|
||||
# `openidea-plugin keygen` writes), built into the app: their packages
|
||||
# must carry its signature whatever the marketplace index lists. Without
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -57,6 +73,8 @@ jobs:
|
||||
build:
|
||||
needs: create-release
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +202,11 @@ jobs:
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on.
|
||||
# writes) nothing is published and the release goes on. The index also lists
|
||||
# the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed
|
||||
# between releases by plugins-index.yml), and the release carries the
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
@@ -237,11 +259,17 @@ jobs:
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations and earlier keys carry over.
|
||||
# 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"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
|
||||
# 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"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
|
||||
@@ -31,5 +31,10 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
Plugins: pull requests here add or change official plugins (`plugins/official/`)
|
||||
and the plugin API. Community plugins live in their authors' repositories and
|
||||
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
|
||||
see [`plugins/README.md`](plugins/README.md) § 8.1.
|
||||
|
||||
A check on each PR adds `needs-info` when the issue link or the verification
|
||||
section is missing, and removes it once they're filled in.
|
||||
Generated
+2
@@ -4276,6 +4276,7 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4340,6 +4341,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -58,6 +58,8 @@ pub(crate) const LIMITS_UPDATE: &str = "openidea_limits";
|
||||
const MAX_EMBED: u64 = 256 * 1024;
|
||||
/// How long a probe waits for the agent to answer `initialize`.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// How long deleting a task waits for its agent to exit before removing the worktree.
|
||||
const EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The agent tasks of one project.
|
||||
pub struct AgentManager {
|
||||
@@ -839,7 +841,7 @@ impl AgentManager {
|
||||
/// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays).
|
||||
pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> {
|
||||
let task = self.task(id)?;
|
||||
task.kill();
|
||||
let live = task.kill();
|
||||
let (isolation, cwd, root) = {
|
||||
let s = task.state.lock();
|
||||
(
|
||||
@@ -851,6 +853,11 @@ impl AgentManager {
|
||||
// A copy of the task (a fork) may work in the same worktree.
|
||||
let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd);
|
||||
if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() {
|
||||
// On Windows the agent's processes lock the folder (their working directory,
|
||||
// open files) until they have exited.
|
||||
if let Some(peer) = live {
|
||||
let _ = tokio::time::timeout(EXIT_WAIT, peer.exited()).await;
|
||||
}
|
||||
let worktrees = self.shared.paths.worktrees_dir.clone();
|
||||
blocking(move || worktree::remove(&root, &worktrees, &cwd)).await?;
|
||||
}
|
||||
@@ -1605,11 +1612,12 @@ impl Task {
|
||||
}
|
||||
}
|
||||
|
||||
fn kill(&self) {
|
||||
/// Stops the agent; returns its connection, to wait for its process to exit.
|
||||
fn kill(&self) -> Option<acp::Conn> {
|
||||
self.cancel_permissions();
|
||||
if let Some(live) = self.live.lock().take() {
|
||||
live.peer.kill();
|
||||
}
|
||||
let live = self.live.lock().take()?;
|
||||
live.peer.kill();
|
||||
Some(live.peer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1109,3 +1109,114 @@ async fn idle_agents_stop_and_resume_their_session_on_the_next_message() {
|
||||
assert!(live(&mgr, &task.id));
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
/// Deleting a task whose agent still runs in the worktree removes both and leaves no
|
||||
/// process behind, whether it is waiting for permission or idle without session/load.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn deleting_a_task_waits_for_its_agent_to_exit_before_removing_the_worktree() {
|
||||
for no_load in [false, true] {
|
||||
delete_running_agent(no_load).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_running_agent(no_load: bool) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(&root)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
|
||||
let agent = Arc::new(FakeAgent { no_load, ..FakeAgent::default() });
|
||||
let pids: Arc<Mutex<Vec<u32>>> = Arc::default();
|
||||
let (fake, started) = (agent.clone(), pids.clone());
|
||||
// The fake agent, served on behalf of a real process that exits only when killed.
|
||||
let connector: Connector = Arc::new(move |_spec, cwd, client, _stderr| {
|
||||
#[cfg(unix)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
cmd
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut cmd = tokio::process::Command::new("cmd");
|
||||
cmd.args(["/D", "/C", "echo ready>agent-ready & ping -n 30 127.0.0.1 >nul"]);
|
||||
cmd
|
||||
};
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.current_dir(cwd).kill_on_drop(true);
|
||||
let child = cmd.spawn()?;
|
||||
started.lock().push(child.id().unwrap());
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
let (br, bw) = tokio::io::split(b);
|
||||
// `connect_with_child` frames with Content-Length.
|
||||
fake.servers.lock().push(Peer::connect(br, bw, fake.clone()));
|
||||
Ok(Peer::connect_with_child(ar, aw, client, child))
|
||||
});
|
||||
let mgr = AgentManager::with_connector(
|
||||
AgentPaths {
|
||||
project_root: root.clone(),
|
||||
store_dir: root.join("store"),
|
||||
worktrees_dir: root.join("worktrees"),
|
||||
models_file: root.join("models.json"),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text(if no_load { "again" } else { "Fix the lexer" }),
|
||||
provider: "claude".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Worktree,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, if no_load { AgentStatus::Ready } else { AgentStatus::NeedsInput }).await;
|
||||
let pid = *pids.lock().last().expect("the agent was started");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !task.cwd.0.join("agent-ready").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::rename(&task.cwd.0, task.cwd.0.with_extension("moved")).unwrap_err().raw_os_error(),
|
||||
Some(32),
|
||||
"the running agent really locks its worktree"
|
||||
);
|
||||
}
|
||||
|
||||
mgr.delete(&task.id, true).await.unwrap();
|
||||
assert!(!task.cwd.0.exists());
|
||||
// Killed and reaped: not even a zombie is left.
|
||||
#[cfg(unix)]
|
||||
let alive = std::process::Command::new("kill").args(["-0", &pid.to_string()]).status().unwrap().success();
|
||||
#[cfg(windows)]
|
||||
let alive = {
|
||||
let mut cmd = std::process::Command::new("tasklist");
|
||||
ide_core::process::prepare(&mut cmd).args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]);
|
||||
String::from_utf8_lossy(&cmd.output().unwrap().stdout).contains(&format!("\"{pid}\""))
|
||||
};
|
||||
assert!(!alive, "the agent (pid {pid}) is still running");
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
pub(crate) struct Created {
|
||||
pub path: PathBuf,
|
||||
@@ -65,7 +65,27 @@ pub(crate) fn remove(project_root: &Path, worktrees_dir: &Path, path: &Path) ->
|
||||
if !path.starts_with(worktrees_dir) || path == worktrees_dir {
|
||||
return Err(err);
|
||||
}
|
||||
std::fs::remove_dir_all(path).map_err(|e| IdeError::internal(e.to_string()))
|
||||
retrying(REMOVE_ATTEMPTS, REMOVE_PAUSE, || std::fs::remove_dir_all(path))
|
||||
.map_err(|e| IdeError::internal(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// On Windows a folder can't be deleted while a process still has a file in it open
|
||||
/// (os error 32): an agent's helper that outlived it, an antivirus scan, the search indexer.
|
||||
/// Those let go within moments, so removing is retried for a few seconds.
|
||||
const REMOVE_ATTEMPTS: u32 = 10;
|
||||
const REMOVE_PAUSE: Duration = Duration::from_millis(300);
|
||||
|
||||
fn retrying(attempts: u32, pause: Duration, mut op: impl FnMut() -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
for _ in 1..attempts {
|
||||
match op() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => std::thread::sleep(pause),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
match op() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files under `cwd` that differ from `base` (untracked files included), with line counts.
|
||||
@@ -149,6 +169,54 @@ fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removing_is_retried_while_a_file_is_still_in_use() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
// A sharing violation as Windows reports it (os error 32), a vanished folder, other errors.
|
||||
let in_use = || Error::from_raw_os_error(32);
|
||||
for (fails, outcome) in [(0, true), (3, true), (REMOVE_ATTEMPTS - 1, true), (REMOVE_ATTEMPTS, false)] {
|
||||
let mut calls = 0;
|
||||
let r = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls <= fails { Err(in_use()) } else { Ok(()) }
|
||||
});
|
||||
assert_eq!(r.is_ok(), outcome, "{fails} failures");
|
||||
assert_eq!(calls, (fails + 1).min(REMOVE_ATTEMPTS));
|
||||
}
|
||||
let mut calls = 0;
|
||||
let gone = retrying(REMOVE_ATTEMPTS, Duration::ZERO, || {
|
||||
calls += 1;
|
||||
if calls == 1 { Err(in_use()) } else { Err(Error::from(ErrorKind::NotFound)) }
|
||||
});
|
||||
assert!(gone.is_ok(), "deleted meanwhile: done");
|
||||
assert_eq!(calls, 2);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn removing_waits_for_a_windows_file_lock_to_be_released() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("repo");
|
||||
std::fs::create_dir(&repo).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
let worktrees = tmp.path().join("wt");
|
||||
let created = create(&repo, &worktrees, "Locked file", None).unwrap();
|
||||
let file = std::fs::OpenOptions::new().read(true).share_mode(0).open(created.path.join("a.txt")).unwrap();
|
||||
assert_eq!(std::fs::remove_file(created.path.join("a.txt")).unwrap_err().raw_os_error(), Some(32));
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
drop(file);
|
||||
});
|
||||
let result = remove(&repo, &worktrees, &created.path);
|
||||
release.join().unwrap();
|
||||
result.unwrap();
|
||||
assert!(!created.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_branch_safe() {
|
||||
assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns");
|
||||
|
||||
@@ -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, MAX_CATEGORIES,
|
||||
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"];
|
||||
/// Categories a plugin is listed under; later ones are ignored.
|
||||
pub 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,9 @@ impl Manifest {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
}
|
||||
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 +338,39 @@ impl Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Its marketplace categories, in [`CATEGORIES`] order: the first
|
||||
/// [`MAX_CATEGORIES`] 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 known: Vec<&str> = Vec::new();
|
||||
for d in &self.declared_categories {
|
||||
if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
|
||||
known.push(d);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<String> = pick(&|c| known.contains(&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 +463,26 @@ 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"]);
|
||||
// At most three: the first ones declared.
|
||||
let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
|
||||
assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
|
||||
assert!(m("categories = [\"Bad Name\"]\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_02_format() {
|
||||
let m = Manifest::parse(FULL).unwrap();
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
//! 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
|
||||
//! `<publisher>.`; 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 `<publisher>.`; 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.
|
||||
//! - **Key continuity**: an installed plugin updates silently only to a
|
||||
//! package signed with the same key, or with a key the installed one
|
||||
//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
|
||||
//! is the user's call. An IDE can pin a publisher to keys it was built with
|
||||
//! ([`Index::pin_publisher`]): the index then can't replace them.
|
||||
//! - **Format**: fields are only ever added (IDEs ignore those they don't
|
||||
//! know); `format` grows only for changes older IDEs can't read, and they
|
||||
//! still apply such an index's revocations ([`Index::parse`]).
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use base64::Engine as _;
|
||||
@@ -40,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
fn endorsement_message(publisher: &str, key: &str) -> Vec<u8> {
|
||||
format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
|
||||
}
|
||||
|
||||
/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
|
||||
/// `message`; malformed keys and signatures are errors.
|
||||
fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result<bool, String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] =
|
||||
B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
|
||||
}
|
||||
|
||||
/// `SIGNATURE.json` of a signed package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -74,6 +102,12 @@ impl PublisherKey {
|
||||
B64.encode(self.0.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
|
||||
pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
|
||||
let sig = self.0.sign(&endorsement_message(publisher, key));
|
||||
Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
|
||||
}
|
||||
|
||||
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
|
||||
let sig = self.0.sign(&signed_message(id, version, digest));
|
||||
PackageSignature {
|
||||
@@ -88,19 +122,11 @@ impl PackageSignature {
|
||||
/// Whether the signature is valid for `id`, `version` and `digest` (the
|
||||
/// key's validity is checked against an [`Index`]).
|
||||
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(&self.key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] = B64
|
||||
.decode(&self.signature)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
|
||||
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
|
||||
if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the signature does not match the package (it was changed after signing)".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +268,10 @@ pub struct Index {
|
||||
pub plugins: Vec<IndexEntry>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revocation>,
|
||||
/// The marketplace moved: its index is at this URL (relative to this
|
||||
/// one, or absolute), and the IDE reads that one instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
@@ -253,7 +283,7 @@ pub struct Publisher {
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyEntry {
|
||||
/// Ed25519 public key, base64.
|
||||
@@ -261,6 +291,27 @@ pub struct KeyEntry {
|
||||
/// Packages signed with it are no longer trusted.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
/// The publisher's earlier key vouching for this one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endorsement: Option<Endorsement>,
|
||||
}
|
||||
|
||||
impl KeyEntry {
|
||||
pub fn new(key: String) -> Self {
|
||||
Self { key, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A publisher's earlier key vouching for a newer one: plugins installed from
|
||||
/// packages signed with the earlier key update to ones signed with the newer
|
||||
/// key without asking the user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Endorsement {
|
||||
/// The endorsing key, base64.
|
||||
pub key: String,
|
||||
/// Its Ed25519 signature of `openidea-key/1\n<publisher>\n<endorsed key>`, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
@@ -281,6 +332,34 @@ pub struct IndexEntry {
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
/// The plugin API versions it loads in (a semver requirement): its
|
||||
/// manifest's `engines.openidea` and the API its component was built for.
|
||||
/// Required: an entry without one is offered to no IDE.
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
}
|
||||
|
||||
impl IndexEntry {
|
||||
/// Whether this IDE can load the version.
|
||||
pub fn compatible(&self) -> bool {
|
||||
crate::engine_compatible(&self.engine, false)
|
||||
}
|
||||
|
||||
fn semver(&self) -> Option<semver::Version> {
|
||||
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.
|
||||
@@ -294,12 +373,71 @@ pub struct Revocation {
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses an index. One in a newer format keeps what must reach every IDE —
|
||||
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
|
||||
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
if index.format != INDEX_FORMAT {
|
||||
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
|
||||
if format == u64::from(INDEX_FORMAT) {
|
||||
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
|
||||
}
|
||||
Ok(index)
|
||||
if format < u64::from(INDEX_FORMAT) {
|
||||
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
|
||||
}
|
||||
fn each<T: serde::de::DeserializeOwned>(value: &serde_json::Value, field: &str) -> Vec<T> {
|
||||
let items = value.get(field).and_then(serde_json::Value::as_array);
|
||||
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
|
||||
}
|
||||
Ok(Index {
|
||||
format: u32::try_from(format).unwrap_or(u32::MAX),
|
||||
publishers: each(&value, "publishers"),
|
||||
plugins: Vec::new(),
|
||||
revoked: each(&value, "revoked"),
|
||||
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Written in a format newer than this IDE reads: it lists no plugins.
|
||||
pub fn is_newer(&self) -> bool {
|
||||
self.format > INDEX_FORMAT
|
||||
}
|
||||
|
||||
/// 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<IndexEntry> = 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`.
|
||||
@@ -335,6 +473,127 @@ impl Index {
|
||||
sig.check(id, version, digest)?;
|
||||
Ok(publisher.id.clone())
|
||||
}
|
||||
|
||||
/// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
|
||||
/// directly or through keys it endorsed. Endorsements by revoked keys don't
|
||||
/// count: whoever stole a key could vouch for their own.
|
||||
pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
|
||||
let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
|
||||
let mut key = to;
|
||||
// Each step moves to an earlier key; more steps than keys is a loop.
|
||||
for _ in 0..=keys.len() {
|
||||
if key == from {
|
||||
return true;
|
||||
}
|
||||
let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
|
||||
if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
key = &e.key;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Makes `roots` (base64) the keys `publisher` signs with, whatever this
|
||||
/// index says: another key it lists stays only when a root endorsed it (a
|
||||
/// rotation the roots vouched for). Revocations of the keys are kept.
|
||||
pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
|
||||
if roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.publishers.iter().position(|p| p.id == publisher) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
|
||||
self.publishers.len() - 1
|
||||
}
|
||||
};
|
||||
for root in roots {
|
||||
if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
|
||||
self.publishers[i].keys.push(KeyEntry::new(root.clone()));
|
||||
}
|
||||
}
|
||||
let keys = self.publishers[i].keys.clone();
|
||||
let trusted = keys
|
||||
.into_iter()
|
||||
.filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
|
||||
.map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
|
||||
.collect();
|
||||
self.publishers[i].keys = trusted;
|
||||
}
|
||||
|
||||
/// Replaces what this index lists for every publisher but `keep` with what
|
||||
/// `other` lists: its publishers, their plugins and their revocations
|
||||
/// (`other`'s downloads should already be absolute). `other` may only
|
||||
/// speak for its own publishers, and none of them may be in `keep`.
|
||||
pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> {
|
||||
let mut theirs: Vec<&str> = Vec::new();
|
||||
for p in &other.publishers {
|
||||
if !publisher_id_ok(&p.id) {
|
||||
return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id));
|
||||
}
|
||||
if keep.contains(&p.id.as_str()) {
|
||||
return Err(format!("may not list publisher {:?}", p.id));
|
||||
}
|
||||
if theirs.contains(&p.id.as_str()) {
|
||||
return Err(format!("publisher {:?} is listed twice", p.id));
|
||||
}
|
||||
theirs.push(&p.id);
|
||||
}
|
||||
let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id));
|
||||
for e in &other.plugins {
|
||||
if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
|
||||
return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
|
||||
}
|
||||
if semver::VersionReq::parse(&e.engine).is_err() {
|
||||
return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
|
||||
}
|
||||
}
|
||||
if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
|
||||
return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
|
||||
}
|
||||
let kept = |id: &str| keep.iter().any(|p| owns(p, id));
|
||||
self.publishers.retain(|p| keep.contains(&p.id.as_str()));
|
||||
self.plugins.retain(|e| keep.contains(&e.publisher.as_str()));
|
||||
self.revoked.retain(|r| kept(&r.id));
|
||||
self.publishers.extend(other.publishers);
|
||||
self.plugins.extend(other.plugins);
|
||||
self.revoked.extend(other.revoked);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package signed `new` (`None`: unsigned) may replace an installed
|
||||
/// one signed `installed` without asking the user: signed with the same key,
|
||||
/// or with one the installed key endorsed (per `index`). An unsigned installed
|
||||
/// copy pins nothing.
|
||||
pub fn signer_continues(
|
||||
index: Option<&Index>,
|
||||
installed: Option<&PackageSignature>,
|
||||
new: Option<&PackageSignature>,
|
||||
) -> bool {
|
||||
match (installed, new) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => false,
|
||||
(Some(old), Some(new)) => {
|
||||
old.publisher == new.publisher
|
||||
&& (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the plugin `id` is in `publisher`'s namespace.
|
||||
fn owns(publisher: &str, id: &str) -> bool {
|
||||
id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// A publisher id is a single segment of a plugin id.
|
||||
pub fn publisher_id_ok(id: &str) -> bool {
|
||||
!id.contains('.') && crate::validate_plugin_id(id).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -354,7 +613,7 @@ mod tests {
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
|
||||
keys: vec![KeyEntry::new(key.public_base64())],
|
||||
}],
|
||||
plugins: Vec::new(),
|
||||
revoked: vec![Revocation {
|
||||
@@ -362,6 +621,7 @@ mod tests {
|
||||
versions: vec!["1.0.0".into()],
|
||||
reason: "malware".into(),
|
||||
}],
|
||||
moved: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +659,138 @@ mod tests {
|
||||
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
|
||||
}
|
||||
|
||||
fn entry(id: &str, publisher: &str) -> IndexEntry {
|
||||
IndexEntry {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
description: String::new(),
|
||||
authors: Vec::new(),
|
||||
publisher: publisher.into(),
|
||||
download: format!("https://example.com/{id}.oiplugin"),
|
||||
digest: "d".into(),
|
||||
capabilities: Vec::new(),
|
||||
engine: "^1.0".into(),
|
||||
categories: Vec::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::<Vec<_>>()
|
||||
};
|
||||
let expected = [
|
||||
("acme.a".to_owned(), "1.2.0".to_owned(), true),
|
||||
("acme.b".to_owned(), "0.2.0".to_owned(), false),
|
||||
// No `engine`: listed, but no IDE installs it.
|
||||
("acme.c".to_owned(), "1.0.0".to_owned(), false),
|
||||
];
|
||||
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::new(format!("{id}-key"))] }
|
||||
}
|
||||
|
||||
fn revocation(id: &str) -> Revocation {
|
||||
Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn including_an_index_replaces_everything_but_the_kept_publishers() {
|
||||
let mut official = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("openidea"), publisher("gone")],
|
||||
plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")],
|
||||
revoked: vec![revocation("openidea.old"), revocation("gone.x")],
|
||||
moved: None,
|
||||
};
|
||||
let community = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("alice")],
|
||||
plugins: vec![entry("alice.arthas", "alice")],
|
||||
revoked: vec![revocation("alice.bad")],
|
||||
moved: None,
|
||||
};
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["openidea", "alice"]);
|
||||
assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]);
|
||||
assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), ["openidea.old", "alice.bad"]);
|
||||
|
||||
// Including again is idempotent.
|
||||
let once = official.clone();
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
assert_eq!(official, once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_included_index_speaks_only_for_its_own_publishers() {
|
||||
let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() };
|
||||
let with = |publishers: Vec<Publisher>, plugins: Vec<IndexEntry>, revoked: Vec<Revocation>| Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers,
|
||||
plugins,
|
||||
revoked,
|
||||
moved: None,
|
||||
};
|
||||
let cases = [
|
||||
(with(vec![publisher("openidea")], vec![], vec![]), "may not list"),
|
||||
(with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"),
|
||||
(with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"),
|
||||
(with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
|
||||
(
|
||||
with(
|
||||
vec![publisher("alice")],
|
||||
vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
|
||||
vec![],
|
||||
),
|
||||
"`engine`",
|
||||
),
|
||||
];
|
||||
for (other, error) in cases {
|
||||
let mut index = base.clone();
|
||||
let err = index.include(other, &["openidea"]).unwrap_err();
|
||||
assert!(err.contains(error), "{err}");
|
||||
assert_eq!(index, base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacking_refuses_paths_leaving_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -413,7 +805,7 @@ mod tests {
|
||||
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
|
||||
assert!(err.contains("invalid path"), "{err}");
|
||||
assert!(!tmp.path().join("outside.txt").exists());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).is_err());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
|
||||
}
|
||||
|
||||
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
|
||||
@@ -462,4 +854,102 @@ mod tests {
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_index_still_revokes_and_moves() {
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
|
||||
"publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
|
||||
"revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
|
||||
"moved": "https://example.com/next.json"}}"#,
|
||||
INDEX_FORMAT + 1
|
||||
);
|
||||
let index = Index::parse(&newer).unwrap();
|
||||
assert!(index.is_newer());
|
||||
assert!(index.plugins.is_empty() && index.offered().is_empty());
|
||||
assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
|
||||
assert!(index.key_revoked("k1"));
|
||||
assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
|
||||
// Fields this IDE doesn't know are ignored in its own format.
|
||||
let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
|
||||
assert!(!Index::parse(¤t).unwrap().is_newer());
|
||||
assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
|
||||
assert!(Index::parse(r#"{"plugins": []}"#).is_err());
|
||||
}
|
||||
|
||||
fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec<KeyEntry> {
|
||||
&mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_keys_continue_through_endorsements() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let mut index = index(&k1);
|
||||
let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
|
||||
endorsement: Some(by.endorse("acme", &key.public_base64())),
|
||||
..KeyEntry::new(key.public_base64())
|
||||
};
|
||||
keys(&mut index, "acme").push(endorsed(&k1, &k2));
|
||||
keys(&mut index, "acme").push(endorsed(&k2, &k3));
|
||||
let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
|
||||
assert!(index.succeeds("acme", &p1, &p1));
|
||||
assert!(index.succeeds("acme", &p1, &p3), "through k2");
|
||||
assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
|
||||
assert!(!index.succeeds("other", &p1, &p2));
|
||||
// An endorsement for another publisher, or by a revoked key, doesn't count.
|
||||
let mut foreign = index.clone();
|
||||
keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
|
||||
assert!(!foreign.succeeds("acme", &p1, &p2));
|
||||
let mut stolen = index.clone();
|
||||
keys(&mut stolen, "acme")[0].revoked = true;
|
||||
assert!(!stolen.succeeds("acme", &p1, &p2));
|
||||
|
||||
let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
|
||||
assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
|
||||
assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
|
||||
assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
|
||||
let stranger = PublisherKey::from_seed([4; 32]);
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
|
||||
assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_publishers_keep_their_built_in_keys() {
|
||||
let (root, next, intruder) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
|
||||
let mut index = index(&intruder);
|
||||
index.publishers[0].id = "openidea".into();
|
||||
keys(&mut index, "openidea").push(KeyEntry {
|
||||
endorsement: Some(root.endorse("openidea", &next.public_base64())),
|
||||
..KeyEntry::new(next.public_base64())
|
||||
});
|
||||
let roots = [root.public_base64()];
|
||||
let listed = |index: &Index| -> Vec<(String, bool)> {
|
||||
let mut index = index.clone();
|
||||
index.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
|
||||
};
|
||||
// The intruder's key goes, the successor the root endorsed stays, the root is added.
|
||||
assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
|
||||
let mut pinned = index.clone();
|
||||
pinned.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
let digest = "d";
|
||||
let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
|
||||
assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
|
||||
assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
|
||||
// A revoked root stays revoked, and what it endorsed is no longer trusted.
|
||||
keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
|
||||
assert_eq!(listed(&index), [(root.public_base64(), true)]);
|
||||
|
||||
// A publisher missing from the index is added with its roots.
|
||||
let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
|
||||
empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
|
||||
assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
|
||||
// No roots: the index decides.
|
||||
let mut open = index.clone();
|
||||
open.pin_publisher("openidea", "OpenIDEA", &[]);
|
||||
assert_eq!(open, index);
|
||||
}
|
||||
}
|
||||
+221
-21
@@ -21,6 +21,18 @@ pub struct FontFamily {
|
||||
pub monospaced: bool,
|
||||
}
|
||||
|
||||
/// PANOSE bProportion value 9 means "Monospaced"; DirectWrite's
|
||||
/// `IsMonospacedFont` alone misses fonts that only carry this classification.
|
||||
const PANOSE_MONOSPACED: u8 = 9;
|
||||
|
||||
/// One face counts as monospaced when the font system flags it fixed pitch, its
|
||||
/// PANOSE proportion is "Monospaced", or its glyph advances are uniform
|
||||
/// (fonts with wrong/missing metadata, e.g. bitmap-style TTFs).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn face_is_mono(is_monospaced_font: bool, panose_proportion: u8, advances_equal: bool) -> bool {
|
||||
is_monospaced_font || panose_proportion == PANOSE_MONOSPACED || advances_equal
|
||||
}
|
||||
|
||||
type Cache = Mutex<Option<Arc<[FontFamily]>>>;
|
||||
|
||||
/// The installed families, sorted case-insensitively, read on first use and
|
||||
@@ -195,7 +207,9 @@ mod imp {
|
||||
// SAFETY: CoreText descriptors carry documented CFString, CFURL and trait dictionary values.
|
||||
unsafe {
|
||||
let collection = CTFontCollection::from_available_fonts(None);
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else { return Vec::new() };
|
||||
let Some(descriptors) = collection.matching_font_descriptors() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
descriptors
|
||||
.cast_unchecked::<CTFontDescriptor>()
|
||||
@@ -271,14 +285,16 @@ mod imp {
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Graphics::DirectWrite::IDWriteFontFace1;
|
||||
use windows::Win32::Graphics::DirectWrite::{
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings,
|
||||
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory, IDWriteFactory3, IDWriteFactory5, IDWriteFont,
|
||||
IDWriteFont1, IDWriteFontCollection, IDWriteFontFamily, IDWriteLocalizedStrings,
|
||||
};
|
||||
use windows::core::{BOOL, Interface, w};
|
||||
use windows::core::{BOOL, Interface, PCWSTR, w};
|
||||
|
||||
/// The system font collection's families, named in English when the
|
||||
/// family has an English name (what CSS matches), else its first name.
|
||||
/// The installed families, named in English when the family has an
|
||||
/// English name (what CSS matches), else its first name.
|
||||
pub(crate) fn list(refresh: bool) -> Vec<(String, bool)> {
|
||||
// SAFETY: plain DirectWrite COM calls on interfaces we own.
|
||||
unsafe { read(refresh) }.unwrap_or_default()
|
||||
@@ -286,11 +302,85 @@ mod imp {
|
||||
|
||||
unsafe fn read(refresh: bool) -> windows::core::Result<Vec<(String, bool)>> {
|
||||
let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?;
|
||||
let mut faces = Vec::new();
|
||||
// Windows 10+: the newer collection covers registered per-user fonts.
|
||||
// Exclude downloadable fonts that are not installed on this machine.
|
||||
// Bitmap .FON fonts (e.g. the stock raster Fixedsys) are invisible to
|
||||
// DirectWrite either way: it only reads TrueType/OpenType files.
|
||||
let mut collection = None;
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
let Some(collection) = collection else {
|
||||
return Ok(Vec::new());
|
||||
if let Ok(factory3) = factory.cast::<IDWriteFactory3>() {
|
||||
let mut local = None;
|
||||
if unsafe { factory3.GetSystemFontCollection(false, &mut local, refresh) }.is_ok() {
|
||||
collection = local.map(Into::into);
|
||||
}
|
||||
}
|
||||
if collection.is_none() {
|
||||
unsafe { factory.GetSystemFontCollection(&mut collection, refresh) }?;
|
||||
}
|
||||
if let Some(collection) = collection {
|
||||
faces.extend(collect(&collection));
|
||||
}
|
||||
// DirectWrite enumerates via the font registry, so font files copied
|
||||
// into the per-user directory without registering (no HKCU entry)
|
||||
// are invisible to the calls above; scan the directory directly.
|
||||
faces.extend(unsafe { user_dir_faces(&factory) });
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
/// Families from `%LOCALAPPDATA%\Microsoft\Windows\Fonts` files, registered
|
||||
/// or not, via a font set built from the files on disk.
|
||||
unsafe fn user_dir_faces(factory: &IDWriteFactory) -> Vec<(String, bool)> {
|
||||
let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let dir = std::path::Path::new(&local_app_data).join(r"Microsoft\Windows\Fonts");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
matches!(
|
||||
p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
|
||||
Some("ttf" | "otf" | "ttc" | "otc")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
unsafe { file_faces(factory, &files) }
|
||||
}
|
||||
|
||||
unsafe fn file_faces(factory: &IDWriteFactory, files: &[std::path::PathBuf]) -> Vec<(String, bool)> {
|
||||
if files.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Ok(factory3) = factory.cast::<IDWriteFactory3>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(factory5) = factory.cast::<IDWriteFactory5>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(builder) = (unsafe { factory5.CreateFontSetBuilder() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
for path in files {
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
// SAFETY: wide is a valid NUL-terminated string for the calls.
|
||||
let Ok(file) = (unsafe { factory.CreateFontFileReference(PCWSTR(wide.as_ptr()), None) }) else {
|
||||
continue;
|
||||
};
|
||||
let _ = unsafe { builder.AddFontFile(&file) };
|
||||
}
|
||||
let Ok(set) = (unsafe { builder.CreateFontSet() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(collection) = (unsafe { factory3.CreateFontCollectionFromFontSet(&set) }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
collect(&collection)
|
||||
}
|
||||
|
||||
fn collect(collection: &IDWriteFontCollection) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..unsafe { collection.GetFontFamilyCount() } {
|
||||
let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else {
|
||||
@@ -299,19 +389,120 @@ mod imp {
|
||||
let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else {
|
||||
continue;
|
||||
};
|
||||
let mono = unsafe {
|
||||
family.GetFirstMatchingFont(
|
||||
DWRITE_FONT_WEIGHT_NORMAL,
|
||||
DWRITE_FONT_STRETCH_NORMAL,
|
||||
DWRITE_FONT_STYLE_NORMAL,
|
||||
)
|
||||
}
|
||||
.ok()
|
||||
.and_then(|f| f.cast::<IDWriteFont1>().ok())
|
||||
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
|
||||
out.push((name, mono));
|
||||
out.push((name, unsafe { family_is_mono(&family) }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A family is monospaced when any face reports fixed pitch, PANOSE
|
||||
/// proportion 9, or uniform glyph advances; probing every face matches
|
||||
/// `normalize`'s any-face merge (macOS and Linux already flag every face,
|
||||
/// not just the regular one).
|
||||
unsafe fn family_is_mono(family: &IDWriteFontFamily) -> bool {
|
||||
(0..unsafe { family.GetFontCount() }).any(|i| {
|
||||
let Ok(font) = (unsafe { family.GetFont(i) }) else {
|
||||
return false;
|
||||
};
|
||||
if let Ok(font1) = font.cast::<IDWriteFont1>() {
|
||||
let is_monospaced = unsafe { font1.IsMonospacedFont() }.as_bool();
|
||||
// The proportion byte is defined for text/display fonts only.
|
||||
let panose = unsafe { font1.GetPanose().values };
|
||||
let panose_proportion = if panose[0] == 2 { panose[3] } else { 0 };
|
||||
if super::face_is_mono(is_monospaced, panose_proportion, false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
unsafe { advances_equal(&font) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Some monospaced fonts carry no fixed-pitch metadata at all (bitmap-style
|
||||
/// TTFs like Fixedsys Excelsior): compare the advance widths of 'i' and
|
||||
/// 'W'. Dual-width CJK monospace fonts pass too (both half-width).
|
||||
unsafe fn advances_equal(font: &IDWriteFont) -> bool {
|
||||
let Ok(face) = (unsafe { font.CreateFontFace() }) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(face1) = face.cast::<IDWriteFontFace1>() else {
|
||||
return false;
|
||||
};
|
||||
let codepoints = ['i' as u32, 'W' as u32];
|
||||
let mut indices = [0u16; 2];
|
||||
if unsafe { face.GetGlyphIndices(codepoints.as_ptr(), 2, indices.as_mut_ptr()) }.is_err()
|
||||
|| indices.contains(&0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut advances = [0i32; 2];
|
||||
unsafe { face1.GetDesignGlyphAdvances(2, indices.as_ptr(), advances.as_mut_ptr(), false) }.is_ok()
|
||||
&& advances[0] > 0
|
||||
&& advances[0] == advances[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_probe_distinguishes_monospace_proportional_and_symbol_fonts() {
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let mut collection = None;
|
||||
factory.GetSystemFontCollection(&mut collection, false).unwrap();
|
||||
let collection = collection.unwrap();
|
||||
for (name, expected) in [
|
||||
("Consolas", true),
|
||||
("Arial", false),
|
||||
("Times New Roman", false),
|
||||
("Wingdings", false),
|
||||
("Webdings", false),
|
||||
("Segoe MDL2 Assets", false),
|
||||
] {
|
||||
let wide: Vec<_> = name.encode_utf16().chain(Some(0)).collect();
|
||||
let mut index = 0;
|
||||
let mut exists = BOOL(0);
|
||||
collection.FindFamilyName(PCWSTR(wide.as_ptr()), &mut index, &mut exists).unwrap();
|
||||
assert!(exists.as_bool(), "{name} ships with Windows");
|
||||
let family = collection.GetFontFamily(index).unwrap();
|
||||
for i in 0..family.GetFontCount() {
|
||||
let font = family.GetFont(i).unwrap();
|
||||
assert_eq!(advances_equal(&font), expected, "{name} face {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_files_include_every_family_in_a_collection() {
|
||||
let path = std::path::PathBuf::from(std::env::var_os("WINDIR").unwrap()).join(r"Fonts\cambria.ttc");
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let faces = file_faces(&factory, &[path]);
|
||||
for name in ["Cambria", "Cambria Math"] {
|
||||
assert!(faces.iter().any(|(family, _)| family == name), "missing {name}: {faces:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_families_available_only_as_downloads() {
|
||||
use windows::Win32::Graphics::DirectWrite::{DWRITE_E_REMOTEFONT, DWRITE_LOCALITY_LOCAL, IDWriteFont3};
|
||||
|
||||
unsafe {
|
||||
let factory: IDWriteFactory = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED).unwrap();
|
||||
let factory3: IDWriteFactory3 = factory.cast().unwrap();
|
||||
let set = factory3.GetSystemFontSet().unwrap();
|
||||
let collection = factory3.CreateFontCollectionFromFontSet(&set).unwrap();
|
||||
let listed = super::normalize(read(false).unwrap());
|
||||
let user = super::normalize(user_dir_faces(&factory));
|
||||
for i in 0..collection.GetFontFamilyCount() {
|
||||
let family = collection.GetFontFamily(i).unwrap();
|
||||
let name = english(&family.GetFamilyNames().unwrap()).unwrap();
|
||||
let remote_only = (0..family.GetFontCount()).all(|j| match family.GetFont(j) {
|
||||
Ok(font) => font.cast::<IDWriteFont3>().unwrap().GetLocality() != DWRITE_LOCALITY_LOCAL,
|
||||
Err(error) => error.code() == DWRITE_E_REMOTEFONT,
|
||||
});
|
||||
if remote_only && !user.iter().any(|f| f.name == name) {
|
||||
assert!(!listed.iter().any(|f| f.name == name), "{name} is not installed locally");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
|
||||
@@ -476,6 +667,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panose_monospaced_proportion_counts_as_mono() {
|
||||
assert!(face_is_mono(true, 0, false));
|
||||
assert!(face_is_mono(false, PANOSE_MONOSPACED, false));
|
||||
assert!(face_is_mono(false, 0, true));
|
||||
assert!(!face_is_mono(false, 0, false));
|
||||
assert!(!face_is_mono(false, 3, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fc_list_spacing_marks_monospace() {
|
||||
let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n";
|
||||
|
||||
@@ -46,6 +46,7 @@ ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! ```text
|
||||
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
|
||||
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
|
||||
//! openidea-plugin check [dir] # validate the package as the IDE would install it
|
||||
//! openidea-plugin check [dir|package] # validate the package as the IDE would install it
|
||||
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin endorse <new-public-key> --key OLD --publisher ID # vouch for a new key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
|
||||
//! openidea-plugin index <dir> [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
|
||||
//! openidea-plugin merge <index> <other-index> --keep ID # list another index's publishers
|
||||
//! ```
|
||||
//!
|
||||
//! `link` writes `links.json` in the IDE's plugins directory
|
||||
@@ -20,7 +22,7 @@
|
||||
mod templates;
|
||||
|
||||
use ide_extension::{Manifest, validate_plugin_id};
|
||||
use ide_host::plugins::PluginHost;
|
||||
use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href};
|
||||
use ide_plugin::{Limits, PluginEngine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -33,15 +35,27 @@ commands:
|
||||
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
|
||||
create a plugin; T = command (default), theme, icon-theme, language
|
||||
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
|
||||
check [dir] validate the package as the IDE would install it
|
||||
check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it
|
||||
link [dir] load the folder in the IDE without installing it
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
endorse <new-public-key> --key F --publisher ID
|
||||
print an earlier key's endorsement of a new one (JSON), for an
|
||||
index the publisher doesn't write with `index --previous-key`
|
||||
pack [dir] [--key F --publisher ID] [--out FILE]
|
||||
write <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> --publisher ID --name NAME --key F [--out FILE]
|
||||
write the marketplace index.json for the packages in dir
|
||||
index <dir> [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE]
|
||||
[--download-base URL]
|
||||
write the marketplace index.json for the packages in dir: with a
|
||||
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. A new key replacing
|
||||
an earlier one is endorsed with --previous-key, so installed
|
||||
plugins update to it without asking
|
||||
merge <index> <other-index> --keep ID [--out FILE]
|
||||
replace every publisher but ID in index with those of other-index
|
||||
(a path or URL), their plugins and revocations
|
||||
|
||||
environment:
|
||||
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
|
||||
@@ -51,13 +65,15 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => cmd_new(&args[1..]),
|
||||
Some("build") => cmd_build(&args[1..]),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check_path(&d)),
|
||||
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
|
||||
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
|
||||
Some("links") => list_links(),
|
||||
Some("keygen") => cmd_keygen(&args[1..]),
|
||||
Some("endorse") => cmd_endorse(&args[1..]),
|
||||
Some("pack") => cmd_pack(&args[1..]),
|
||||
Some("index") => cmd_index(&args[1..]),
|
||||
Some("merge") => cmd_merge(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
|
||||
Ok(())
|
||||
@@ -323,10 +339,21 @@ fn ensure_wasm_target() {
|
||||
|
||||
/// Validates `dir` exactly as Install from Disk would, and checks the
|
||||
/// component imports only the plugin API.
|
||||
/// [`check`] of a folder, or of a package file unpacked as installing would.
|
||||
fn check_path(path: &Path) -> Result<(), String> {
|
||||
if !path.is_file() {
|
||||
return check(path);
|
||||
}
|
||||
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())?;
|
||||
@@ -345,6 +372,17 @@ 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.declared_categories.len() > ide_extension::MAX_CATEGORIES {
|
||||
println!(
|
||||
" warning: only the first {} known categories are used; {} are declared",
|
||||
ide_extension::MAX_CATEGORIES,
|
||||
manifest.declared_categories.len()
|
||||
);
|
||||
}
|
||||
if !manifest.activation.is_empty() {
|
||||
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
|
||||
println!(" activation: {}", events.join(", "));
|
||||
@@ -361,9 +399,7 @@ fn check(dir: &Path) -> Result<(), String> {
|
||||
}
|
||||
println!("ok");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
})()
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
@@ -479,14 +515,31 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_endorse(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher"])?;
|
||||
let usage = "usage: openidea-plugin endorse <new-public-key> --key <earlier-key-file> --publisher ID";
|
||||
let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else {
|
||||
return Err(usage.into());
|
||||
};
|
||||
if !package::publisher_id_ok(publisher) {
|
||||
return Err(format!("invalid publisher id {publisher:?}"));
|
||||
}
|
||||
let endorsement = read_key(key)?.endorse(publisher, new.trim());
|
||||
println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fresh scratch directory, removed when dropped.
|
||||
fn scratch_dir(what: &str) -> Result<tempfile::TempDir, String> {
|
||||
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<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
|
||||
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> {
|
||||
@@ -522,29 +575,28 @@ 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", "previous-key", "out", "download-base"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let publisher = a.value("publisher").ok_or("--publisher is required")?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let key = read_key(a.value("key").ok_or("--key is required")?)?;
|
||||
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)?,
|
||||
Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
|
||||
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
|
||||
};
|
||||
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = index
|
||||
.publishers
|
||||
.iter()
|
||||
.filter(|p| p.id == publisher)
|
||||
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
|
||||
.collect();
|
||||
index.publishers.retain(|p| p.id != publisher);
|
||||
let keys: Vec<KeyEntry> = 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.clear();
|
||||
let publisher = match (a.value("publisher"), a.value("key")) {
|
||||
(Some(publisher), Some(key)) => {
|
||||
let key = read_key(key)?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let previous = a.value("previous-key").map(read_key).transpose()?;
|
||||
index.publishers =
|
||||
register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?;
|
||||
Some(publisher)
|
||||
}
|
||||
(None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()),
|
||||
(None, None) => None,
|
||||
_ => return Err("--publisher and --key go together".into()),
|
||||
};
|
||||
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
@@ -552,35 +604,144 @@ 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<IndexEntry> = 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 unpacked = package::unpack(&path, &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)?;
|
||||
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
|
||||
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
|
||||
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let sig = unpacked.signature.as_ref().ok_or("not signed")?;
|
||||
let signer = index.verify(sig, &m.id, &m.version, &digest)?;
|
||||
if publisher.is_some_and(|p| p != signer) {
|
||||
return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default()));
|
||||
}
|
||||
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(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
publisher: sig.publisher.clone(),
|
||||
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
publisher: signer,
|
||||
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()),
|
||||
categories: m.categories(),
|
||||
})
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let entry = entry?;
|
||||
println!(" {} {}", entry.id, entry.version);
|
||||
index.plugins.push(entry);
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!(" {} {} (plugin API {})", entry.id, entry.version, entry.engine);
|
||||
added.push(entry);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed
|
||||
/// (packages they signed remain valid until revoked); a key new to the
|
||||
/// publisher is endorsed by `previous`, one of them, so installed plugins
|
||||
/// signed with it update without asking.
|
||||
fn register_key(
|
||||
mut publishers: Vec<package::Publisher>,
|
||||
publisher: &str,
|
||||
name: &str,
|
||||
key: &PublisherKey,
|
||||
previous: Option<&PublisherKey>,
|
||||
) -> Result<Vec<package::Publisher>, String> {
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect();
|
||||
let entry = match earlier.iter().find(|k| k.key == current) {
|
||||
Some(listed) => listed.clone(),
|
||||
None => {
|
||||
let endorsement = match previous {
|
||||
Some(prev) => {
|
||||
let prev_key = prev.public_base64();
|
||||
if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) {
|
||||
return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index"));
|
||||
}
|
||||
Some(prev.endorse(publisher, ¤t))
|
||||
}
|
||||
None => {
|
||||
if earlier.iter().any(|k| !k.revoked) {
|
||||
println!(
|
||||
" warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \
|
||||
its plugins will be asked to confirm the change when they update"
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
KeyEntry { endorsement, ..KeyEntry::new(current.clone()) }
|
||||
}
|
||||
};
|
||||
let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect();
|
||||
publishers.retain(|p| p.id != publisher);
|
||||
publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
Ok(publishers)
|
||||
}
|
||||
|
||||
/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
|
||||
fn current_format(index: Index, file: &str) -> Result<Index, String> {
|
||||
if index.is_newer() {
|
||||
return Err(format!(
|
||||
"{file}: index format {} is newer than this tool writes; update openidea-plugin",
|
||||
index.format
|
||||
));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn cmd_merge(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["keep", "out"])?;
|
||||
let [base, other] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin merge <index> <other-index> --keep ID [--out FILE]".into());
|
||||
};
|
||||
let keep = a.value("keep").ok_or("--keep is required")?;
|
||||
let out = a.value("out").unwrap_or(base);
|
||||
let mut index =
|
||||
current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
|
||||
let bytes = read_market_url(other, 16 << 20)?;
|
||||
let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
|
||||
let mut theirs = current_format(theirs, other)?;
|
||||
// Their downloads are relative to their index, not to ours.
|
||||
for e in &mut theirs.plugins {
|
||||
e.download = resolve_market_href(other, &e.download);
|
||||
}
|
||||
let count = theirs.plugins.len();
|
||||
index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?;
|
||||
println!(" {count} plugins from {other}");
|
||||
write_index(&index, Path::new(out))
|
||||
}
|
||||
|
||||
fn write_index(index: &Index, out: &Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -589,6 +750,25 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_key_is_endorsed_by_the_previous_one() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap();
|
||||
let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap();
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() };
|
||||
assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64()));
|
||||
assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed");
|
||||
// Registering the current key again keeps its endorsement.
|
||||
let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap();
|
||||
assert_eq!(again, rotated);
|
||||
// Only a key the publisher has can endorse.
|
||||
assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key"));
|
||||
// Without one the new key is listed unendorsed.
|
||||
let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap();
|
||||
assert!(unendorsed[0].keys[0].endorsement.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
|
||||
@@ -601,6 +781,126 @@ mod tests {
|
||||
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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}-{version}.oiplugin")).to_string_lossy().into_owned()])
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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<String> {
|
||||
args.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_and_community_indexes_merge() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
let mut keys = Vec::new();
|
||||
for (name, seed) in [("openidea", 1), ("alice", 2), ("carol", 3)] {
|
||||
let file = t.join(format!("{name}.key"));
|
||||
let key = PublisherKey::from_seed([seed; 32]);
|
||||
std::fs::write(&file, key.seed_base64()).unwrap();
|
||||
keys.push((file.to_string_lossy().into_owned(), key.public_base64()));
|
||||
}
|
||||
let (official, community) = (t.join("official"), t.join("community"));
|
||||
signed_theme(t, "openidea.night", "openidea", &keys[0].0, &official);
|
||||
signed_theme(t, "alice.dawn", "alice", &keys[1].0, &community);
|
||||
let official_index = official.join("index.json");
|
||||
let community_index = community.join("index.json");
|
||||
let release = strings(&[official.to_str().unwrap(), "--publisher", "openidea", "--key", &keys[0].0]);
|
||||
cmd_index(&release).unwrap();
|
||||
|
||||
// The community index lists its publishers; `index` without a key checks packages against them.
|
||||
let skeleton = Index {
|
||||
format: package::INDEX_FORMAT,
|
||||
publishers: vec![package::Publisher {
|
||||
id: "alice".into(),
|
||||
name: "Alice".into(),
|
||||
keys: vec![KeyEntry::new(keys[1].1.clone())],
|
||||
}],
|
||||
..Index::default()
|
||||
};
|
||||
write_index(&skeleton, &community_index).unwrap();
|
||||
cmd_index(&strings(&[community.to_str().unwrap()])).unwrap();
|
||||
|
||||
cmd_merge(&strings(&[
|
||||
official_index.to_str().unwrap(),
|
||||
community_index.to_str().unwrap(),
|
||||
"--keep",
|
||||
"openidea",
|
||||
]))
|
||||
.unwrap();
|
||||
// A release rebuilds the official plugins and keeps the community's.
|
||||
cmd_index(&release).unwrap();
|
||||
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);
|
||||
|
||||
// A package by a publisher the index doesn't know is refused.
|
||||
signed_theme(t, "carol.noon", "carol", &keys[2].0, &community);
|
||||
let err = cmd_index(&strings(&[community.to_str().unwrap()])).unwrap_err();
|
||||
assert!(err.contains("unknown publisher"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_is_a_valid_package() {
|
||||
for t in templates::Template::ALL {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -76,9 +76,17 @@ async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static)
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server (resolve and commands don't apply).
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
|
||||
/// `data` of a plugin's code action whose edits it computes when picked.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
fn plugin_completion_kind(kind: &str) -> Option<CompletionKind> {
|
||||
Some(match kind {
|
||||
@@ -120,14 +128,14 @@ fn plugin_completion(item: ide_plugin::CompletionItem) -> CompletionItem {
|
||||
kind,
|
||||
documentation: item.documentation.map(|value| Markup { kind: "markdown".into(), value }),
|
||||
sort_text: item.sort_text,
|
||||
filter_text: None,
|
||||
filter_text: item.filter_text,
|
||||
preselect: false,
|
||||
deprecated: false,
|
||||
insert_text: item.insert_text,
|
||||
is_snippet: false,
|
||||
is_snippet: item.snippet,
|
||||
replace_range: item.replace,
|
||||
replace_range_tab: None,
|
||||
additional_edits: Vec::new(),
|
||||
additional_edits: item.additional_edits,
|
||||
commit_characters: Vec::new(),
|
||||
command: None,
|
||||
data: None,
|
||||
@@ -314,7 +322,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(_, a)| plugin_code_action(a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
@@ -330,24 +338,53 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(a: ide_plugin::CodeAction) -> CodeAction {
|
||||
let files = a
|
||||
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and no edits, the plugin computes them when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.data.is_some();
|
||||
let files: Vec<_> = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
let data = a
|
||||
.data
|
||||
.filter(|_| lazy)
|
||||
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
edit: Some(WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data: None,
|
||||
data,
|
||||
server: PLUGIN_SERVER,
|
||||
}
|
||||
}
|
||||
|
||||
/// A code action resolved by whoever offered it: its language server, or
|
||||
/// the plugin its `data` names.
|
||||
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
if action.server != PLUGIN_SERVER {
|
||||
return host.project(window)?.lsp.resolve_code_action(doc, action).await;
|
||||
}
|
||||
let Some(r) = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok()) else {
|
||||
return Ok(action);
|
||||
};
|
||||
let (plugins, d) = plugin_providers(host, window, doc, ProviderKind::CodeActions)
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("the plugin that offered this action is gone"))?;
|
||||
let pending = ide_plugin::CodeAction {
|
||||
title: action.title.clone(),
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
@@ -381,7 +418,7 @@ pub async fn lsp_resolve_code_action(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
|
||||
}
|
||||
|
||||
@@ -391,7 +428,7 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
let action = resolve_action(host, window, doc, action).await?;
|
||||
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
|
||||
} else {
|
||||
action
|
||||
@@ -801,6 +838,25 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
|
||||
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
|
||||
title: "Fix".into(),
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
data: data.map(str::to_owned),
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
|
||||
// With edits, `data` is the plugin's business: applied as is.
|
||||
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
|
||||
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
//! plugins, and revocations. The host keeps the last index it read
|
||||
//! (`market-index.json`), checks signatures against it, and disables what it
|
||||
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
|
||||
//! Release builds pin the official publisher to the keys they were built
|
||||
//! with, so no index can sign `openidea.*` plugins with another key.
|
||||
|
||||
use super::{PackageFile, PluginHost, PluginSource, PluginStatus};
|
||||
use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
|
||||
use ide_extension::{MANIFEST_FILE, Manifest};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -15,6 +18,9 @@ use std::path::{Path, PathBuf};
|
||||
/// the app, next to the signed packages of the official plugins.
|
||||
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
|
||||
|
||||
/// The publisher of the official plugins.
|
||||
pub const OFFICIAL_PUBLISHER: &str = "openidea";
|
||||
|
||||
/// The last index read, in the plugins directory.
|
||||
pub const INDEX_FILE: &str = "market-index.json";
|
||||
const DOWNLOADS: &str = ".downloads";
|
||||
@@ -22,6 +28,8 @@ const MAX_INDEX: usize = 16 << 20;
|
||||
const MAX_PACKAGE: usize = 256 << 20;
|
||||
const MAX_STAGED_FILES: usize = 256;
|
||||
const MAX_STAGED_BYTES: usize = 16 << 20;
|
||||
/// `moved` links followed before giving up (a loop, or a misconfiguration).
|
||||
const MAX_MOVES: usize = 3;
|
||||
|
||||
/// A marketplace entry with what is installed.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
@@ -31,6 +39,32 @@ pub struct MarketPlugin {
|
||||
pub entry: IndexEntry,
|
||||
/// Installed version, if any.
|
||||
pub installed: Option<String>,
|
||||
/// Whether this IDE can load `entry` (the newest version it can, else the newest).
|
||||
pub compatible: bool,
|
||||
}
|
||||
|
||||
impl MarketPlugin {
|
||||
/// A version this IDE can load, newer than the one installed.
|
||||
pub fn is_update(&self) -> bool {
|
||||
let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) {
|
||||
(Ok(offered), Ok(installed)) => offered > installed,
|
||||
_ => false,
|
||||
};
|
||||
self.compatible && self.installed.as_ref().is_some_and(newer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The official publisher's keys this IDE was built with
|
||||
/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development
|
||||
/// builds have none: the index decides.
|
||||
pub(super) fn built_in_official_keys() -> Vec<String> {
|
||||
let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default();
|
||||
keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn pinned(mut index: Index, official_keys: &[String]) -> Index {
|
||||
index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys);
|
||||
index
|
||||
}
|
||||
|
||||
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
|
||||
@@ -58,6 +92,33 @@ pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The index at `url`, following `moved`, with downloads made absolute against
|
||||
/// where it was read from.
|
||||
fn read_index(url: &str) -> IdeResult<Index> {
|
||||
let mut url = url.to_owned();
|
||||
for _ in 0..=MAX_MOVES {
|
||||
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
|
||||
let mut index = Index::parse(&text).map_err(IdeError::invalid)?;
|
||||
match index.moved.take().filter(|m| !m.trim().is_empty()) {
|
||||
Some(next) => {
|
||||
let next = resolve(&url, &next);
|
||||
if url.starts_with("https://") && !next.starts_with("https://") {
|
||||
return Err(IdeError::invalid(format!("the marketplace moved to {next}, which isn't https")));
|
||||
}
|
||||
url = next;
|
||||
}
|
||||
None => {
|
||||
for e in &mut index.plugins {
|
||||
e.download = resolve(&url, &e.download);
|
||||
}
|
||||
return Ok(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(IdeError::unavailable(format!("the marketplace index moved more than {MAX_MOVES} times")))
|
||||
}
|
||||
|
||||
/// `href` relative to the index at `base`: a URL, or a local path (with the
|
||||
/// platform's separators, e.g. `C:\\market\\index.json`).
|
||||
pub fn resolve(base: &str, href: &str) -> String {
|
||||
@@ -77,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn load_market_index(dir: &Path) -> Option<Index> {
|
||||
pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option<Index> {
|
||||
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
|
||||
Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
|
||||
let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?;
|
||||
Some(pinned(index, official_keys))
|
||||
}
|
||||
|
||||
/// Pins the official publisher to `keys` (base64) instead of the built-in
|
||||
/// ones, from the next index read.
|
||||
pub fn set_official_keys(&self, keys: Vec<String>) {
|
||||
*self.official_keys.write() = keys;
|
||||
}
|
||||
|
||||
/// The marketplace index URL: the user's, else the default (none: no marketplace).
|
||||
@@ -97,14 +165,16 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations.
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
|
||||
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
|
||||
let index = Index::parse(&text).map_err(IdeError::invalid)?;
|
||||
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text);
|
||||
*self.market.write() = Some(index);
|
||||
let index = read_index(&url)?;
|
||||
if let Ok(json) = serde_json::to_string(&index) {
|
||||
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), json);
|
||||
}
|
||||
let newer = index.is_newer().then_some(index.format);
|
||||
*self.market.write() = Some(pinned(index, &self.official_keys.read()));
|
||||
// Trust may have changed: who signed what, what is revoked.
|
||||
let installed: Vec<_> =
|
||||
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
|
||||
@@ -113,7 +183,41 @@ impl PluginHost {
|
||||
plugin.meta.lock().publisher = publisher;
|
||||
}
|
||||
self.apply_revocations();
|
||||
Ok(self.market_plugins())
|
||||
if let Some(format) = newer {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply"
|
||||
)));
|
||||
}
|
||||
let plugins = self.market_plugins();
|
||||
self.announce_updates(&plugins);
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Tells the windows about updates once per set of versions: a later
|
||||
/// refresh offering the same ones says nothing.
|
||||
fn announce_updates(&self, plugins: &[MarketPlugin]) {
|
||||
let updates: Vec<PluginUpdate> = plugins
|
||||
.iter()
|
||||
.filter(|p| p.is_update())
|
||||
.map(|p| PluginUpdate {
|
||||
id: p.entry.id.clone(),
|
||||
name: p.entry.name.clone(),
|
||||
version: p.entry.version.clone(),
|
||||
})
|
||||
.collect();
|
||||
let key: Vec<String> = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect();
|
||||
if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() {
|
||||
self.emit(None, PluginEvent::UpdatesAvailable { updates });
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the package in `src` would replace the installed `id`'s copy
|
||||
/// with one signed by another key than that copy, without that key's
|
||||
/// endorsement: the user confirms the change of signer.
|
||||
pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool {
|
||||
let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false };
|
||||
let market = self.market.read();
|
||||
!package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref())
|
||||
}
|
||||
|
||||
/// The plugins of the last index read.
|
||||
@@ -121,16 +225,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 +243,18 @@ impl PluginHost {
|
||||
pub fn download_market_plugin(&self, id: &str) -> IdeResult<PathBuf> {
|
||||
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)?;
|
||||
|
||||
@@ -36,7 +36,10 @@ pub mod web;
|
||||
|
||||
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
|
||||
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
|
||||
pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX};
|
||||
pub use market::{
|
||||
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
};
|
||||
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
|
||||
pub(crate) use providers::editor_providers as editor_providers_for;
|
||||
pub use providers::{ProviderKind, set_editor_providers};
|
||||
@@ -242,12 +245,16 @@ pub struct Consent {
|
||||
/// Optional capabilities the user accepted (`net:crates.io`, …). Required
|
||||
/// ones are implied by consenting at all.
|
||||
pub optional: Vec<String>,
|
||||
/// The user accepted a package signed by another key than the installed
|
||||
/// copy ([`InstallPreview::signer_changed`]).
|
||||
#[serde(default)]
|
||||
pub signer_change: bool,
|
||||
}
|
||||
|
||||
impl Consent {
|
||||
/// Accept everything the plugin asks for (tests, trusted official plugins).
|
||||
pub fn all() -> Self {
|
||||
Self { optional: vec!["*".into()] }
|
||||
Self { optional: vec!["*".into()], signer_change: true }
|
||||
}
|
||||
|
||||
fn accepts(&self, cap: &Capability) -> bool {
|
||||
@@ -274,6 +281,14 @@ pub struct InstallPreview {
|
||||
pub unsigned: bool,
|
||||
/// The verified publisher.
|
||||
pub publisher: Option<String>,
|
||||
/// Replaces an installed copy signed with another key, which didn't
|
||||
/// endorse this package's (or this one is unsigned): the user confirms it
|
||||
/// ([`Consent::signer_change`]).
|
||||
pub signer_changed: bool,
|
||||
/// Something to confirm: a first install, an unsigned package, another
|
||||
/// signer, or capabilities the installed version wasn't granted or
|
||||
/// refused. Without, an update installs with the grants it has.
|
||||
pub needs_consent: bool,
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
@@ -320,6 +335,10 @@ pub struct PluginHost {
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// The last marketplace index read: trust root for signatures, revocations.
|
||||
market: RwLock<Option<ide_extension::package::Index>>,
|
||||
/// The official publisher's keys the index is pinned to (none: as listed).
|
||||
official_keys: RwLock<Vec<String>>,
|
||||
/// `id@version` of the updates last announced.
|
||||
announced_updates: Mutex<Vec<String>>,
|
||||
/// The marketplace used while the user has set none (the app's official one).
|
||||
default_market_url: RwLock<Option<String>>,
|
||||
/// Updates not confirmed yet (the new version hasn't started once): what
|
||||
@@ -358,9 +377,12 @@ impl PluginHost {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir));
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
market,
|
||||
official_keys: RwLock::new(official_keys),
|
||||
announced_updates: Mutex::default(),
|
||||
default_market_url: RwLock::new(None),
|
||||
updates: Mutex::default(),
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
@@ -703,14 +725,27 @@ impl PluginHost {
|
||||
}
|
||||
let publisher = self.verified_publisher(src);
|
||||
let m = &pkg.manifest;
|
||||
let replaces = self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone());
|
||||
// An update shows what the installed version was granted.
|
||||
let grant = replaces
|
||||
.as_ref()
|
||||
.and_then(|_| self.ledger.lock().get(&Subject::Installed(m.id.clone())).cloned())
|
||||
.filter(|g| g.confirmed);
|
||||
let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.has(c));
|
||||
let mut capabilities: Vec<CapabilityInfo> =
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect();
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect();
|
||||
for extra in &set.capabilities {
|
||||
if !m.declares(extra) {
|
||||
capabilities.push(CapabilityInfo::new(extra, true, false));
|
||||
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
|
||||
}
|
||||
}
|
||||
capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id)));
|
||||
let signer_changed = self.signer_changed(&m.id, src);
|
||||
let answered = |c: &CapabilityInfo| {
|
||||
c.granted || (c.optional && grant.as_ref().is_some_and(|g| g.declined.contains(&c.id)))
|
||||
};
|
||||
let needs_consent =
|
||||
grant.is_none() || publisher.is_none() || signer_changed || !capabilities.iter().all(answered);
|
||||
Ok(InstallPreview {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
@@ -721,9 +756,11 @@ impl PluginHost {
|
||||
capabilities,
|
||||
contributions: counts(m),
|
||||
warnings: set.warnings.clone(),
|
||||
replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()),
|
||||
replaces,
|
||||
unsigned: publisher.is_none(),
|
||||
publisher,
|
||||
signer_changed,
|
||||
needs_consent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -746,6 +783,11 @@ impl PluginHost {
|
||||
if let Some(problem) = self.signature_problem(src) {
|
||||
return Err(IdeError::invalid(format!("refusing {id}: {problem}")));
|
||||
}
|
||||
if !consent.signer_change && self.signer_changed(&id, src) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"refusing {id}: it is signed by another key than the installed version; confirm the change of signer"
|
||||
)));
|
||||
}
|
||||
let version = pkg.manifest.version.clone();
|
||||
if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) {
|
||||
return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason)));
|
||||
@@ -2211,6 +2253,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,
|
||||
|
||||
@@ -292,6 +292,21 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The edits of an action `plugin_id` returned with `data`, now that the
|
||||
/// user picked it: a call with the command budget, not the edit path's.
|
||||
pub fn resolve_code_action(
|
||||
&self,
|
||||
plugin_id: &str,
|
||||
doc: &ProviderDocument,
|
||||
action: CodeAction,
|
||||
) -> ide_core::IdeResult<CodeAction> {
|
||||
let d = Arc::new(doc.clone());
|
||||
let timeout = self.limits.timeout;
|
||||
self.code_call(plugin_id, ide_plugin::Invocation::default(), "resolving the code action", move |rt, _| {
|
||||
rt.resolve_code_action(&d, &action, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
|
||||
@@ -272,7 +272,8 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
let consent = Consent { optional: pkg.granted.clone() };
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
if let Err(e) = self.follow_grants(&pkg, &consent) {
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
|
||||
@@ -138,12 +138,24 @@ pub struct PluginsSnapshot {
|
||||
pub ui: Vec<UiContribution>,
|
||||
}
|
||||
|
||||
/// A newer version of an installed plugin in the marketplace.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUpdate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,25 +131,44 @@ fn installs_from_before_versioning_keep_working_and_move_on_update() {
|
||||
|
||||
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
|
||||
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
|
||||
let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| {
|
||||
t.replace("id = \"hello\"", "id = \"acme.hello\"")
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
|
||||
});
|
||||
signed(dir, "acme", version, key, host, |t| t.to_owned())
|
||||
}
|
||||
|
||||
/// `hello` as `<publisher>.hello` at `version`, its manifest changed by
|
||||
/// `edit`, packed and signed with `key`.
|
||||
fn signed(
|
||||
dir: &Path,
|
||||
publisher: &str,
|
||||
version: &str,
|
||||
key: &PublisherKey,
|
||||
host: &PluginHost,
|
||||
edit: impl Fn(&str) -> String,
|
||||
) -> (PathBuf, IndexEntry) {
|
||||
let id = format!("{publisher}.hello");
|
||||
let src =
|
||||
variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{id}-{version}"), |t| {
|
||||
edit(
|
||||
&t.replace("id = \"hello\"", &format!("id = \"{id}\""))
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
});
|
||||
let files = host.package_files_of(&src).unwrap();
|
||||
let digest = package::package_digest(&src, &files).unwrap();
|
||||
let sig = key.sign("acme", "acme.hello", version, &digest);
|
||||
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
|
||||
let sig = key.sign(publisher, &id, version, &digest);
|
||||
let out = dir.join(format!("{id}-{version}.oiplugin"));
|
||||
package::pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
let entry = IndexEntry {
|
||||
id: "acme.hello".into(),
|
||||
id: id.clone(),
|
||||
name: "Hello".into(),
|
||||
version: version.into(),
|
||||
description: String::new(),
|
||||
authors: vec![],
|
||||
publisher: "acme".into(),
|
||||
publisher: publisher.into(),
|
||||
download: out.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
digest,
|
||||
capabilities: vec![],
|
||||
engine: "^1.0".into(),
|
||||
categories: vec!["tools".into()],
|
||||
};
|
||||
(out, entry)
|
||||
}
|
||||
@@ -166,10 +185,11 @@ fn write_index(
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
|
||||
keys: vec![KeyEntry { revoked: key_revoked, ..KeyEntry::new(key.public_base64()) }],
|
||||
}],
|
||||
plugins,
|
||||
revoked,
|
||||
moved: None,
|
||||
};
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
@@ -260,3 +280,210 @@ 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::<u64>().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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_marketplace_is_read_where_it_moved_to() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
write_index(&market, &key, vec![entry], vec![], false);
|
||||
// The old address points at the new one; downloads resolve against where the index is.
|
||||
let old = f.work.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "../market/index.json"}"#).unwrap();
|
||||
f.host.set_market_url(Some(old.join("index.json").to_string_lossy().into_owned()));
|
||||
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
assert_eq!(listed[0].entry.id, "acme.hello");
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0");
|
||||
|
||||
// A loop gives up.
|
||||
std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "index.json"}"#).unwrap();
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("moved more than"), "{}", err.message);
|
||||
}
|
||||
|
||||
/// `dir/index.json` listing `publishers` and `plugins`; its path.
|
||||
fn index_of(dir: &Path, publishers: Vec<Publisher>, plugins: Vec<IndexEntry>) -> String {
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() };
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn publisher(id: &str, keys: Vec<KeyEntry>) -> Publisher {
|
||||
Publisher { id: id.into(), name: id.into(), keys }
|
||||
}
|
||||
|
||||
fn updates_announced(events: &Events) -> Vec<Vec<String>> {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
PluginEvent::UpdatesAvailable { updates } => {
|
||||
Some(updates.iter().map(|u| format!("{} {}", u.id, u.version)).collect())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What the install page sends for an update it doesn't need to show: the grants the installed version has.
|
||||
fn as_granted(preview: &InstallPreview) -> Consent {
|
||||
let optional = preview.capabilities.iter().filter(|c| c.optional && c.granted).map(|c| c.id.clone()).collect();
|
||||
Consent { optional, signer_change: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_keep_their_signer_and_need_no_consent_when_nothing_is_new() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let same = |t: &str| t.to_owned();
|
||||
let (_, v1) = signed(&market, "acme", "1.0.0", &k1, &f.host, same);
|
||||
f.host.set_market_url(Some(index_of(
|
||||
&market,
|
||||
vec![publisher("acme", vec![KeyEntry::new(k1.public_base64())])],
|
||||
vec![v1.clone()],
|
||||
)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let first = f.host.preview_install(&dir).unwrap();
|
||||
assert!(first.needs_consent && !first.signer_changed, "a first install is confirmed");
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
assert!(updates_announced(&f.events).is_empty());
|
||||
|
||||
// 1.1.0, same key and needs: announced once, installed with the grants it has.
|
||||
let (_, v11) = signed(&market, "acme", "1.1.0", &k1, &f.host, same);
|
||||
let keys1 = vec![KeyEntry::new(k1.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v1.clone(), v11.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
f.host.refresh_market().unwrap();
|
||||
assert_eq!(updates_announced(&f.events), [["acme.hello 1.1.0"]], "once per set of versions");
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!(preview.replaces.as_deref(), Some("1.0.0"));
|
||||
assert!(!preview.needs_consent && !preview.signer_changed);
|
||||
assert!(preview.capabilities.iter().all(|c| c.granted), "shows what the installed version was granted");
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert!(f.host.market_plugins().iter().all(|p| !p.is_update()));
|
||||
|
||||
// 1.2.0 asks for something new: the user is asked.
|
||||
let (_, v12) = signed(&market, "acme", "1.2.0", &k1, &f.host, |t| {
|
||||
t.replace(
|
||||
"required = [\"editor.read\", \"editor.write\"]",
|
||||
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"net:example.com\"]",
|
||||
)
|
||||
});
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v12.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.needs_consent && !preview.signer_changed);
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
|
||||
// 1.3.0 signed with a key the installed one didn't endorse: another signer, the user's call.
|
||||
let (_, v13) = signed(&market, "acme", "1.3.0", &k2, &f.host, same);
|
||||
let keys12 = vec![KeyEntry::new(k1.public_base64()), KeyEntry::new(k2.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys12)], vec![v13]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.signer_changed && preview.needs_consent);
|
||||
let err = f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap_err();
|
||||
assert!(err.message.contains("another key"), "{}", err.message);
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.2.0", "nothing changed");
|
||||
f.host.install_from_dir(&dir, &Consent { signer_change: true, ..as_granted(&preview) }).unwrap();
|
||||
|
||||
// 1.4.0 signed with k3, which k2 endorsed: a rotation the installed signer vouched for.
|
||||
let (_, v14) = signed(&market, "acme", "1.4.0", &k3, &f.host, same);
|
||||
let keys = vec![
|
||||
KeyEntry { endorsement: Some(k2.endorse("acme", &k3.public_base64())), ..KeyEntry::new(k3.public_base64()) },
|
||||
KeyEntry::new(k2.public_base64()),
|
||||
];
|
||||
index_of(&market, vec![publisher("acme", keys)], vec![v14]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(!preview.signer_changed && !preview.needs_consent);
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.4.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_in_a_newer_format_still_revokes() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": "a shape this IDE can't read",
|
||||
"revoked": [{{"id": "acme.hello", "reason": "steals tokens"}}]}}"#,
|
||||
package::INDEX_FORMAT + 1
|
||||
);
|
||||
std::fs::write(market.join("index.json"), newer).unwrap();
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("newer OpenIDEA"), "{}", err.message);
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
|
||||
assert!(f.host.market_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_plugins_need_the_key_this_ide_was_built_with() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (root, intruder) = (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([6; 32]));
|
||||
f.host.set_official_keys(vec![root.public_base64()]);
|
||||
// The index lists the intruder's key for the official publisher.
|
||||
let (_, forged) = signed(&market, OFFICIAL_PUBLISHER, "1.0.0", &intruder, &f.host, |t| t.to_owned());
|
||||
let keys = vec![KeyEntry::new(intruder.public_base64())];
|
||||
f.host.set_market_url(Some(index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys.clone())], vec![forged])));
|
||||
f.host.refresh_market().unwrap();
|
||||
let err = f.host.download_market_plugin("openidea.hello").unwrap_err();
|
||||
assert!(err.message.contains("trusted publisher"), "{}", err.message);
|
||||
|
||||
// Signed with the built-in key it installs, whatever keys the index lists.
|
||||
let (_, genuine) = signed(&market, OFFICIAL_PUBLISHER, "1.0.1", &root, &f.host, |t| t.to_owned());
|
||||
index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys)], vec![genuine]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
|
||||
}
|
||||
@@ -123,6 +123,19 @@ fn misbehave_pkg() -> PathBuf {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
|
||||
}
|
||||
|
||||
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
|
||||
/// another (or behind a new instance starting) holds once it caught up.
|
||||
fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while !f() {
|
||||
if Instant::now() > until {
|
||||
return false;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
|
||||
fn write_theme_pair(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("t")).unwrap();
|
||||
@@ -397,26 +410,22 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None));
|
||||
|
||||
// Three in a row: stopped for the session, with growing waits between.
|
||||
// Three in a row: stopped for the session, backing off between them (the wait
|
||||
// doubles: `Limits::backoff`, tested where it is defined; wall-clock waits on CI
|
||||
// are too noisy to compare).
|
||||
f.take_events();
|
||||
// How long each crash kept the next call waiting, without the restart itself, whose
|
||||
// instantiation time varies a lot on CI.
|
||||
let mut waits = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let started = Instant::now();
|
||||
for crash in 0..3 {
|
||||
let err = loop {
|
||||
let attempt = Instant::now();
|
||||
match f.run("misbehave", "panic", None) {
|
||||
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
|
||||
other => {
|
||||
waits.push(attempt - started);
|
||||
break other.unwrap_err();
|
||||
}
|
||||
other => break other.unwrap_err(),
|
||||
}
|
||||
};
|
||||
assert!(err.contains("crashed"), "{err}");
|
||||
if crash < 2 {
|
||||
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("restarts in"), "backing off");
|
||||
}
|
||||
}
|
||||
assert!(waits[1] >= backoff / 2 && waits[2] > waits[1] + backoff / 2, "the wait doubles: {waits:?}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Failed);
|
||||
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
|
||||
@@ -531,16 +540,22 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
let provider = host.syntax_provider();
|
||||
let mis = LanguageId::new("mis");
|
||||
|
||||
let tokens = provider.highlights(&mis, "fast").unwrap();
|
||||
// The first call starts the instance, which may take longer than the soft deadline.
|
||||
let mut tokens = None;
|
||||
assert!(eventually(|| {
|
||||
tokens = provider.highlights(&mis, "fast");
|
||||
tokens.is_some()
|
||||
}));
|
||||
let tokens = tokens.unwrap();
|
||||
assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword"));
|
||||
|
||||
// Slower than the soft deadline: dropped (however late this thread gets
|
||||
// to look), but the instance lives on.
|
||||
assert!(provider.highlights(&mis, "sleep:150").is_none());
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
assert!(provider.highlights(&mis, "fast").is_some());
|
||||
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
|
||||
let info = host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.stats.slow_calls, info.stats.crashes, info.stats.activations), (1, 0, 1));
|
||||
assert!(info.stats.slow_calls >= 1, "{:?}", info.stats);
|
||||
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1));
|
||||
|
||||
// Past the hard deadline: interrupted; the next call gets a new instance.
|
||||
assert!(provider.highlights(&mis, "spin").is_none());
|
||||
@@ -549,7 +564,8 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
let info = host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.stats.crashes, 1, "{:?}", info.error);
|
||||
assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error);
|
||||
assert!(provider.highlights(&mis, "fast").is_some());
|
||||
// The new instance may take longer to start than the soft deadline on a slow runner.
|
||||
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
|
||||
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
}
|
||||
|
||||
@@ -558,9 +574,10 @@ fn edit_path_calls_have_soft_and_hard_deadlines() {
|
||||
#[test]
|
||||
fn providers_of_several_plugins_run_concurrently() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// A budget no runner misses, so only the overlap is measured.
|
||||
let limits = Limits {
|
||||
edit_soft: Duration::from_millis(300),
|
||||
edit_hard: Duration::from_secs(2),
|
||||
edit_soft: Duration::from_secs(5),
|
||||
edit_hard: Duration::from_secs(10),
|
||||
fuel: u64::MAX,
|
||||
..Limits::default()
|
||||
};
|
||||
@@ -593,12 +610,12 @@ fn providers_of_several_plugins_run_concurrently() {
|
||||
assert_eq!(host.provide_completion(&doc("fast"), at, None).len(), 2);
|
||||
|
||||
let started = Instant::now();
|
||||
let items = host.provide_completion(&doc("sleep:200"), at, None);
|
||||
let items = host.provide_completion(&doc("sleep:500"), at, None);
|
||||
let elapsed = started.elapsed();
|
||||
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
|
||||
from.sort();
|
||||
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
|
||||
assert!(elapsed < Duration::from_millis(350), "one after the other would take 400ms: {elapsed:?}");
|
||||
assert!(elapsed < Duration::from_millis(950), "one after the other would take 1000ms: {elapsed:?}");
|
||||
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
|
||||
}
|
||||
|
||||
@@ -618,7 +635,7 @@ fn idle_instances_are_reclaimed_and_schedules_reactivate() {
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
|
||||
assert_eq!(f.command_titles("misbehave").len(), 8, "contributions stay");
|
||||
assert_eq!(f.command_titles("misbehave").len(), 10, "contributions stay");
|
||||
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
|
||||
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
|
||||
|
||||
@@ -2393,3 +2410,56 @@ fn uninstalling_deletes_the_plugins_secrets() {
|
||||
f.host.uninstall("openidea.ai-completion").unwrap();
|
||||
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
|
||||
/// providers carry snippets, imports, lazily computed quick fixes and tagged
|
||||
/// problems.
|
||||
#[test]
|
||||
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let f = fixture(Limits::default());
|
||||
let pkg = f._tmp.path().join("misbehave-providers");
|
||||
copy_dir(&misbehave_pkg(), &pkg);
|
||||
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
f.install(&pkg);
|
||||
let doc = |text: &str| ide_plugin::ProviderDocument {
|
||||
path: f.project.join("a.txt"),
|
||||
language: "text".into(),
|
||||
version: 1,
|
||||
text: text.into(),
|
||||
};
|
||||
let at = ide_core::Pos::new(0, 0);
|
||||
|
||||
let items = f.host.provide_completion(&doc("rich"), at, None);
|
||||
let item = &items[0].1;
|
||||
assert_eq!(
|
||||
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
|
||||
("f(${1:x})$0", true, Some("fun"))
|
||||
);
|
||||
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
|
||||
|
||||
// Offered without edits; computed when picked.
|
||||
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
|
||||
let (plugin, action) = actions.into_iter().next().expect("offered");
|
||||
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone()).unwrap();
|
||||
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
|
||||
|
||||
// Tags the IDE knows, the tool that found it.
|
||||
let path = f.project.join("a.txt");
|
||||
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
|
||||
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
|
||||
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -16,3 +16,6 @@ parking_lot.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -243,6 +243,9 @@ struct Inner {
|
||||
pid: Option<u32>,
|
||||
/// Fired when the reader task has stopped (EOF, error or kill).
|
||||
reader_done: CancellationToken,
|
||||
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
|
||||
/// also what it started); at once when there is no child.
|
||||
exited: CancellationToken,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -432,6 +435,7 @@ impl Peer {
|
||||
(child.wait().await.ok(), true)
|
||||
}
|
||||
};
|
||||
inner.exited.cancel();
|
||||
if !killed {
|
||||
// Bounded: a grandchild may keep the pipes open.
|
||||
let drained = async {
|
||||
@@ -510,7 +514,11 @@ impl Peer {
|
||||
kill: CancellationToken::new(),
|
||||
pid,
|
||||
reader_done: CancellationToken::new(),
|
||||
exited: CancellationToken::new(),
|
||||
});
|
||||
if pid.is_none() {
|
||||
inner.exited.cancel();
|
||||
}
|
||||
|
||||
// Writer: a single task drains the queue so messages never interleave.
|
||||
{
|
||||
@@ -704,6 +712,13 @@ impl Peer {
|
||||
self.inner.close_transport();
|
||||
self.inner.kill.cancel();
|
||||
}
|
||||
|
||||
/// Resolves once the child process is gone: exited on its own, or killed with what it
|
||||
/// started and reaped. [`closed`](Self::closed) can come earlier, at EOF. Until then the
|
||||
/// child may still hold files open, which on Windows can't be deleted.
|
||||
pub async fn exited(&self) {
|
||||
self.inner.exited.cancelled().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the pending entry and tells the remote side to stop when a request
|
||||
|
||||
@@ -413,6 +413,65 @@ async fn kill_terminates_process() {
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.closed()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_resolves_once_the_killed_process_is_reaped() {
|
||||
let peer =
|
||||
Peer::spawn("sleep", &["30".into()], &std::env::temp_dir(), &[], Arc::new(NoopHandler), Box::new(|_| {}))
|
||||
.unwrap();
|
||||
let pid = peer.pid().unwrap().to_string();
|
||||
let exited = tokio::time::timeout(Duration::from_millis(200), peer.exited()).await;
|
||||
assert!(exited.is_err(), "running until killed");
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
|
||||
let alive = std::process::Command::new("kill").args(["-0", &pid]).status().unwrap().success();
|
||||
assert!(!alive, "reaped, not even a zombie");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_resolves_at_once_without_a_process() {
|
||||
let (client, _server) = pair(Arc::new(NoopHandler), Arc::new(Recorder::default()));
|
||||
tokio::time::timeout(Duration::from_secs(1), client.exited()).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn exited_waits_for_a_windows_descendant_to_release_its_file() {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("hold.ps1"),
|
||||
"$f=[IO.File]::Open('locked.txt','Create','ReadWrite','None'); [IO.File]::WriteAllText('ready.txt','ready'); Start-Sleep 30",
|
||||
)
|
||||
.unwrap();
|
||||
let peer = Peer::spawn(
|
||||
"cmd",
|
||||
&[
|
||||
"/D".into(),
|
||||
"/C".into(),
|
||||
"powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File hold.ps1".into(),
|
||||
],
|
||||
tmp.path(),
|
||||
&[],
|
||||
Arc::new(NoopHandler),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while !tmp.path().join("ready.txt").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let open = || std::fs::OpenOptions::new().read(true).share_mode(0).open(tmp.path().join("locked.txt"));
|
||||
assert_eq!(open().unwrap_err().raw_os_error(), Some(32));
|
||||
assert!(tokio::time::timeout(Duration::from_millis(200), peer.exited()).await.is_err());
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), peer.exited()).await.unwrap();
|
||||
drop(open().expect("exited must wait for the descendant's file handle to close"));
|
||||
}
|
||||
|
||||
/// A launcher that prints in the system code page and exits before speaking the
|
||||
/// protocol: `jdtls.bat` on a Chinese Windows when its Python step fails, then
|
||||
/// `pause` prints `请按任意键继续. . .` in GBK on stdout.
|
||||
|
||||
@@ -467,7 +467,6 @@ const MAX_SYNTAX_ITEMS: usize = 200_000;
|
||||
const MAX_BATCH: usize = 1000;
|
||||
|
||||
fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
use crate::types::ActionSource as S;
|
||||
match e {
|
||||
WorkbenchEvent::FileOpened(p) => wit_events::Event::FileOpened(p),
|
||||
WorkbenchEvent::FileSaved(p) => wit_events::Event::FileSaved(p),
|
||||
@@ -477,14 +476,7 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
WorkbenchEvent::ActionPerformed(a) => wit_events::Event::ActionPerformed(wit_events::ActionPerformed {
|
||||
action: a.action,
|
||||
title: a.title,
|
||||
source: match a.source {
|
||||
S::Keyboard => wit_events::ActionSource::Keyboard,
|
||||
S::Menu => wit_events::ActionSource::Menu,
|
||||
S::Toolbar => wit_events::ActionSource::Toolbar,
|
||||
S::ContextMenu => wit_events::ActionSource::ContextMenu,
|
||||
S::Search => wit_events::ActionSource::Search,
|
||||
S::Other => wit_events::ActionSource::Other,
|
||||
},
|
||||
source: a.source.name().to_owned(),
|
||||
shortcut: a.shortcut,
|
||||
}),
|
||||
}
|
||||
@@ -670,9 +662,17 @@ impl Runtime {
|
||||
detail: i.detail.map(|d| clip(d, MAX_TITLE)),
|
||||
kind: i.kind.map(|k| clip(k, 32)),
|
||||
insert_text: clip(i.insert_text, 64 << 10),
|
||||
snippet: i.snippet,
|
||||
replace: i.replace.map(range_from_wit),
|
||||
sort_text: i.sort_text.map(|t| clip(t, 64)),
|
||||
filter_text: i.filter_text.map(|t| clip(t, MAX_TITLE)),
|
||||
documentation: i.documentation.map(|d| clip(d, 16 << 10)),
|
||||
additional_edits: i
|
||||
.additional_edits
|
||||
.into_iter()
|
||||
.take(MAX_ADDITIONAL_EDITS)
|
||||
.map(edit_from_wit)
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -699,27 +699,20 @@ impl Runtime {
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions
|
||||
.into_iter()
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|a| CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let edits = f
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|e| ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text })
|
||||
.collect();
|
||||
(std::path::PathBuf::from(f.path), edits)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect())
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
pub fn resolve_code_action(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
resolved.map(code_action_from_wit).map_err(|e| CallError::Guest(e.into()))
|
||||
}
|
||||
|
||||
pub fn provide_definition(
|
||||
@@ -968,6 +961,47 @@ const MAX_VIEW_ITEMS: usize = 20_000;
|
||||
const MAX_CELL: usize = 64 << 10;
|
||||
const MAX_HTML: usize = 4 << 20;
|
||||
|
||||
/// Most additional edits of a completion item.
|
||||
const MAX_ADDITIONAL_EDITS: usize = 100;
|
||||
|
||||
fn edit_from_wit(e: wit_types::TextEdit) -> ide_core::TextEdit {
|
||||
ide_core::TextEdit { range: range_from_wit(e.range), new_text: e.text }
|
||||
}
|
||||
|
||||
fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
}
|
||||
}
|
||||
|
||||
fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
wit_prov::CodeAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_prov::FileEdit {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
@@ -975,6 +1009,9 @@ fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
wit_vt::Body::Other(e) => {
|
||||
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
|
||||
}
|
||||
wit_vt::Body::Tree(items) => ViewBody::Tree(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -1006,10 +1043,8 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
fields
|
||||
.into_iter()
|
||||
.take(MAX_VIEW_ITEMS)
|
||||
.map(|f| ViewField {
|
||||
key: clip(f.key, 128),
|
||||
label: clip(f.label, MAX_TITLE),
|
||||
value: match f.value {
|
||||
.filter_map(|f| {
|
||||
let value = match f.value {
|
||||
wit_vt::FieldValue::Text(t) => FieldValue::Text(clip(t, MAX_CELL)),
|
||||
wit_vt::FieldValue::Multiline(t) => FieldValue::Multiline(clip(t, 1 << 20)),
|
||||
wit_vt::FieldValue::Checkbox(b) => FieldValue::Checkbox(b),
|
||||
@@ -1017,7 +1052,10 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
|
||||
selected: clip(selected, MAX_TITLE),
|
||||
},
|
||||
},
|
||||
// A field type of a newer API: left out.
|
||||
wit_vt::FieldValue::Other(_) => return None,
|
||||
};
|
||||
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
@@ -1067,3 +1105,26 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
ViewEvent::Message { text } => W::Message(text),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ext(kind: &str) -> wit_types::Extension {
|
||||
wit_types::Extension { kind: kind.into(), data: "{}".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_and_fields_of_a_newer_api_degrade() {
|
||||
let view = |body| wit_vt::View { toolbar: Vec::new(), body, status: None };
|
||||
let shown = view_from_wit(view(wit_vt::Body::Other(ext("markdown"))));
|
||||
assert_eq!(shown.body, ViewBody::Message("This view needs a newer version of OpenIDEA (markdown).".into()));
|
||||
let field = |key: &str, value| wit_vt::Field { key: key.into(), label: key.into(), value };
|
||||
let form = view_from_wit(view(wit_vt::Body::Form(vec![
|
||||
field("color", wit_vt::FieldValue::Other(ext("color"))),
|
||||
field("name", wit_vt::FieldValue::Text("n".into())),
|
||||
])));
|
||||
let ViewBody::Form(fields) = form.body else { panic!("a form") };
|
||||
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,20 @@ pub enum ActionSource {
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ActionSource {
|
||||
/// As plugins see it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Keyboard => "keyboard",
|
||||
Self::Menu => "menu",
|
||||
Self::Toolbar => "toolbar",
|
||||
Self::ContextMenu => "context-menu",
|
||||
Self::Search => "search",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActionPerformed {
|
||||
@@ -303,6 +317,16 @@ pub fn net_host(url: &str) -> Result<String, String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_restart_backoff_doubles_up_to_a_minute() {
|
||||
let limits = Limits { restart_backoff: Duration::from_millis(150), ..Limits::default() };
|
||||
let waits: Vec<u128> = (1..=4).map(|n| limits.backoff(n).as_millis()).collect();
|
||||
assert_eq!(waits, [150, 300, 600, 1200]);
|
||||
assert_eq!(limits.backoff(30), Duration::from_millis(150 * 64), "the factor stops growing");
|
||||
let long = Limits { restart_backoff: Duration::from_secs(10), ..Limits::default() };
|
||||
assert_eq!(long.backoff(5), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -338,9 +362,14 @@ pub struct CompletionItem {
|
||||
/// WIT `completion-kind` name (`method`, `keyword`, …).
|
||||
pub kind: Option<String>,
|
||||
pub insert_text: String,
|
||||
/// `insert_text` has tab stops and placeholders.
|
||||
pub snippet: bool,
|
||||
pub replace: Option<ide_core::Range>,
|
||||
pub sort_text: Option<String>,
|
||||
pub filter_text: Option<String>,
|
||||
pub documentation: Option<String>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
pub additional_edits: Vec<ide_core::TextEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -355,6 +384,8 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -440,20 +440,11 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from_wit(k: events::EventKind) -> EventKind {
|
||||
match k {
|
||||
events::EventKind::FileOpened => EventKind::FileOpened,
|
||||
events::EventKind::FileSaved => EventKind::FileSaved,
|
||||
events::EventKind::EditorFocused => EventKind::EditorFocused,
|
||||
events::EventKind::ActionPerformed => EventKind::ActionPerformed,
|
||||
events::EventKind::VcsChanged => EventKind::VcsChanged,
|
||||
events::EventKind::Scheduled => EventKind::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: events::EventKind) -> Fallible<()> {
|
||||
let kind = kind_from_wit(kind);
|
||||
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
return Ok(Err(wit::Error::Invalid(format!("no event kind {kind:?} in this OpenIDEA"))));
|
||||
};
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -463,8 +454,10 @@ impl events::Host for StoreData {
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: events::EventKind) -> wasmtime::Result<()> {
|
||||
self.host.subscribe(kind_from_wit(kind), false);
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
if let Some(kind) = EventKind::parse(&kind) {
|
||||
self.host.subscribe(kind, false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -542,10 +535,10 @@ impl diagnostics::Host for StoreData {
|
||||
diagnostics::Severity::Hint => ide_core::Severity::Hint,
|
||||
},
|
||||
message: clip(d.message, 4096),
|
||||
source: Some(source.clone()),
|
||||
source: Some(d.source.map_or_else(|| source.clone(), |s| clip(s, 128))),
|
||||
code: d.code.map(|c| clip(c, 128)),
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
unnecessary: d.tags.iter().any(|t| t == "unnecessary"),
|
||||
deprecated: d.tags.iter().any(|t| t == "deprecated"),
|
||||
server: Some(source.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
86dfe70a30ab1f7955a16e96c1b32c8be663456b871cc4d3bed7dc6052c062da
|
||||
c9acbc021f3840649823cb7680e13907d429c6fda8271fdb0ae6e5ea89ed15ee
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0dacb82ac35c1f132dc66e85dbf0663b729f818279d3b32cd06403e502fbf880
|
||||
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0ad2e4c6977a576b7eb27fc1e535fd056dd9da64bdfe6cfda9ca9f57537d0384
|
||||
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
|
||||
@@ -40,3 +40,11 @@ title = "insert"
|
||||
[[contributes.commands]]
|
||||
id = "cancel"
|
||||
title = "cancel"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "subscribe"
|
||||
title = "subscribe"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "diagnose"
|
||||
title = "diagnose"
|
||||
+52
-4
@@ -14,7 +14,9 @@ wit_bindgen::generate!({
|
||||
generate_all,
|
||||
});
|
||||
|
||||
use exports::openidea::plugin::providers::{self, CodeAction, CompletionItem, Document, InlineSuggestion, LineMarker};
|
||||
use exports::openidea::plugin::providers::{
|
||||
self, CodeAction, CompletionItem, Document, FileEdit, InlineSuggestion, LineMarker,
|
||||
};
|
||||
use exports::openidea::plugin::syntax_provider::{self, Fold, Symbol, Token};
|
||||
use exports::openidea::plugin::{commands, event_handler, lifecycle};
|
||||
use openidea::plugin::{invocation::Invocation, types};
|
||||
@@ -67,6 +69,23 @@ impl commands::Guest for Misbehave {
|
||||
}
|
||||
// The user gave up: not the plugin failing.
|
||||
"cancel" => Err(types::Error::Cancelled),
|
||||
// Event kinds are names: one this IDE doesn't know is refused.
|
||||
"subscribe" => openidea::plugin::events::subscribe(&arg.unwrap_or_default()).map(|()| None),
|
||||
// Tagged problems from another tool on the line 0 of `arg`.
|
||||
"diagnose" => {
|
||||
use openidea::plugin::diagnostics::{self, Diagnostic, Severity};
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
let d = Diagnostic {
|
||||
range: types::Range { start, end: start },
|
||||
severity: Severity::Warning,
|
||||
message: "unused".into(),
|
||||
code: None,
|
||||
source: Some("lint".into()),
|
||||
tags: vec!["unnecessary".into(), "glowing".into()],
|
||||
related: Vec::new(),
|
||||
};
|
||||
diagnostics::publish(&arg.unwrap_or_default(), &[d]).map(|()| None)
|
||||
}
|
||||
other => Err(types::Error::NotFound(format!("command {other}"))),
|
||||
}
|
||||
}
|
||||
@@ -113,14 +132,24 @@ impl syntax_provider::Guest for Misbehave {
|
||||
impl providers::Guest for Misbehave {
|
||||
fn completion(doc: Document, _at: types::Position, _trigger: Option<String>) -> Vec<CompletionItem> {
|
||||
behave(&doc.text);
|
||||
// `rich`: a snippet that also adds an import.
|
||||
let rich = doc.text == "rich";
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
vec![CompletionItem {
|
||||
label: doc.path.clone(),
|
||||
detail: None,
|
||||
kind: Some("keyword".into()),
|
||||
insert_text: doc.path,
|
||||
insert_text: if rich { "f(${1:x})$0".into() } else { doc.path },
|
||||
snippet: rich,
|
||||
replace: None,
|
||||
sort_text: None,
|
||||
filter_text: rich.then(|| "fun".into()),
|
||||
documentation: None,
|
||||
additional_edits: if rich {
|
||||
vec![types::TextEdit { range: types::Range { start, end: start }, text: "use f;\n".into() }]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -128,8 +157,27 @@ impl providers::Guest for Misbehave {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_actions(_doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
Vec::new()
|
||||
/// `lazy`: one action whose edits come from `resolve-code-action`.
|
||||
fn code_actions(doc: Document, _selection: types::Range) -> Vec<CodeAction> {
|
||||
if doc.text != "lazy" {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![CodeAction {
|
||||
title: "Fix later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: Vec::new(),
|
||||
data: Some("fix".into()),
|
||||
}]
|
||||
}
|
||||
|
||||
fn resolve_code_action(doc: Document, action: CodeAction) -> Result<CodeAction, types::Error> {
|
||||
if action.data.as_deref() != Some("fix") {
|
||||
return Err(types::Error::Invalid("not one of mine".into()));
|
||||
}
|
||||
let start = types::Position { line: 0, character: 0 };
|
||||
let edit = types::TextEdit { range: types::Range { start, end: start }, text: "fixed ".into() };
|
||||
Ok(CodeAction { edits: vec![FileEdit { path: doc.path, edits: vec![edit] }], ..action })
|
||||
}
|
||||
|
||||
fn definition(_doc: Document, _at: types::Position) -> Vec<types::Location> {
|
||||
|
||||
+104
-4
@@ -79,6 +79,7 @@ authors = ["You <[email protected]>"]
|
||||
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,13 @@ 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, and any after the first three it knows, are
|
||||
ignored (`check` warns about both).
|
||||
|
||||
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
|
||||
@@ -313,6 +321,11 @@ Events are batched: a running plugin gets them within a fraction of a second,
|
||||
an idle one collects them for 2 s before it is woken, so frequent events
|
||||
(Key Promoter's actions) don't keep it alive.
|
||||
|
||||
Kinds are names (`subscribe` takes a string), so later IDEs add kinds without
|
||||
a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
|
||||
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
|
||||
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
|
||||
|
||||
### Providers
|
||||
|
||||
Declare what the plugin provides in the editor; only plugins whose
|
||||
@@ -330,7 +343,14 @@ Then `register_plugin!(MyPlugin, providers)` and implement
|
||||
the document (path, language, version, text). Results join the language
|
||||
servers' (completion lists, ⌥⏎ quick fixes, ⌘B, gutter icons; a code action
|
||||
carries edits for any files, applied as one change). A completion item's
|
||||
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon.
|
||||
`kind` (`CompletionKind` in the SDK, a string in the WIT) picks its icon; it
|
||||
can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
|
||||
of its label, and bring `additional_edits` elsewhere in the document (an
|
||||
import). A code action can leave `edits` empty and set `data`: its edits are
|
||||
then computed by `resolve_code_action` when the user picks it, under the
|
||||
command budget instead of the 50 ms. Problems (`publish_diagnostics`) can name
|
||||
the tool that found them (`source`) and be `unnecessary` (faded) or
|
||||
`deprecated` (struck through).
|
||||
Editor providers run under the edit-path budget: 50 ms, after which the
|
||||
result is dropped, and an interruption at 500 ms. Every plugin providing a
|
||||
kind is asked at the same time, so each has the whole budget.
|
||||
@@ -385,6 +405,9 @@ collapse of items, form fields, table cells, inserted and deleted rows, and
|
||||
`html` messages. A tree is a flat list whose items name their `parent`; an
|
||||
`expandable` item gets its children when it is expanded. A table gets only
|
||||
the rows it shows (`viewport`): report `total_rows` and return those.
|
||||
Bodies, field types and view events added by later APIs travel as `Other {
|
||||
kind, data }`: an IDE that doesn't know a body says it needs updating, and
|
||||
leaves out a field type it doesn't know.
|
||||
|
||||
Showing a view activates the plugin (`on-view:<id>`). `views` become tool
|
||||
windows; a `customEditors` file (`priority = "default"`) opens in the
|
||||
@@ -739,6 +762,12 @@ granted, and starts the approved program.
|
||||
the version it replaced automatically (you're told why); _Roll Back to …_
|
||||
in the plugin's details does it by hand. Data (`storage`) is per plugin, not
|
||||
per version.
|
||||
- **Updates:** when the daily marketplace check finds newer versions of
|
||||
installed plugins, the IDE says so once, with _Update All_ (also in the gear
|
||||
menu and as the _Update All Plugins_ action). An update that asks for
|
||||
nothing new installs with the grants the installed version has; the install
|
||||
page is shown only for new capabilities, an unsigned package or another
|
||||
signer (see _Distribution_).
|
||||
- **Linked plugins:** `openidea-plugin link <dir>` (§1). Listed with a _linked_
|
||||
tag; capabilities are granted per folder.
|
||||
- **Project plugins:** put (or symlink) the package in
|
||||
@@ -758,6 +787,9 @@ files).
|
||||
openidea-plugin keygen acme.key # once; keep the file secret
|
||||
openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme
|
||||
openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key
|
||||
openidea-plugin check acme.tools-1.0.0.oiplugin # a package as installing would check it
|
||||
openidea-plugin merge market/index.json https://example.com/index.json --keep acme
|
||||
openidea-plugin endorse <new public key> --key acme.key --publisher acme # when rotating keys
|
||||
```
|
||||
|
||||
A **marketplace** is static: an `index.json` and the packages next to it, on
|
||||
@@ -773,8 +805,68 @@ and the publisher's key, then shows the install page.
|
||||
- **Revocation:** the index can revoke plugin versions (or all versions) and
|
||||
publisher keys. The IDE reads the index daily; revoked plugins are disabled
|
||||
with the reason and the user is notified — also after a restart, before
|
||||
their code could run. A publisher rotates keys by adding the new one; older
|
||||
keys stay valid until revoked.
|
||||
their code could run.
|
||||
- **Key rotation:** a publisher rotates keys by registering the new one with
|
||||
`index --key new.key --previous-key old.key`: the old key endorses the new
|
||||
one, and older keys stay valid until revoked (`endorse` prints the
|
||||
endorsement for an index someone else writes, such as the registry's). An installed plugin updates
|
||||
silently only to a package signed with the key it was installed with, or
|
||||
one that key endorsed (directly or through endorsed keys; endorsements by
|
||||
revoked keys don't count). Another signer — an unendorsed key, or an
|
||||
unsigned package replacing a signed one — is shown on the install page and
|
||||
needs the user's confirmation.
|
||||
- **Official key:** release builds carry the official publisher's public key
|
||||
(`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from the release's signing key).
|
||||
`openidea.*` plugins must be signed with it, or with a key it endorsed,
|
||||
whatever keys an index lists for `openidea`.
|
||||
- **Format:** fields are only ever added to the index (an IDE ignores those it
|
||||
doesn't know), and `format` stays `1` unless older IDEs can't read the
|
||||
change. An IDE reading a newer format still applies its revocations and
|
||||
`moved`, lists no plugins, and asks the user to update.
|
||||
- **Moving:** an index with `"moved": "<url>"` sends the IDE to the index at
|
||||
that URL (relative or absolute; https stays https), so a marketplace can
|
||||
change hosts while released IDEs keep the address they were built with.
|
||||
|
||||
**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; an entry without one is offered to no IDE), 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. 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.
|
||||
|
||||
`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`.
|
||||
|
||||
### 8.1 Official and community plugins
|
||||
|
||||
The IDE's default marketplace is `plugins-index.json` of the latest release.
|
||||
It lists two kinds of plugins:
|
||||
|
||||
- **Official** (`plugins/official/`, ids `openidea.*`): maintained in this
|
||||
repository, built and signed by its release workflow, and kept working as
|
||||
the API changes. New official plugins are proposed in an issue first; they
|
||||
cover general needs or show how to use part of the API.
|
||||
- **Community** (their authors' ids and keys): developed in their authors'
|
||||
repositories and listed through the registry,
|
||||
[l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins).
|
||||
An author registers a publisher (an id prefix, its public key and the GitHub
|
||||
accounts that may change it) and adds each plugin version by pull request;
|
||||
the registry checks the package (digest, signature, `openidea-plugin check`)
|
||||
and publishes its index, which `plugins-index.yml` merges into the release's
|
||||
within the hour. Its README has the rules.
|
||||
|
||||
Pull requests that add a plugin to this repository are for official plugins.
|
||||
A plugin that needs a new extension point brings the extension point here, as
|
||||
its own pull request, and the plugin to the registry.
|
||||
|
||||
## 9. Testing the host
|
||||
|
||||
@@ -813,7 +905,10 @@ only grows:
|
||||
Extend with new types and new functions (or interfaces). `types.error` is
|
||||
used everywhere and is no exception; new situations use `failed(string)`
|
||||
or `invalid(string)`. Sets that keep growing (completion, token, symbol and
|
||||
fold kinds, icons) are strings for this reason.
|
||||
fold kinds, icons, event kinds, action sources, diagnostic tags) are
|
||||
strings for this reason, and variants that grow (`event`, view `body`,
|
||||
`view-event`, `field-value`) have an `other(extension)` case, a kind and
|
||||
JSON data, that carries later cases to older plugins and IDEs.
|
||||
2. **Host interfaces (imports) may gain functions and interfaces**: older
|
||||
components don't import them.
|
||||
3. **Plugin interfaces (exports) never gain functions** once released: the
|
||||
@@ -825,6 +920,11 @@ only grows:
|
||||
minor with a message that says to update the IDE or rebuild with the SDK.
|
||||
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
|
||||
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
|
||||
6. **1.x calls are synchronous and pass documents whole.** Host calls block
|
||||
the plugin's instance, and providers get the document's text with each
|
||||
call. WASI 0.3 async components come with 2.0, behind an adapter in the
|
||||
host that keeps 1.x plugins running; incremental document sync, if
|
||||
needed, is a new interface.
|
||||
|
||||
Checked automatically:
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283
|
||||
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
|
||||
@@ -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
|
||||
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
|
||||
@@ -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:*"]
|
||||
|
||||
@@ -97,12 +97,8 @@ fn diagnostic(p: &Problem, text: &str) -> Diagnostic {
|
||||
let line_len = text.split('\n').nth(p.line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
let start = p.column.unwrap_or(0).min(line_len);
|
||||
let end = if p.column.is_some() && start < line_len { start + 1 } else { line_len };
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start))),
|
||||
severity: p.severity,
|
||||
message: p.message.clone(),
|
||||
code: Some(p.check.clone()),
|
||||
}
|
||||
let range = Range::new(Position::new(p.line, start), Position::new(p.line, end.max(start)));
|
||||
Diagnostic::new(range, p.severity, p.message.clone()).code(p.check.clone())
|
||||
}
|
||||
|
||||
/// The project containing `path`.
|
||||
@@ -188,6 +184,7 @@ impl Providers for Checkstyle {
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: true,
|
||||
edits: vec![(doc.path.clone(), vec![edit])],
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
|
||||
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
|
||||
@@ -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
|
||||
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
|
||||
@@ -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
|
||||
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
|
||||
@@ -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"]
|
||||
|
||||
@@ -52,12 +52,9 @@ pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
|
||||
.map(|r| {
|
||||
let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32);
|
||||
let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: if r.error { Severity::Error } else { Severity::Warning },
|
||||
message: r.message.clone(),
|
||||
code: Some("graphviz".into()),
|
||||
}
|
||||
let range = Range::new(Position::new(line, 0), Position::new(line, len));
|
||||
let severity = if r.error { Severity::Error } else { Severity::Warning };
|
||||
Diagnostic::new(range, severity, r.message.clone()).code("graphviz")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980
|
||||
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
|
||||
@@ -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
|
||||
63f1bbdf6eca5a4642cd3453ae384ff3f876de0373c12008b757a63852164b71
|
||||
@@ -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:*"]
|
||||
|
||||
@@ -129,12 +129,8 @@ pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str
|
||||
fn missing(text: &str) -> Diagnostic {
|
||||
let line = insert_line(text);
|
||||
let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: Severity::Info,
|
||||
message: "No license header".into(),
|
||||
code: Some(CODE.into()),
|
||||
}
|
||||
Diagnostic::new(Range::new(Position::new(line, 0), Position::new(line, len)), Severity::Info, "No license header")
|
||||
.code(CODE)
|
||||
}
|
||||
|
||||
/// Reports `path` when it takes a header and has none, clears it otherwise.
|
||||
@@ -274,6 +270,7 @@ impl Providers for LicenseHeader {
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: true,
|
||||
edits: vec![(doc.path.clone(), vec![edit])],
|
||||
data: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f7df58bb0ab2b92370b148de2614174ec1ff9b01c0078b5c20bd331f82298616
|
||||
0db5cb24c358651d0e728ab3efb52286290c1a38ec379435225c935d4165ad6a
|
||||
@@ -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
|
||||
257478b050ea360c55e904f2a89c6069293139ff0d97ba860de3f29dac927787
|
||||
@@ -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
|
||||
21e7510d6a3712bf0f21d53a5c8d652e3c2787d89a1fc25998cde96efe124207
|
||||
@@ -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
|
||||
04844bfbfbcb1770f4f4742658944c592922b037b2387e9340de58ef0d9baacc
|
||||
@@ -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
|
||||
|
||||
@@ -152,24 +152,12 @@ mod imp {
|
||||
wit::vcs::fetch(p).map_err(error)
|
||||
}
|
||||
|
||||
fn kind(k: EventKind) -> wit::events::EventKind {
|
||||
use wit::events::EventKind as K;
|
||||
match k {
|
||||
EventKind::FileOpened => K::FileOpened,
|
||||
EventKind::FileSaved => K::FileSaved,
|
||||
EventKind::EditorFocused => K::EditorFocused,
|
||||
EventKind::ActionPerformed => K::ActionPerformed,
|
||||
EventKind::VcsChanged => K::VcsChanged,
|
||||
EventKind::Scheduled => K::Scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(k: EventKind) -> Result<(), Error> {
|
||||
wit::events::subscribe(kind(k)).map_err(error)
|
||||
wit::events::subscribe(k.name()).map_err(error)
|
||||
}
|
||||
|
||||
pub fn unsubscribe(k: EventKind) {
|
||||
wit::events::unsubscribe(kind(k))
|
||||
wit::events::unsubscribe(k.name())
|
||||
}
|
||||
|
||||
pub fn begin_task(title: &str, cancellable: bool) -> TaskRes {
|
||||
@@ -189,7 +177,7 @@ mod imp {
|
||||
}
|
||||
|
||||
pub fn publish_diagnostics(path: &str, items: &[Diagnostic]) -> Result<(), Error> {
|
||||
use wit::diagnostics::{Diagnostic as D, Severity as S};
|
||||
use wit::diagnostics::{Diagnostic as D, RelatedInformation as R, Severity as S};
|
||||
let items: Vec<D> = items
|
||||
.iter()
|
||||
.map(|d| D {
|
||||
@@ -202,6 +190,23 @@ mod imp {
|
||||
},
|
||||
message: d.message.clone(),
|
||||
code: d.code.clone(),
|
||||
source: d.source.clone(),
|
||||
tags: [(d.unnecessary, "unnecessary"), (d.deprecated, "deprecated")]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, tag)| tag.to_owned())
|
||||
.collect(),
|
||||
related: d
|
||||
.related
|
||||
.iter()
|
||||
.map(|r| R {
|
||||
location: wit::types::Location {
|
||||
path: r.location.path.clone(),
|
||||
range: range_out(r.location.range),
|
||||
},
|
||||
message: r.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
wit::diagnostics::publish(path, &items).map_err(error)
|
||||
@@ -264,7 +269,7 @@ mod imp {
|
||||
}
|
||||
|
||||
pub fn event(e: wit::events::Event) -> Event {
|
||||
use wit::events::{ActionSource as S, Event as E};
|
||||
use wit::events::Event as E;
|
||||
match e {
|
||||
E::FileOpened(p) => Event::FileOpened(p),
|
||||
E::FileSaved(p) => Event::FileSaved(p),
|
||||
@@ -274,16 +279,10 @@ mod imp {
|
||||
E::ActionPerformed(a) => Event::ActionPerformed(ActionPerformed {
|
||||
action: a.action,
|
||||
title: a.title,
|
||||
source: match a.source {
|
||||
S::Keyboard => ActionSource::Keyboard,
|
||||
S::Menu => ActionSource::Menu,
|
||||
S::Toolbar => ActionSource::Toolbar,
|
||||
S::ContextMenu => ActionSource::ContextMenu,
|
||||
S::Search => ActionSource::Search,
|
||||
S::Other => ActionSource::Other,
|
||||
},
|
||||
source: ActionSource::parse(&a.source),
|
||||
shortcut: a.shortcut,
|
||||
}),
|
||||
E::Other(x) => Event::Other { kind: x.kind, data: x.data },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,16 @@ macro_rules! __providers_exports {
|
||||
$crate::__rt::code_actions::<$plugin>(doc, selection)
|
||||
}
|
||||
|
||||
fn resolve_code_action(
|
||||
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
|
||||
action: $crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
|
||||
) -> ::core::result::Result<
|
||||
$crate::providers_bindings::exports::openidea::plugin::providers::CodeAction,
|
||||
$crate::bindings::openidea::plugin::types::Error,
|
||||
> {
|
||||
$crate::__rt::resolve_code_action::<$plugin>(doc, action)
|
||||
}
|
||||
|
||||
fn definition(
|
||||
doc: $crate::providers_bindings::exports::openidea::plugin::providers::Document,
|
||||
at: $crate::bindings::openidea::plugin::types::Position,
|
||||
@@ -420,9 +430,12 @@ pub mod __rt {
|
||||
detail: i.detail,
|
||||
kind: i.kind.map(|k| k.as_str().to_owned()),
|
||||
insert_text: i.insert_text,
|
||||
snippet: i.snippet,
|
||||
replace: i.replace.map(crate::host::__range_to),
|
||||
sort_text: i.sort_text,
|
||||
filter_text: i.filter_text,
|
||||
documentation: i.documentation,
|
||||
additional_edits: i.additional_edits.into_iter().map(edit_out).collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -443,25 +456,54 @@ pub mod __rt {
|
||||
sel: b::types::Range,
|
||||
) -> Vec<w::CodeAction> {
|
||||
let d = doc(d);
|
||||
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel)))
|
||||
.into_iter()
|
||||
.map(|a| w::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| w::FileEdit {
|
||||
path,
|
||||
edits: edits
|
||||
.into_iter()
|
||||
.map(|e| b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
with::<T, _>(|x| x.code_actions(&d, crate::host::__range_from(sel))).into_iter().map(action_out).collect()
|
||||
}
|
||||
|
||||
pub fn resolve_code_action<T: Plugin + p::Providers + Default>(
|
||||
d: w::Document,
|
||||
action: w::CodeAction,
|
||||
) -> Result<w::CodeAction, b::types::Error> {
|
||||
let d = doc(d);
|
||||
with::<T, _>(|x| x.resolve_code_action(&d, action_in(action))).map(action_out).map_err(error_out)
|
||||
}
|
||||
|
||||
fn edit_out(e: crate::TextEdit) -> b::types::TextEdit {
|
||||
b::types::TextEdit { range: crate::host::__range_to(e.range), text: e.text }
|
||||
}
|
||||
|
||||
fn action_out(a: p::CodeAction) -> w::CodeAction {
|
||||
w::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| w::FileEdit { path, edits: edits.into_iter().map(edit_out).collect() })
|
||||
.collect(),
|
||||
data: a.data,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_in(a: w::CodeAction) -> p::CodeAction {
|
||||
p::CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let edits = f
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|e| crate::TextEdit { range: crate::host::__range_from(e.range), text: e.text })
|
||||
.collect();
|
||||
(f.path, edits)
|
||||
})
|
||||
.collect(),
|
||||
data: a.data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn definition<T: Plugin + p::Providers + Default>(
|
||||
@@ -531,6 +573,9 @@ pub mod __rt {
|
||||
v::FieldValue::Multiline(t) => vt::FieldValue::Multiline(t),
|
||||
v::FieldValue::Checkbox(c) => vt::FieldValue::Checkbox(c),
|
||||
v::FieldValue::Choice(o, s) => vt::FieldValue::Choice((o, s)),
|
||||
v::FieldValue::Other { kind, data } => {
|
||||
vt::FieldValue::Other(b::types::Extension { kind, data })
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
@@ -543,6 +588,7 @@ pub mod __rt {
|
||||
editable: t.editable,
|
||||
}),
|
||||
v::Body::Html(h) => vt::Body::Html(h),
|
||||
v::Body::Other { kind, data } => vt::Body::Other(b::types::Extension { kind, data }),
|
||||
};
|
||||
vt::View {
|
||||
toolbar: view
|
||||
@@ -582,6 +628,7 @@ pub mod __rt {
|
||||
vt::ViewEvent::InsertRow(r) => v::ViewEvent::InsertRow(r),
|
||||
vt::ViewEvent::DeleteRows(r) => v::ViewEvent::DeleteRows(r),
|
||||
vt::ViewEvent::Message(m) => v::ViewEvent::Message(m),
|
||||
vt::ViewEvent::Other(x) => v::ViewEvent::Other { kind: x.kind, data: x.data },
|
||||
};
|
||||
with::<T, _>(|p| p.handle(&view, event, &ctx)).map_err(error_out)
|
||||
}
|
||||
|
||||
@@ -722,11 +722,8 @@ mod tests {
|
||||
let out = mock.invocation().project().unwrap().run_process("java", &["-version".into()], None).unwrap();
|
||||
assert_eq!(out.stdout, "-version");
|
||||
assert_eq!(mock.process_runs()[0].cwd.as_deref(), Some("/project"));
|
||||
host::publish_diagnostics(
|
||||
"/project/a.java",
|
||||
&[Diagnostic { range: Range::default(), severity: Severity::Warning, message: "m".into(), code: None }],
|
||||
)
|
||||
.unwrap();
|
||||
host::publish_diagnostics("/project/a.java", &[Diagnostic::new(Range::default(), Severity::Warning, "m")])
|
||||
.unwrap();
|
||||
assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,18 @@ pub struct CompletionItem {
|
||||
pub label: String,
|
||||
pub detail: Option<String>,
|
||||
pub kind: Option<CompletionKind>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
pub insert_text: String,
|
||||
pub snippet: bool,
|
||||
/// Replaced on accept; `None`: the word at the caret.
|
||||
pub replace: Option<Range>,
|
||||
pub sort_text: Option<String>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
pub filter_text: Option<String>,
|
||||
/// Markdown.
|
||||
pub documentation: Option<String>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
pub additional_edits: Vec<TextEdit>,
|
||||
}
|
||||
|
||||
impl CompletionItem {
|
||||
@@ -107,9 +113,12 @@ impl CompletionItem {
|
||||
detail: None,
|
||||
kind: Some(kind),
|
||||
insert_text: label.into(),
|
||||
snippet: false,
|
||||
replace: None,
|
||||
sort_text: None,
|
||||
filter_text: None,
|
||||
documentation: None,
|
||||
additional_edits: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +138,9 @@ pub struct CodeAction {
|
||||
pub preferred: bool,
|
||||
/// (absolute path, edits) per file.
|
||||
pub edits: Vec<(String, Vec<TextEdit>)>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits come
|
||||
/// from [`Providers::resolve_code_action`] once the user picks the action.
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -157,6 +169,12 @@ pub trait Providers {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The edits of an action [`Providers::code_actions`] returned with
|
||||
/// `data`, when the user picks it (the command budget applies, not 50 ms).
|
||||
fn resolve_code_action(&mut self, _doc: &Document, action: CodeAction) -> Result<CodeAction, crate::Error> {
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
fn definition(&mut self, _doc: &Document, _at: Position) -> Vec<Location> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -143,7 +143,9 @@ pub struct StatusItem {
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// Kinds of workbench events; later SDKs add more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum EventKind {
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
@@ -154,17 +156,46 @@ pub enum EventKind {
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
impl EventKind {
|
||||
/// The name the IDE knows it by.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::EditorFocused => "editor-focused",
|
||||
Self::ActionPerformed => "action-performed",
|
||||
Self::VcsChanged => "vcs-changed",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the user started an action; later IDEs may report more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ActionSource {
|
||||
Keyboard,
|
||||
Menu,
|
||||
Toolbar,
|
||||
ContextMenu,
|
||||
Search,
|
||||
/// Anything else, including sources this SDK doesn't know.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ActionSource {
|
||||
pub fn parse(name: &str) -> Self {
|
||||
match name {
|
||||
"keyboard" => Self::Keyboard,
|
||||
"menu" => Self::Menu,
|
||||
"toolbar" => Self::Toolbar,
|
||||
"context-menu" => Self::ContextMenu,
|
||||
"search" => Self::Search,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ActionPerformed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
@@ -177,6 +208,7 @@ pub struct ActionPerformed {
|
||||
|
||||
/// A workbench event (see [`crate::host::subscribe`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Event {
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
@@ -186,6 +218,11 @@ pub enum Event {
|
||||
VcsChanged(String),
|
||||
/// The `on-schedule` period elapsed.
|
||||
Scheduled,
|
||||
/// A kind this SDK doesn't know: its name and JSON data.
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The repository containing a project.
|
||||
@@ -227,6 +264,41 @@ pub struct Diagnostic {
|
||||
pub message: String,
|
||||
/// Rule or check id.
|
||||
pub code: Option<String>,
|
||||
/// The tool that found it, when not the plugin itself.
|
||||
pub source: Option<String>,
|
||||
/// Code that does nothing (shown faded).
|
||||
pub unnecessary: bool,
|
||||
/// Use of something deprecated (struck through).
|
||||
pub deprecated: bool,
|
||||
/// Other places that explain it.
|
||||
pub related: Vec<RelatedInformation>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn new(range: Range, severity: Severity, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
range,
|
||||
severity,
|
||||
message: message.into(),
|
||||
code: None,
|
||||
source: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(mut self, code: impl Into<String>) -> Self {
|
||||
self.code = Some(code.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RelatedInformation {
|
||||
pub location: Location,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// An HTTP request (see [`crate::host::fetch`]).
|
||||
@@ -276,3 +348,17 @@ pub struct ProcessOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_the_ide_uses() {
|
||||
assert_eq!(EventKind::ActionPerformed.name(), "action-performed");
|
||||
assert_eq!(ActionSource::parse("context-menu"), ActionSource::ContextMenu);
|
||||
assert_eq!(ActionSource::parse("pen"), ActionSource::Other, "a source of a newer IDE");
|
||||
let d = Diagnostic::new(Range::default(), Severity::Hint, "m").code("c");
|
||||
assert_eq!((d.code.as_deref(), d.unnecessary, d.related.len()), (Some("c"), false, 0));
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,19 @@ impl ListItem {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum FieldValue {
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
/// Options and the chosen one.
|
||||
Choice(Vec<String>, String),
|
||||
/// A field type of a newer plugin API: its kind and JSON data (IDEs that
|
||||
/// don't know it leave the field out).
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -142,6 +149,7 @@ pub struct Table {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Body {
|
||||
Message(String),
|
||||
Tree(Vec<TreeItem>),
|
||||
@@ -151,6 +159,12 @@ pub enum Body {
|
||||
/// Shown in a sandboxed frame; its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` and `openidea.onMessage(fn)`.
|
||||
Html(String),
|
||||
/// A body of a newer plugin API: its kind and JSON data (IDEs that don't
|
||||
/// know it say they need updating).
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -197,6 +211,7 @@ impl Viewport {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ViewEvent {
|
||||
Button(String),
|
||||
Activate(String),
|
||||
@@ -213,6 +228,11 @@ pub enum ViewEvent {
|
||||
InsertRow(u32),
|
||||
DeleteRows(Vec<u32>),
|
||||
Message(String),
|
||||
/// An event this SDK doesn't know: its kind and JSON data.
|
||||
Other {
|
||||
kind: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Content of the plugin's declared views and custom editors.
|
||||
|
||||
@@ -2,36 +2,15 @@ package openidea:[email protected];
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
use types.{error};
|
||||
|
||||
enum event-kind {
|
||||
file-opened,
|
||||
file-saved,
|
||||
/// The focused editor changed.
|
||||
editor-focused,
|
||||
/// An IDE action ran. Requires `events.input`.
|
||||
action-performed,
|
||||
/// A repository's state changed (commit, checkout, fetch, …).
|
||||
vcs-changed,
|
||||
/// The plugin's `on-schedule` period elapsed.
|
||||
scheduled,
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
enum action-source {
|
||||
keyboard,
|
||||
menu,
|
||||
toolbar,
|
||||
context-menu,
|
||||
search,
|
||||
other,
|
||||
}
|
||||
use types.{error, extension};
|
||||
|
||||
record action-performed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
action: string,
|
||||
title: string,
|
||||
source: action-source,
|
||||
/// How the user started it: `keyboard`, `menu`, `toolbar`,
|
||||
/// `context-menu`, `search` or `other` (more may be added).
|
||||
source: string,
|
||||
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
|
||||
shortcut: option<string>,
|
||||
}
|
||||
@@ -43,14 +22,21 @@ interface events {
|
||||
action-performed(action-performed),
|
||||
vcs-changed(string),
|
||||
scheduled,
|
||||
/// A kind added after 1.0 (subscribed to by its name).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// and an IDE that doesn't know one fails with `invalid`.
|
||||
/// Requires `events` (`events.input` for `action-performed`).
|
||||
subscribe: func(kind: event-kind) -> result<_, error>;
|
||||
unsubscribe: func(kind: event-kind);
|
||||
subscribe: func(kind: string) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
|
||||
@@ -2,7 +2,7 @@ package openidea:[email protected];
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range};
|
||||
use types.{error, range, location};
|
||||
|
||||
enum severity {
|
||||
error,
|
||||
@@ -11,12 +11,23 @@ interface diagnostics {
|
||||
hint,
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic (the earlier declaration, say).
|
||||
record related-information {
|
||||
location: location,
|
||||
message: string,
|
||||
}
|
||||
|
||||
record diagnostic {
|
||||
range: range,
|
||||
severity: severity,
|
||||
message: string,
|
||||
/// Rule or check id (`LineLength`).
|
||||
code: option<string>,
|
||||
/// The tool that found it, when not the plugin itself (`checkstyle`).
|
||||
source: option<string>,
|
||||
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
|
||||
tags: list<string>,
|
||||
related: list<related-information>,
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
|
||||
|
||||
@@ -5,7 +5,7 @@ package openidea:[email protected];
|
||||
/// on the editing path: a result later than ~50 ms is dropped, a call still
|
||||
/// running at 500 ms is interrupted.
|
||||
interface providers {
|
||||
use types.{position, range, text-edit, location};
|
||||
use types.{error, position, range, text-edit, location};
|
||||
|
||||
/// The document a request is about.
|
||||
record document {
|
||||
@@ -24,11 +24,17 @@ interface providers {
|
||||
/// constant, struct, event, operator or type-parameter. Unknown kinds
|
||||
/// show no icon.
|
||||
kind: option<string>,
|
||||
/// With `snippet`, tab stops and placeholders (`$1`, `${1:name}`, `$0`).
|
||||
insert-text: string,
|
||||
snippet: bool,
|
||||
/// Replaced on accept; `none`: the word at the caret.
|
||||
replace: option<range>,
|
||||
sort-text: option<string>,
|
||||
/// Matched against what was typed instead of `label`.
|
||||
filter-text: option<string>,
|
||||
documentation: option<string>,
|
||||
/// Applied with the item elsewhere in the document (an import).
|
||||
additional-edits: list<text-edit>,
|
||||
}
|
||||
|
||||
record inline-suggestion {
|
||||
@@ -48,6 +54,9 @@ interface providers {
|
||||
kind: option<string>,
|
||||
preferred: bool,
|
||||
edits: list<file-edit>,
|
||||
/// The plugin's own reference: with it and no `edits`, the edits are
|
||||
/// computed by `resolve-code-action` once the user picks the action.
|
||||
data: option<string>,
|
||||
}
|
||||
|
||||
record line-marker {
|
||||
@@ -64,6 +73,9 @@ interface providers {
|
||||
inline-completion: func(doc: document, at: position) -> option<inline-suggestion>;
|
||||
/// Actions for `selection` (quick fixes for the plugin's own diagnostics).
|
||||
code-actions: func(doc: document, selection: range) -> list<code-action>;
|
||||
/// The edits of an action `code-actions` returned with `data`, when the
|
||||
/// user picks it (the command budget applies, not the editor's 50 ms).
|
||||
resolve-code-action: func(doc: document, action: code-action) -> result<code-action, error>;
|
||||
definition: func(doc: document, at: position) -> list<location>;
|
||||
line-markers: func(doc: document) -> list<line-marker>;
|
||||
}
|
||||
@@ -52,6 +52,15 @@ interface types {
|
||||
range: range,
|
||||
}
|
||||
|
||||
/// A case added after 1.0, carried by the `other` case of a variant that
|
||||
/// grows (events, view bodies, view events, form fields): its kind and its
|
||||
/// data as JSON. Whoever doesn't know the kind ignores it or shows a
|
||||
/// fallback; the SDKs decode the kinds they know.
|
||||
record extension {
|
||||
kind: string,
|
||||
data: string,
|
||||
}
|
||||
|
||||
/// Passed to `lifecycle.activate`.
|
||||
record context {
|
||||
plugin-id: string,
|
||||
|
||||
@@ -8,6 +8,8 @@ package openidea:[email protected];
|
||||
/// runs in the WebView, except a `html` body, which is shown in a sandboxed
|
||||
/// frame that can only exchange messages with the plugin.
|
||||
interface view-types {
|
||||
use types.{extension};
|
||||
|
||||
record button {
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -43,6 +45,8 @@ interface view-types {
|
||||
checkbox(bool),
|
||||
/// The options and the chosen one.
|
||||
choice(tuple<list<string>, string>),
|
||||
/// A field type added after 1.0 (an IDE that doesn't know it leaves it out).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record field {
|
||||
@@ -72,6 +76,8 @@ interface view-types {
|
||||
/// network, no IDE API. Its script talks to the plugin with
|
||||
/// `openidea.postMessage(text)` / `openidea.onMessage(fn)`.
|
||||
html(string),
|
||||
/// A body added after 1.0; an IDE that doesn't know it says it needs updating.
|
||||
other(extension),
|
||||
}
|
||||
|
||||
record view {
|
||||
@@ -109,6 +115,8 @@ interface view-types {
|
||||
delete-rows(list<u32>),
|
||||
/// From the `html` body's script.
|
||||
message(string),
|
||||
/// An event added after 1.0, from a body or field added after 1.0.
|
||||
other(extension),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-1
@@ -2239,6 +2239,11 @@ export type Consent = {
|
||||
* ones are implied by consenting at all.
|
||||
*/
|
||||
optional: string[],
|
||||
/**
|
||||
* The user accepted a package signed by another key than the installed
|
||||
* copy ([`InstallPreview::signer_changed`]).
|
||||
*/
|
||||
signerChange?: boolean,
|
||||
};
|
||||
|
||||
/** Build, Execution, Deployment › Console. */
|
||||
@@ -4139,6 +4144,14 @@ 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.
|
||||
* Required: an entry without one is offered to no IDE.
|
||||
*/
|
||||
engine?: string,
|
||||
/** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */
|
||||
categories?: string[],
|
||||
};
|
||||
|
||||
export type IndexStatus = {
|
||||
@@ -4296,6 +4309,18 @@ export type InstallPreview = {
|
||||
unsigned: boolean,
|
||||
/** The verified publisher. */
|
||||
publisher: string | null,
|
||||
/**
|
||||
* Replaces an installed copy signed with another key, which didn't
|
||||
* endorse this package's (or this one is unsigned): the user confirms it
|
||||
* ([`Consent::signer_change`]).
|
||||
*/
|
||||
signerChanged: boolean,
|
||||
/**
|
||||
* Something to confirm: a first install, an unsigned package, another
|
||||
* signer, or capabilities the installed version wasn't granted or
|
||||
* refused. Without, an update installs with the grants it has.
|
||||
*/
|
||||
needsConsent: boolean,
|
||||
};
|
||||
|
||||
/** A theme of a plugin installed in a JetBrains IDE. */
|
||||
@@ -4871,6 +4896,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 = {
|
||||
@@ -5244,7 +5271,12 @@ export type PluginEditor = {
|
||||
/** Backend → UI notifications (Tauri event `ide://plugin`). */
|
||||
export type PluginEvent =
|
||||
/** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */
|
||||
{ type: "commandsChanged" } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
{ type: "commandsChanged" } |
|
||||
/**
|
||||
* The marketplace offers newer versions of installed plugins (announced
|
||||
* once per set of versions).
|
||||
*/
|
||||
{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } |
|
||||
/** A plugin's status bar item was set (`item`) or removed. */
|
||||
{ type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } |
|
||||
/** A plugin began a task: show it with the background tasks. */
|
||||
@@ -5353,6 +5385,13 @@ export type PluginStatusItem = {
|
||||
item: StatusItem,
|
||||
};
|
||||
|
||||
/** A newer version of an installed plugin in the marketplace. */
|
||||
export type PluginUpdate = {
|
||||
id: string,
|
||||
name: string,
|
||||
version: string,
|
||||
};
|
||||
|
||||
export type PluginsSnapshot = {
|
||||
plugins: PluginInfo[],
|
||||
activeTheme: ActiveTheme | null,
|
||||
|
||||
@@ -380,6 +380,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.categorySelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailsNotice {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.marketOverview {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
SearchField,
|
||||
Select,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
@@ -28,6 +29,7 @@ import { RISK_LABEL, sortByRisk } from "./consent";
|
||||
import { itemsOf, uiPoint } from "./projection";
|
||||
import {
|
||||
compareVersions,
|
||||
isUpdate,
|
||||
grantPlugin,
|
||||
installFromDisk,
|
||||
installFromMarket,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
setPluginCapability,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
usePlugins,
|
||||
} from "./store";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -536,9 +539,15 @@ interface GearActions {
|
||||
importInstalledIntellij: () => void;
|
||||
}
|
||||
|
||||
function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] {
|
||||
function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: GearActions): MenuItem[] {
|
||||
const installed = plugins.filter((p) => p.source === "installed");
|
||||
return [
|
||||
{
|
||||
label: updates > 0 ? tx`Update All Plugins (${updates})` : "Update All Plugins",
|
||||
disabled: updates === 0,
|
||||
run: () => void updateAllPlugins(),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
|
||||
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
|
||||
...(actions.canImport
|
||||
@@ -572,6 +581,79 @@ 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")}
|
||||
className={styles.categorySelect}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The marketplace index URL, editable in place. */
|
||||
function MarketUrlBar() {
|
||||
const current = usePlugins((s) => s.marketUrl);
|
||||
@@ -597,7 +679,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 = isUpdate(entry);
|
||||
return (
|
||||
<li className={clsx(styles.row, selected && styles.rowSelected)}>
|
||||
<button
|
||||
@@ -620,6 +702,8 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
|
||||
<Badge tone="accent">{tx("Update")}</Badge>
|
||||
) : entry.installed ? (
|
||||
<Badge tone="neutral">{tx("Installed")}</Badge>
|
||||
) : !entry.compatible ? (
|
||||
<Badge tone="warning">{tx("Incompatible")}</Badge>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
@@ -627,7 +711,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 = isUpdate(entry);
|
||||
const install = async () => {
|
||||
const id = await installFromMarket(entry.id);
|
||||
if (id) onInstalled(id);
|
||||
@@ -645,7 +729,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
{(!entry.installed || update) && (
|
||||
{entry.compatible && (!entry.installed || update) && (
|
||||
<Button size="small" variant="primary" disabled={installing} onClick={() => void install()}>
|
||||
{update ? tx`Update to ${entry.version}` : tx("Install")}
|
||||
</Button>
|
||||
@@ -653,12 +737,22 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.marketOverview}>
|
||||
{!entry.compatible && (
|
||||
<p className={clsx(styles.notice, styles.detailsNotice)} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<span>{tx`Needs plugin API ${entry.engine}. Update OpenIDEA to install it.`}</span>
|
||||
</p>
|
||||
)}
|
||||
{entry.description && <p className={styles.description}>{entry.description}</p>}
|
||||
<dl className={styles.facts}>
|
||||
<dt>
|
||||
<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>
|
||||
@@ -698,13 +792,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;
|
||||
|
||||
@@ -714,7 +810,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) => {
|
||||
@@ -727,7 +827,7 @@ export function PluginsPage() {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
gearMenu(plugins, {
|
||||
gearMenu(plugins, (market ?? []).filter(isUpdate).length, {
|
||||
canImport: useProject.getState().project?.trusted === true,
|
||||
install: (packageFile) => void installFromDisk(undefined, packageFile).then(showInstalled),
|
||||
importIntellijFile: () => void importIntellijTheme().then(showInstalled),
|
||||
@@ -819,7 +919,12 @@ export function PluginsPage() {
|
||||
<li className={styles.marketItem}>
|
||||
<MarketUrlBar />
|
||||
</li>
|
||||
{!marketUrl && (
|
||||
{(market?.length ?? 0) > 0 && (
|
||||
<li className={styles.marketItem}>
|
||||
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
|
||||
</li>
|
||||
)}
|
||||
{!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>
|
||||
@@ -847,7 +952,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>
|
||||
|
||||
@@ -63,6 +63,8 @@ function backend(overrides: Partial<PluginsBackend> = {}): PluginsBackend {
|
||||
replaces: null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import type { Consent, InstallPreview, MarketPlugin, PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import { _setActiveEditor } from "@/editor/api";
|
||||
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -30,10 +30,12 @@ import { useTasks } from "@/workbench/tasks";
|
||||
import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
|
||||
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
|
||||
import { ConsentDialogHost, provideConsentDialog } from "../consent";
|
||||
import {
|
||||
adoptIconTheme,
|
||||
compareVersions,
|
||||
installFromDisk,
|
||||
updateAllPlugins,
|
||||
refreshPlugins,
|
||||
runPluginCommand,
|
||||
selectPluginTheme,
|
||||
@@ -568,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", () => {
|
||||
@@ -621,7 +638,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 +663,206 @@ 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()),
|
||||
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(<PluginsPage />);
|
||||
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();
|
||||
});
|
||||
|
||||
describe("updates", () => {
|
||||
const offered = (id: string, version: string, installed: string): MarketPlugin => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: `${id}.oiplugin`,
|
||||
digest: "x",
|
||||
capabilities: [],
|
||||
engine: "^1.0",
|
||||
installed,
|
||||
compatible: true,
|
||||
});
|
||||
const preview = (id: string, version: string, extra: Partial<InstallPreview> = {}): InstallPreview => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
hasCode: true,
|
||||
capabilities: [
|
||||
{ id: "editor.read", description: "Read", risk: "low", optional: false, granted: true },
|
||||
{ id: "net:a.example", description: "Net", risk: "medium", optional: true, granted: true },
|
||||
{ id: "net:b.example", description: "Net", risk: "medium", optional: true, granted: false },
|
||||
],
|
||||
contributions: [],
|
||||
warnings: [],
|
||||
replaces: "1.0.0",
|
||||
unsigned: false,
|
||||
publisher: "acme",
|
||||
signerChanged: false,
|
||||
needsConsent: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function updatesBackend(previews: Record<string, InstallPreview>) {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "a", version: "1.0.0" }),
|
||||
plugin({ id: "b", version: "1.0.0" }),
|
||||
]);
|
||||
const consents: [string, Consent][] = [];
|
||||
setPluginsBackend({
|
||||
...base,
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
offered("a", "1.1.0", "1.0.0"),
|
||||
offered("b", "2.0.0", "1.0.0"),
|
||||
offered("c", "1.0.0", "1.0.0"),
|
||||
],
|
||||
marketDownload: async (id) => id,
|
||||
previewInstall: async (dir) => previews[dir]!,
|
||||
installDir: async (dir, consent) => {
|
||||
consents.push([dir, consent]);
|
||||
return plugin({ id: dir, name: dir.toUpperCase(), version: previews[dir]!.version });
|
||||
},
|
||||
});
|
||||
return consents;
|
||||
}
|
||||
|
||||
it("installs updates with nothing to confirm without asking, keeping their grants", async () => {
|
||||
const asked: ConsentRequest[] = [];
|
||||
setConsentPrompt(async (req) => {
|
||||
asked.push(req);
|
||||
return { optional: [], signerChange: !!req.signerChanged };
|
||||
});
|
||||
const consents = updatesBackend({
|
||||
a: preview("a", "1.1.0"),
|
||||
b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }),
|
||||
});
|
||||
expect(await updateAllPlugins()).toBe(2);
|
||||
// `a` with the optional capability it had; `b` asked, with the change of signer.
|
||||
expect(asked.map((r) => [r.pluginId, r.signerChanged])).toEqual([["b", true]]);
|
||||
expect(consents).toEqual([
|
||||
["a", { optional: ["net:a.example"] }],
|
||||
["b", { optional: [], signerChange: true }],
|
||||
]);
|
||||
expect(notes.map((n) => n.title)).toEqual(["Updated 2 plugins"]);
|
||||
});
|
||||
|
||||
it("warns about another signer on the install page and confirms it", async () => {
|
||||
const consents = updatesBackend({ b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }) });
|
||||
const stop = provideConsentDialog();
|
||||
const user = userEvent.setup();
|
||||
render(<ConsentDialogHost />);
|
||||
const done = installFromDisk("b");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("signed with a different key");
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
await done;
|
||||
expect(consents[0]![1]).toMatchObject({ signerChange: true });
|
||||
stop();
|
||||
});
|
||||
|
||||
it("offers Update All in the gear menu while there are updates", async () => {
|
||||
setConsentPrompt(async () => ({ optional: [] }));
|
||||
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<PluginsPage />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
|
||||
await user.click(screen.getByRole("button", { name: "Manage Plugins" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Update All Plugins (2)" }));
|
||||
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
|
||||
});
|
||||
|
||||
it("announces updates with actions to install them or show the plugins", async () => {
|
||||
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
handlePluginEvent({
|
||||
type: "updatesAvailable",
|
||||
updates: [
|
||||
{ id: "a", name: "A", version: "1.1.0" },
|
||||
{ id: "b", name: "B", version: "2.0.0" },
|
||||
],
|
||||
});
|
||||
const note = notes.at(-1)!;
|
||||
expect(note).toMatchObject({ title: "2 plugin updates available", body: "A 1.1.0, B 2.0.0" });
|
||||
expect(note.actions?.map((a) => a.label)).toEqual(["Update All", "Show Plugins"]);
|
||||
note.actions![0]!.run();
|
||||
await waitFor(() => expect(notes.at(-1)?.title).toBe("Updated 2 plugins"));
|
||||
});
|
||||
});
|
||||
|
||||
it("offers to roll back an update", async () => {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
|
||||
|
||||
@@ -429,6 +429,8 @@ export function createMemoryPluginsBackend(
|
||||
replaces: plugins.find((p) => p.id === entry.id)?.version ?? null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* variables, notifications become balloons, consent requests open the
|
||||
* permission dialog, and opened files fire `on-language` / `on-file`.
|
||||
*/
|
||||
import type { ActiveTheme, PluginEvent } from "@/bindings";
|
||||
import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
useThemeOverride,
|
||||
windowBackgroundHex,
|
||||
} from "@/workbench/theme";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
|
||||
|
||||
export { pluginActionId } from "./points";
|
||||
|
||||
@@ -244,11 +245,28 @@ function trackActiveFile(): () => void {
|
||||
|
||||
// --- events -----------------------------------------------------------------
|
||||
|
||||
function announceUpdates(updates: readonly PluginUpdate[]) {
|
||||
const names = updates.map((u) => `${u.name} ${u.version}`).join(", ");
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "info",
|
||||
title: updates.length === 1 ? "Plugin update available" : `${updates.length} plugin updates available`,
|
||||
body: names,
|
||||
actions: [
|
||||
{ label: "Update All", run: () => void updateAllPlugins() },
|
||||
{ label: "Show Plugins", run: () => void executeAction("plugins.settings") },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function handlePluginEvent(e: PluginEvent) {
|
||||
switch (e.type) {
|
||||
case "commandsChanged":
|
||||
scheduleRefresh();
|
||||
break;
|
||||
case "updatesAvailable":
|
||||
announceUpdates(e.updates);
|
||||
break;
|
||||
case "notification": {
|
||||
const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId;
|
||||
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
|
||||
|
||||
@@ -59,7 +59,7 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
{
|
||||
label: req.kind === "install" ? tx("Install") : tx("Allow"),
|
||||
role: "default",
|
||||
onClick: () => finish({ optional: [...accepted] }),
|
||||
onClick: () => finish({ optional: [...accepted], signerChange: !!req.signerChanged }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -69,6 +69,14 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
<T>This package is not signed. Only continue if you trust where it came from.</T>
|
||||
</p>
|
||||
)}
|
||||
{req.signerChanged && (
|
||||
<p className={styles.consentWarning} role="alert">
|
||||
<T>
|
||||
This version is signed with a different key than the installed one, and the publisher's earlier key
|
||||
doesn't vouch for it. Only continue if you know the publisher changed keys.
|
||||
</T>
|
||||
</p>
|
||||
)}
|
||||
{caps.length === 0 ? (
|
||||
<p>
|
||||
<T>This plugin needs no special permissions.</T>
|
||||
|
||||
@@ -33,7 +33,7 @@ import { defineBuiltinUiPoints } from "./points";
|
||||
import { startViewUi } from "./uiPoints";
|
||||
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
|
||||
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
|
||||
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
|
||||
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store";
|
||||
|
||||
function registerPluginsPage(): () => void {
|
||||
return registerSettingsPage({
|
||||
@@ -89,6 +89,16 @@ export function activate(): () => void {
|
||||
await installFromDisk(typeof arg === "string" ? arg : undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plugins.updateAll",
|
||||
title: "Update All Plugins",
|
||||
category: "Plugins",
|
||||
icon: "plugin",
|
||||
keywords: ["upgrade", "marketplace", "new version"],
|
||||
run: async () => {
|
||||
await updateAllPlugins();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plugins.importIntellijTheme",
|
||||
title: "Import IntelliJ Theme from 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";
|
||||
@@ -71,6 +77,8 @@ export interface ConsentRequest {
|
||||
capabilities: CapabilityInfo[];
|
||||
/** Unsigned package (from disk or a project folder). */
|
||||
unsigned: boolean;
|
||||
/** Replaces an installed copy signed with another key that didn't vouch for this one. */
|
||||
signerChanged?: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -209,8 +217,13 @@ export async function setMarketUrl(url: string | null): Promise<void> {
|
||||
await refreshMarket(true);
|
||||
}
|
||||
|
||||
/** A version this IDE can load, newer than the one installed. */
|
||||
export function isUpdate(entry: MarketPlugin): boolean {
|
||||
return entry.compatible && !!entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
}
|
||||
|
||||
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
|
||||
export async function installFromMarket(id: string): Promise<string | null> {
|
||||
export async function installFromMarket(id: string, options: InstallOptions = {}): Promise<string | null> {
|
||||
usePlugins.setState({ installing: true });
|
||||
let dir: string;
|
||||
try {
|
||||
@@ -225,11 +238,27 @@ export async function installFromMarket(id: string): Promise<string | null> {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const installed = await installFromDisk(dir);
|
||||
const installed = await installFromDisk(dir, false, options);
|
||||
void refreshMarket(false);
|
||||
return installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs every update the marketplace offers, one after another. Updates
|
||||
* that need nothing confirmed install right away; the others show their
|
||||
* install page. Returns how many were installed.
|
||||
*/
|
||||
export async function updateAllPlugins(): Promise<number> {
|
||||
await refreshMarket(true);
|
||||
const updates = (usePlugins.getState().market ?? []).filter(isUpdate);
|
||||
let updated = 0;
|
||||
for (const entry of updates) if (await installFromMarket(entry.id, { quiet: true })) updated++;
|
||||
if (updated > 0) {
|
||||
notifications().notify({ group: "plugins", level: "info", title: `Updated ${updated} plugins` });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Settings › Plugins › Roll Back: the version before the last update. */
|
||||
export async function rollBackPlugin(id: string): Promise<void> {
|
||||
try {
|
||||
@@ -274,12 +303,22 @@ async function packageFolder(backend: PluginsBackend, picked: string): Promise<s
|
||||
return manifest ? picked.slice(0, manifest.index) : picked;
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
/** No notification when it installs (a batch reports once). */
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a plugin (a folder, its `plugin.toml`, or an `.oiplugin` / `.zip`
|
||||
* package file), shows what it may do, and installs it with the user's
|
||||
* consent. Returns the new plugin id.
|
||||
* consent. An update with nothing to confirm installs with the grants it
|
||||
* has. Returns the new plugin id.
|
||||
*/
|
||||
export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> {
|
||||
export async function installFromDisk(
|
||||
path?: string,
|
||||
packageFile = false,
|
||||
options: InstallOptions = {},
|
||||
): Promise<string | null> {
|
||||
const backend = pluginsBackend();
|
||||
let dir: string | null = path ?? null;
|
||||
try {
|
||||
@@ -300,24 +339,29 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
|
||||
let installed: string | null = null;
|
||||
try {
|
||||
const preview = await backend.previewInstall(dir);
|
||||
const consent = await askConsent({
|
||||
pluginId: preview.id,
|
||||
name: preview.name,
|
||||
version: preview.version,
|
||||
kind: "install",
|
||||
capabilities: preview.capabilities,
|
||||
unsigned: preview.unsigned,
|
||||
warnings: preview.warnings,
|
||||
});
|
||||
const consent: Consent | null = preview.needsConsent
|
||||
? await askConsent({
|
||||
pluginId: preview.id,
|
||||
name: preview.name,
|
||||
version: preview.version,
|
||||
kind: "install",
|
||||
capabilities: preview.capabilities,
|
||||
unsigned: preview.unsigned,
|
||||
signerChanged: preview.signerChanged,
|
||||
warnings: preview.warnings,
|
||||
})
|
||||
: { optional: preview.capabilities.filter((c) => c.optional && c.granted).map((c) => c.id) };
|
||||
if (!consent) return null;
|
||||
const info = await backend.installDir(dir, consent);
|
||||
installed = info.status === "failed" ? null : info.id;
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: info.status === "failed" ? "warning" : "info",
|
||||
title: `Installed ${info.name} ${info.version}`,
|
||||
body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
|
||||
});
|
||||
if (info.status === "failed" || !options.quiet) {
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: info.status === "failed" ? "warning" : "info",
|
||||
title: `Installed ${info.name} ${info.version}`,
|
||||
body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
|
||||
});
|
||||
}
|
||||
return info.id;
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "plugins", level: "error", title: "Could not install plugin", body: errorText(e) });
|
||||
|
||||
@@ -6061,5 +6061,21 @@
|
||||
"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 后再安装。",
|
||||
"Icon Themes": "图标主题",
|
||||
"Linters & Formatters": "检查与格式化",
|
||||
"AI": "人工智能",
|
||||
"Frameworks": "框架",
|
||||
"All Categories": "全部分类",
|
||||
"Category": "分类",
|
||||
"Update All Plugins": "更新全部插件",
|
||||
"Update All Plugins ({updates})": "更新全部插件({updates})",
|
||||
"Update All": "全部更新",
|
||||
"Show Plugins": "查看插件",
|
||||
"Plugin update available": "有插件可更新",
|
||||
"{length} plugin updates available": "{length} 个插件可更新",
|
||||
"Updated {updated} plugins": "已更新 {updated} 个插件",
|
||||
"This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
|
||||
}
|
||||
+13
-27
@@ -2,36 +2,15 @@ package openidea:[email protected];
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
use types.{error};
|
||||
|
||||
enum event-kind {
|
||||
file-opened,
|
||||
file-saved,
|
||||
/// The focused editor changed.
|
||||
editor-focused,
|
||||
/// An IDE action ran. Requires `events.input`.
|
||||
action-performed,
|
||||
/// A repository's state changed (commit, checkout, fetch, …).
|
||||
vcs-changed,
|
||||
/// The plugin's `on-schedule` period elapsed.
|
||||
scheduled,
|
||||
}
|
||||
|
||||
/// How the user started an action.
|
||||
enum action-source {
|
||||
keyboard,
|
||||
menu,
|
||||
toolbar,
|
||||
context-menu,
|
||||
search,
|
||||
other,
|
||||
}
|
||||
use types.{error, extension};
|
||||
|
||||
record action-performed {
|
||||
/// Action id (`editor.duplicate-line`).
|
||||
action: string,
|
||||
title: string,
|
||||
source: action-source,
|
||||
/// How the user started it: `keyboard`, `menu`, `toolbar`,
|
||||
/// `context-menu`, `search` or `other` (more may be added).
|
||||
source: string,
|
||||
/// Its keyboard shortcut, when it has one (`Ctrl+D`).
|
||||
shortcut: option<string>,
|
||||
}
|
||||
@@ -43,14 +22,21 @@ interface events {
|
||||
action-performed(action-performed),
|
||||
vcs-changed(string),
|
||||
scheduled,
|
||||
/// A kind added after 1.0 (subscribed to by its name).
|
||||
other(extension),
|
||||
}
|
||||
|
||||
/// Delivers `kind` events from now on, for the rest of the session (also
|
||||
/// across idle reclaim: an event activates the plugin again). Declare
|
||||
/// `activation = ["on-event:<kind>"]` to get them before first use.
|
||||
/// Kinds: `file-opened`, `file-saved`, `editor-focused` (the focused editor
|
||||
/// changed), `vcs-changed` (a repository's state changed: commit,
|
||||
/// checkout, fetch…), `action-performed` (an IDE action ran) and
|
||||
/// `scheduled` (the `on-schedule` period elapsed); later IDEs add more,
|
||||
/// and an IDE that doesn't know one fails with `invalid`.
|
||||
/// Requires `events` (`events.input` for `action-performed`).
|
||||
subscribe: func(kind: event-kind) -> result<_, error>;
|
||||
unsubscribe: func(kind: event-kind);
|
||||
subscribe: func(kind: string) -> result<_, error>;
|
||||
unsubscribe: func(kind: string);
|
||||
}
|
||||
|
||||
/// Long-running work with a progress indicator. While a task is open, the
|
||||
|
||||
+12
-1
@@ -2,7 +2,7 @@ package openidea:[email protected];
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
use types.{error, range};
|
||||
use types.{error, range, location};
|
||||
|
||||
enum severity {
|
||||
error,
|
||||
@@ -11,12 +11,23 @@ interface diagnostics {
|
||||
hint,
|
||||
}
|
||||
|
||||
/// Another place that explains a diagnostic (the earlier declaration, say).
|
||||
record related-information {
|
||||
location: location,
|
||||
message: string,
|
||||
}
|
||||
|
||||
record diagnostic {
|
||||
range: range,
|
||||
severity: severity,
|
||||
message: string,
|
||||
/// Rule or check id (`LineLength`).
|
||||
code: option<string>,
|
||||
/// The tool that found it, when not the plugin itself (`checkstyle`).
|
||||
source: option<string>,
|
||||
/// `unnecessary` (shown faded) and `deprecated` (struck through); unknown ones are ignored.
|
||||
tags: list<string>,
|
||||
related: list<related-information>,
|
||||
}
|
||||
|
||||
/// Replaces the plugin's diagnostics for the file at `path` (absolute, in
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user