Plugin API 1.2 adds `ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm` and `views.reveal`. The dialogs show in the window whose command or event asked and block that call until the user answers; the wait is added to the call's deadline, so it doesn't use up the time budget. They need no capability and return `invalid` without a window (events without a client, rendering, editor providers). Prompts are withdrawn when the call is cancelled or the plugin stops, and reach remote windows through the dispatcher (`plugin_answer_prompt`). `views.reveal` brings one of the plugin's declared views to the front. The Rust SDK and its mock gain `pick`, `pick_many`, `input`, `confirm` and `reveal_view`. All components are rebuilt for the WIT change. Closes #1768 Co-authored-by: arnaud-ritti <[email protected]>
Writing OpenIDEA plugins
OpenIDEA plugins are sandboxed WebAssembly components (wasmtime, component model, WASI preview 2). A plugin can register commands (they become IDE actions you can search for and bind to keys), read project files, edit the focused editor, react to workbench events, run background tasks, show notifications and status bar items, keep data in its own storage, read and fetch git repositories, provide completion, quick fixes, navigation and gutter markers, call web services and run approved programs, show tool windows and custom editors, and contribute menus, shortcuts, settings, themes, file icon themes and languages. Plugins that only contribute data (themes, icons, languages, menus, settings) need no code at all.
Preview. The plugin system ships as a preview and the official plugins are versioned
0.x. Released API versions only grow: a plugin built foropenidea:[email protected]keeps loading in later 1.x IDEs. See Evolving the plugin API below.
plugins/
└─ official/
├─ harbor-night/ # data only: a colour theme (UI and editor scheme)
├─ material-icons/ # data only: a file icon theme
├─ solidity/ # language + syntax provider + run configurations
├─ string-tools/ # 26 text commands, versioned multi-cursor edits
├─ key-promoter/ # action events, storage
├─ git-toolbox/ # on-schedule fetch, tasks, VCS, status bar item
├─ checkstyle/ # one-shot process, diagnostics, quick fixes
├─ ai-completion/ # inline completion over the network, secrets
├─ mybatis/ # navigation, gutter markers, completion across files
├─ csv-editor/ # custom editor sharing the text document
├─ excalidraw/ # web view custom editor, file icons, a template
├─ graphviz/ # web view with code: live preview, problems from the page, a language
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
└─ license-header/ # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave
The plugin API is defined in wit/ (openidea:[email protected], world
plugin).
1. Quick start
The openidea-plugin tool scaffolds, builds, checks and links plugins:
cargo install --locked --git https://github.com/l0ng-ai/openidea ide-host --bin openidea-plugin
# in a checkout of this repository: cargo install --path crates/ide-host --bin openidea-plugin
openidea-plugin new my-plugin # Rust command plugin (SDK)
openidea-plugin new my-theme --template theme # also: icon-theme, language
cd my-plugin
cargo test # logic, against the SDK's mock host
openidea-plugin build # → dist/plugin.wasm, then `check`
openidea-plugin link # load this folder in the IDE
Each release also attaches the tool for Linux x64
(openidea-plugin-linux-x86_64.gz). A plugin is a project of its own: keep it
in its own repository, and list it in the community marketplace when it is
ready (§8.1).
A Rust plugin made by new depends on the SDK of the tool's release, from
this repository's tag (openidea-plugin = { git = "…", tag = "vX.Y.Z" });
change the tag to build against a later API. Inside a checkout of this
repository, or with --sdk <path to sdk/rust/openidea-plugin>, it depends on
that SDK by path instead.
build runs cargo build --release --target wasm32-wasip2 (installing the
target through rustup if needed), copies the component to the manifest's
wasm path and runs check. check validates the package exactly as
Install Plugin from Disk… would — manifest, every contribution, the SVG
sanitiser, the component — and that the component imports nothing but the
plugin API and WASI and links against the IDE's: every function it imports
exists with the same signature, and it exports the plugin world. It prints what the user will be asked to grant.
link adds the folder to links.json in the IDE's plugins directory
($OPENIDEA_PLUGINS_DIR, default <app-data>/ai.l0ng.openidea/plugins). Linked
plugins are read from the folder itself, so the edit loop is build then
Settings › Plugins › Reload. unlink and links undo and list.
2. The package
A plugin is a directory with a plugin.toml:
id = "acme.word-tools" # [a-z0-9][a-z0-9_-]*, optionally with dotted namespaces
name = "Word Tools"
version = "1.2.0" # semver
description = "Counts and transforms words."
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
icon = "icon.svg" # optional, see below
icon-dark = "icon_dark.svg" # optional
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
[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:api.example.com"]
[[contributes.commands]]
id = "count" # [a-z0-9][a-z0-9._-]*
title = "Word Tools: Count Words"
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).
icon is an SVG of the package shown in Settings › Plugins and the
marketplace; icon-dark, if given, replaces it with dark themes. Each is at
most 16 KiB and sanitised like file icons (no scripts, external references or
foreignObject); an invalid one fails the package. Without icon, the
plugin gets the generic plugin icon. icon is new in API 1.2: IDEs before it
refuse the manifest, so a package with one declares engines.openidea = "^1.2"
(check and installing refuse it otherwise).
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 skipped with a warning.
Manifests in the 0.1 format (openidea-api = "0.1", permissions = [...])
still load (their permissions become required capabilities; contributions
work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
Contribution points
| Point | Declaration | Code needed |
|---|---|---|
commands |
id, title, category?, when? |
to run them |
themes |
path (theme JSON), editor-scheme? |
no |
iconThemes |
id, label, path (icon theme JSON, see §5) |
no |
languages |
path (languages/<id>.toml format, see §6) |
no |
runDetectors |
id, name, files, command, args?, test? (§6.2) |
no |
providers |
kinds, languages (editor providers, see Providers) |
yes |
secrets |
name, label, description? (see Network) |
to use them |
menus |
menu (editor.context, projectView.context, editorTab.context, viewItem.context, tools), view?, command, when? |
no |
keymaps |
command, key, mac? (Ctrl+Alt+C; a chord: Meta+K Meta+C) |
no |
statusBar |
id, text, tooltip?, command?, align?, priority? |
no |
settings |
schema (JSON file), title? — one table, see Settings |
no |
views |
id, title, icon? (an icon name, or an SVG of the package), location (left, right, bottom), web? (see Web views) |
without web |
customEditors |
id, title, patterns (file names), priority (default, option), layout?, web? |
without web |
fileIcons |
id, patterns (file names), icon (SVG), iconDark? — whatever icon theme is in use |
no |
fileTemplates |
id, name, extension, path (the new file's content; template variables work) — New menu |
no |
A viewItem.context menu needs view, the id of one of the plugin's own
views. It adds the commands to the right-click menu of the items in that
view's tree or list, and each command gets the item's key as its argument.
when clauses can use viewItem (the key) and viewItemKind (the key up to
its first :, so pod for pod:default/nginx; the whole key when it has no
:). Only the menu's when sees them, not the command's own when; a command
whose when is false shows greyed out. Other menus refuse view, a web view
has no item menu, and no menu opens when no declaration matches the item.
The menu is new in API 1.2, so a plugin using it declares
engines.openidea = "^1.2". For example:
[[contributes.menus]]
menu = "viewItem.context"
view = "pods"
command = "kube.logs"
when = "viewItemKind == pod"
Declared contributions are live as soon as the plugin is enabled — commands show in Find Action, menus and the status bar, shortcuts work, themes and icon themes can be applied, settings pages are there — before any plugin code is loaded. Menus, shortcuts and status bar items refer to the plugin's declared commands. A plugin shortcut never takes a keystroke the IDE (or the user) already uses; the user can rebind it in the keymap editor.
A when clause (on commands and menus) can test whether the open project
contains a file or folder: when = "workspaceContains:.devcontainer/devcontainer.json".
The path is exact (no globs) and relative to the project root; in a workspace the
key is true when any of its projects contains the path. It updates when
files change. An IDE that doesn't know the key treats it as
false: the plugin still installs and the key reads as false there.
Plugins can set their own keys, plugin.<id>.<key>, with host::set_context:
a key set from a command or event applies to that window, one set outside any
applies to every window, and a window's value wins over the global one. Keys are
kept across a window reload and idle reclaim, and dropped when the plugin stops.
Background associations
[[contributes.documentIndexers]] declares file extensions and requires the optional
document-indexer export. It publishes cross-file associations (definitions, references,
aliases and gutter markers) in background batches, not on the editing path. See
Background document indexers.
Activation
Code is instantiated lazily: when one of the plugin's commands runs, or when
an event in activation fires:
| Event | Fires when |
|---|---|
on-command:<id> |
the command runs (implied for every command) |
on-language:<id> |
a file of that language is opened (*: any) |
on-file:<glob> |
a project file matching the glob is opened (**) |
on-view:<id> |
one of the plugin's views is shown |
on-schedule:<secs> |
every secs seconds (at least 60): Event::Scheduled |
on-event:<kind> |
a workbench event of that kind (see Events) |
on-startup-finished |
shortly after the workbench is ready (use sparingly) |
Until then the plugin is listed as Inactive and costs nothing at start-up.
An instance unused for 5 minutes is reclaimed (its deactivate runs) and the
plugin is Inactive again until its next event; on-schedule activates it
when due but does not keep it alive. Keep nothing in memory you can't rebuild
in activate.
Capabilities
Code can only do what its capabilities allow, and the user approves them when installing (or when a new version asks for more). Low-risk capabilities are listed; high-risk ones are called out as equivalent to running code on the machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
|---|---|---|
project.read |
low | Project::root, read_file, find_files, host::projects, publish_diagnostics |
editor.read |
low | TextEditor::document, selections |
editor.write |
medium | TextEditor::apply_edits |
vcs.read |
low | Project::vcs_status (branch, upstream, ahead/behind, changes), vcs_remotes |
vcs.write |
medium | Project::vcs_fetch (trusted projects only) |
storage |
low | host::storage (1 MiB per plugin) |
events |
low | file opened/saved, editor focused, repository changed events |
events.input |
medium | action-performed events (which actions you run, and how) |
process:<program> |
high | Project::run_process, language servers, debug adapters, runs |
net:<host> |
medium | host::fetch to that host (*.example.com: its subdomains) |
secrets |
medium | {{secret:<name>}} in request headers, host::secret_is_set |
clipboard |
medium | host::copy_to_clipboard and the clipboard API in the plugin's web views |
process: capabilities are also derived from what the contributions start: a
language's language-servers and debugger commands and a run detector's
command. Those are optional — refusing one keeps the rest of the plugin
working. On grant the program is resolved to an absolute path and its content
hash is recorded; it is checked again right before every start, and a replaced
file is refused until the user approves it again. In a remote project the
server resolves and pins the program on its own machine (see Remote
projects). fs.read:<path> can be declared and granted; it unlocks nothing
yet.
Logging, notifications, status bar items, tasks and run-time contributions
are always available. Calling a gated function without its capability returns
Error::Denied("<capability>") — the plugin keeps running and can degrade
(Git Toolbox still shows the branch without vcs.write).
3. Writing code (Rust SDK)
openidea-plugin new writes this for you. Cargo.toml:
[package]
name = "openidea-plugin-word-tools"
version = "0.1.0"
edition = "2024"
[workspace] # keep it out of any parent workspace
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { git = "https://github.com/l0ng-ai/openidea", tag = "vX.Y.Z" } # or { path = "…/sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
src/lib.rs:
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
#[derive(Default)]
struct WordTools;
impl Plugin for WordTools {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or("no file is open")?;
match id {
"count" => {
let words = editor.document()?.text.split_whitespace().count();
host::notify(NotificationLevel::Info, "Word count", &format!("{words} words"));
Ok(None)
}
// Every selection (a cursor: empty) in one undoable change.
"shout" => {
editor.transform_selections(|s| s.to_uppercase())?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
}
register_plugin!(WordTools);
On wasm32-wasip2 the SDK calls the IDE; on your machine it calls the SDK's
mock host, so the logic is unit-tested with cargo test:
#[test]
fn counts_and_shouts() {
let host = openidea_plugin::mock::MockHost::install();
host.open_editor("/project/a.md", "markdown", "one two three", &[(0, 4, 0, 7)]);
WordTools.run_command("count", &host.invocation(), None).unwrap();
assert_eq!(host.notifications()[0].body, "3 words");
WordTools.run_command("shout", &host.invocation(), None).unwrap();
assert_eq!(host.editor_text(), "one TWO three");
}
The mock enforces capabilities like the IDE (MockHost::install().with_capabilities(&[...])),
keeps a versioned editor document (type_in_editor makes edits stale), and
records notifications, status items, storage, subscriptions, tasks, fetches,
commands, languages, themes and log lines.
Any language with component-model tooling works (componentize-py,
jco componentize, TinyGo): the only contract is the plugin world in
wit/ (plugin-syntax adds the syntax provider). Build it yourself,
then run openidea-plugin check.
The API
| Rust SDK | WIT | Notes |
|---|---|---|
Plugin::run_command(id, ctx, arg) |
export commands.run |
ctx is the window: ctx.project(), ctx.editor(). An error shows " |