# 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 for > `openidea:plugin@1.0` 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, semantic-tokens, hover, commit, inlay-hints ``` The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.3.0`, world `plugin`). ## 1. Quick start The `openidea-plugin` tool scaffolds, builds, checks and links plugins: ```sh 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 `, 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 `/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`: ```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 "] 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/.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`, `commit.message`), `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 a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_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 | | `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes | 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: ```toml [[contributes.menus]] menu = "viewItem.context" view = "pods" command = "kube.logs" when = "viewItemKind == pod" ``` A `commit.message` menu puts the command, with the plugin's icon, in the toolbar above the commit message of the Commit tool window and the Commit dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`). In a context menu (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`), entries can name a `submenu`: the plugin's entries of that menu with the same `submenu` are shown under it (`Arthas` ▸ _Watch_ / _Trace_), in declaration order; a submenu with one visible entry shows it as a plain entry, and entries without `submenu` stay where they are. The label goes through the plugin's translations. It is new in API 1.3 (`engines.openidea = "^1.3"`): ```toml [[contributes.menus]] menu = "editor.context" command = "arthas.watch" submenu = "Arthas" ``` 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..`, 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](../docs/plugins/indexing.md). ### 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:` | the command runs (implied for every command) | | `on-language:` | a file of that language is opened (`*`: any) | | `on-file:` | a project file matching the glob is opened (`**`) | | `on-view:` | one of the plugin's views is shown | | `on-schedule:` | every `secs` seconds (at least 60): `Event::Scheduled` | | `on-event:` | 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` | | `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects | | `fs.read:` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory | | `fs.write:` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading | | `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`, `host::pending_commit`, before-commit checks | | `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` | | `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) | | `events` | low | file opened/saved, editor focused, repository changed events | | `events.input` | medium | `action-performed` events (which actions you run, and how) | | `process:` | high | `Project::run_process`, language servers, debug adapters, runs | | `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | | `secrets` | medium | `{{secret:}}` 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:` grants reading outside the open projects: a directory (its files, at any depth) or one file. The path is resolved to its real location before it is compared, so a symlink pointing out of the granted directory is refused, and a sibling whose name merely starts with the granted one (`/opt/see` vs `/opt/seeyon`) is not covered. Granting a whole file system (`/`, `C:\`) is refused when the manifest is read: declare the directories you need. Reads are capped — text over the limit fails rather than truncating, and files that are not text have to be read as bytes. `fs.write:` grants replacing files outside the projects under that path, creating them when they are not there. A write is separate from a read and does not imply one. Writes are atomic (a temporary file is renamed over the old one), keep the file's encoding and BOM (a new file is UTF-8), are capped at 16 MiB, and are refused while the editor holds unsaved changes for the same file. The path is resolved as a read's is, so a link inside a granted directory cannot be used to write outside it. `project.write` does the same inside the open projects, creating missing directories. Both are new in API 1.3 (`engines.openidea = "^1.3"`). Logging, notifications, status bar items, tasks and run-time contributions are always available. Calling a gated function without its capability returns `Error::Denied("")` — 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`: ```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`: ```rust 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, 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`: ```rust #[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/`](../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 _" failed: …"_; the plugin keeps running. `"msg".into()` is `Error::Failed`; `Cancelled` and `Denied` don't count as the plugin failing. | | `Plugin::handle_events(events, ctx)` | export `event-handler.handle` | Batches of subscribed events, oldest first, per window. | | `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. | | `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). | | `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. | | `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. | | `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). | | `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). | | `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). | | `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. | | `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. | | `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. | | `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. | | `host::set_status_item(id, item)`, `remove_status_item` | `ui` | `text`, `tooltip`, `command` (one of yours, run on click); at most 8; kept while the plugin is idle. | | `host::set_context(key, value)`, `remove_context` | `ui.{set,remove}-context` | Sets `plugin.<id>.<key>` for `when` clauses in the window the call came from (`true`, `3.5`, `"text"`; `ContextValue`). Key 1–64 of `A-Za-z0-9_-`, 100 per plugin; gone when the plugin stops. | | `host::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). | | `host::copy_to_clipboard(text)` | `ui.copy-to-clipboard` | Copies `text` to the system clipboard. Needs `clipboard`. | | `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. | | `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. | | `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. | | `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Same rules as `input`. | | `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. | | `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. | | `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. | | `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. | | `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. | | `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. | | `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. | | `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. | | `host::reveal_view(view)` | `views.reveal` | Brings one of your declared `[[contributes.views]]` to the front in the calling window. | | `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. | | `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. | | `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | | `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | `run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")` from another feature, or nothing when run from Search Everywhere / a shortcut. `ctx.selected_paths()` lists the files and folders a Project view or editor tab menu ran the command on (the argument is the first of them); it is empty otherwise. No capability is needed. ### Events | Kind | Capability | Delivered | | ------------------ | -------------- | ------------------------------------------------------------------------- | | `file-opened` | `events` | a file opens in an editor | | `file-saved` | `events` | after the file is written | | `editor-focused` | `events` | the focused editor changes (path, or none) | | `vcs-changed` | `events` | a repository's state changed (commit, checkout, fetch…; its root) | | `action-performed` | `events.input` | an IDE action ran: id, title, source (keyboard, menu, toolbar…), shortcut | | `scheduled` | — | the `on-schedule` period elapsed | 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. API 1.3 adds `host::subscribe_paths("file-opened", &["config/*.yaml"])` (`events.subscribe-paths`). The host matches project-relative globs before queueing an event or waking an idle plugin. `file-saved` works the same way. Patterns use `/`, `*` stays within a directory, and `**` crosses directories. Up to 64 patterns of 1024 bytes each are accepted; absolute paths, parent traversal and malformed patterns fail without replacing the old filter. Register filters in `activate`, using a startup or command activation to install them before observing events. Patterns are ORed; another call replaces the same kind's filter, an empty list pauses it, `subscribe(kind)` restores all paths, and `unsubscribe(kind)` removes it. Filters survive idle reclaim and disappear on disable, uninstall or reload. Each window's events use its current project; files outside that project do not match. Provider calls can use `host::project_of(&doc.path)` (`workspace.project-of`) without an invocation project or enumerating every open project. It returns the deepest open project containing the absolute path, or `None` outside open roots. Symlinks and missing file tails are resolved before checking containment. Obtaining a handle grants no read access: `Project::root` and file operations still require `project.read`. ### Commits A plugin can work on the commit the user is preparing, in the Commit tool window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`): - **A button above the message.** `[[contributes.menus]]` with `menu = "commit.message"` shows one of the plugin's commands there, with the plugin's icon. - **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what Commit would commit right now: per file its repository, path, status and a `git diff` of only the included changes (the checked files and changed blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message, Amend and a `revision`. Unsaved editors of those files are saved first, as Commit does; nothing is staged or committed. `None` without a repository. - **The message.** `host::set_commit_message(revision, text)` (`vcs.write`) replaces it only while the draft is still at `revision`. If the user typed or changed the included changes meanwhile, it fails with `Error::Stale` and nothing is overwritten. The user reviews the text and commits. Both work from a command or event of a window, in a trusted project; they wait for the window, which isn't charged to the time limit. A message generator calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_) inside a task: ```rust let draft = host::pending_commit()?.ok_or("no repository")?; let message = ask_the_model(&draft.changes)?; host::set_commit_message(draft.revision, &message)?; ``` **Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`) is listed in Commit Options, on by default. When the user commits, after the IDE's own checks, the IDE calls the optional `commit-checker` export (`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`) with the check's id and the draft. Problems (`message`, `location?`) stop the commit and show beside the message with the check's title, until the user changes it or chooses Commit Anyway. A check that fails, times out or lacks `vcs.read` is reported as a problem too. ### Providers Declare what the plugin provides in the editor; only plugins whose declaration matches the file's language are called (and loaded): ```toml [[contributes.providers]] kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover", "inlay-hints" languages = ["java", "xml"] # editor language ids; "*" for any ``` Then `register_plugin!(MyPlugin, providers)` and implement `openidea_plugin::providers::Providers` for it: `completion`, `inline_completion`, `code_actions`, `definition`, `line_markers`. Each gets 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; 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. A quick fix that creates, renames or deletes files implements `file_actions::FileActions` too (`register_plugin!(T, providers, file_actions)`; the `code-actions` kind covers both): a `FileAction` carries `operations` (`FileOperation::Create { path, text, overwrite }`, `Rename`, `Delete`) and `edits`, applied as one workspace edit, so a failure changes nothing and one undo reverses it. A path outside the project root disables the action; `open` shows a file once the action is applied. Like a code action, it can leave `operations` and `edits` empty and set `data`: `resolve_file_action` computes them when the user picks it. File actions are new in API 1.3. 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. Providers run in the plugin's one instance, with its commands, events and views: they see the same memory (Checkstyle answers quick fixes from the results of its last check) but also wait behind them. Keep commands and event handlers short where you can; a provider call queued behind a long one is dropped at 50 ms like a slow one. Inline completion is exempt from the 50 ms (it may need the network) but is cancelled as soon as the user types on: check `Task::is_cancelled` or just let the host interrupt the call. #### Semantic highlighting The `semantic-tokens` kind (API 1.3: `engines.openidea = "^1.3"`) colours tokens in documents of the declared languages, including languages the plugin doesn't contribute, such as `php`. Register with `register_plugin!(MyPlugin, semantic_tokens)` (any combination works: `(MyPlugin, providers, views, semantic_tokens)`) and implement `openidea_plugin::semantic_tokens::SemanticTokens`: ```rust impl SemanticTokens for MyPlugin { fn semantic_tokens(&mut self, doc: &Document) -> Vec<SemanticToken> { let (start, end) = (text::position(&doc.text, 10), text::position(&doc.text, 14)); vec![SemanticToken::new(Range { start, end }, TokenKind::Method)] } } ``` A token covers part of one line and carries an LSP token kind (`TokenKind`: `method`, `property`, `type`, …) and modifiers (`TokenModifier`: `static`, `deprecated`, …); the WIT takes them as kebab-case strings. The tokens join the language server's semantic tokens and win where they overlap one, so the editor's colour scheme styles them like the server's. They are asked again after each edit, under the edit-path budget; at most 65,536 per document. #### Hover The `hover` kind (API 1.3) adds Markdown to the hover of documents of the declared languages, here too including languages the plugin doesn't contribute. Register with `register_plugin!(MyPlugin, hover)` and implement `openidea_plugin::hover::HoverProvider`: ```rust impl HoverProvider for MyPlugin { fn hover(&mut self, doc: &Document, at: Position) -> Option<Hover> { Some(Hover::new("**orders.index** · `GET /orders`")) } } ``` The host asks the language server and the plugins together, under the edit-path budget, and shows the server's hover first, then each plugin's below a rule. `range`, when given, is the text the hover describes (used when the server names none). At most 64 KiB of Markdown per plugin. #### Inlay hints The `inlay-hints` kind (API 1.3) shows short labels inside the code of documents of the declared languages, such as a type after a call. Register with `register_plugin!(MyPlugin, inlay_hints)` and implement `openidea_plugin::inlay_hints::InlayHints`: ```rust impl InlayHints for MyPlugin { fn inlay_hints(&mut self, doc: &Document, range: Range) -> Vec<InlayHint> { vec![InlayHint::new(range.start, ": string").kind(InlayKind::Type).tooltip("returns string")] } } ``` `range` is the visible part of the document; hints outside it are dropped. A hint's kind (`type`, `parameter` or `other`) decides which Inlay Hints setting shows it. The host asks the language server and the plugins together, under the edit-path budget, and shows both; the editor asks again after edits and scrolling. Labels are cut at 256 bytes and tooltips at 1 KiB. ### Network and secrets `host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host the plugin was granted with `net:<host>`; redirects to another host are refused and responses are limited to 8 MiB. Requests are made from the machine the project is on. Secrets declared under `[[contributes.secrets]]` are entered by the user in Settings › Plugins and kept in the system keychain. The plugin never reads them: it writes `{{secret:<name>}}` in a request header and the host fills it in on the way out (`secrets` capability). `host::secret_is_set(name)` tells whether the user has entered one. ### Views and custom editors A view is data: a toolbar, a body — `Tree`, `List`, `Form`, `Table`, `Message` or `Html` — and a status line. Declare it, then `register_plugin!(MyPlugin, views)` (or `(MyPlugin, providers, views)`) and implement `openidea_plugin::views::Views`: ```rust impl Views for MyPlugin { fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> { Ok(View::new(Body::Tree(vec![TreeItem::new("a", "A").expandable(self.open)])) .button(Button::new("refresh", "Refresh").icon("refresh"))) } fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> { if let ViewEvent::Expand(_) = event { self.open = true; host::refresh_view(view); } Ok(()) } } ``` The IDE draws the view with its own components (theme, keyboard, speed search) and sends what the user does: buttons, select / activate / expand / 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. For a table the user can sort by clicking a header, build it with `Body::sortable_table(&table, sort)`: it travels as a `table` through `other` (so IDEs and plugins of any API version keep working), the IDE makes the column headers clickable and asks for a column and direction with `ViewEvent::sort()` — sort your rows and render again; the sort you report shows as an arrow in the header. API 1.3 adds read-only form values without changing the released field record: `FieldValue::label(text)` uses `other { kind: "label", data }`, where `data` is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind `readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag defaults to false). Labels render as plain selectable values; read-only text keeps the single-line or multiline text control and never sends field events. Table row clicks and Space send `other` kind `table-select`; double-click and Enter send `table-activate`, both with JSON `{ "row": 0 }`. `ViewEvent::table_select()` and `table_activate()` decode the absolute, zero-based index, including `first_row` for paged tables. These work for both plain tables and the same `other` table shape used by sortable tables above; no new table shape is needed. Editable cells still double-click to edit; activate their row with Enter on the focused row or double-click its row number. `viewItem.context` menus also work on table rows: the command argument and `viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables keep their insert/delete entries alongside contributed commands. After sorting or changing data, plugins resolve row indices against their current row order. Declare `engines.openidea = "^1.3"` when relying on these additions. 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 plugin's editor, or through _Open With_ for `option`. For a custom editor `ctx.editor()` is its document: read it with `document()` and change it with `apply_edits(version, …)` — the text editor of the same file follows, Undo / Redo are one history with it, and Save, the dirty mark and the close prompts are the document's. _Open as Text_ opens the text editor. For a tool window `ctx.editor()` is the window's active text editor, as for a command, so a view's buttons can act on what the user is editing (a click in the tool window doesn't change which editor that is). `layout` puts a custom editor beside the text editor instead of in a tab of its own: with `"split"` or `"preview"` the file opens in the text editor, with your editor next to it (the layout files start in) and the _Editor Only / Editor and … / … Only_ switch in the tab strip, as for Markdown. `"tab"` (the default) is the editor's own tab. `Html` is the escape hatch for content the declarative bodies can't show. It runs in a sandboxed frame — an opaque origin under a CSP that allows only its inline scripts and styles and `data:` images, no network, no IDE API — and talks to the plugin only through `openidea.postMessage(text)` / `openidea.onMessage(fn)` (`ViewEvent::Message`, `host::post_view_message`). The frame follows the IDE theme without reloading: its root carries `data-theme="light|dark"` and these CSS variables, with concrete colour values (the body already uses the text colour, UI font and size): | Variable | Meaning | | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `--openidea-bg`, `--openidea-bg-elevated`, `--openidea-bg-hover`, `--openidea-bg-selected`, `--openidea-bg-input` | Surfaces (the frame itself is transparent) | | `--openidea-fg`, `--openidea-fg-strong`, `--openidea-fg-muted` | Text | | `--openidea-border`, `--openidea-focus-ring`, `--openidea-link` | Lines, focus, links | | `--openidea-accent`, `--openidea-fg-on-accent` | Accent and text on it | | `--openidea-danger`, `--openidea-warning`, `--openidea-success`, `--openidea-info` | Status colours | | `--openidea-font-ui`, `--openidea-font-mono`, `--openidea-font-size` | Fonts (web fonts the IDE bundles are not available in the frame) | Scripts get the same as `openidea.theme` (`{ dark, vars }`) and `openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only ever added. #### Services views `location = "services"` (API 1.3: `engines.openidea = "^1.3"`) puts a view's tree in the Services tool window, next to Docker and the run configurations, instead of a tool window of its own. Its code renders it (no `web`): ```toml [[contributes.views]] id = "kube" title = "Kubernetes" icon = "icons/kubernetes.svg" location = "services" [capabilities] optional = ["process:kubectl"] ``` The items of the view's `tree` are Services nodes: `render`, `handle` and the toolbar work as in a tool window, the toolbar's buttons acting on the one selected node, and `viewItem.context` menus give the nodes a right-click menu. The pane beside the tree comes from the `detail-provider` export: register `register_plugin!(MyPlugin, views, details)` and implement `openidea_plugin::details::Details`, which returns the selected node's tabs (at most 8; asked again after `host::refresh_view("kube")`): ```rust impl Details for MyPlugin { fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> { let pod = key.trim_start_matches("pod:"); let log = ProcessLog::new("kubectl", &["logs", "-f", "--tail=1000", pod]) .toggle("ts", "Timestamps", &["--timestamps"]); Ok(vec![Tab::new("log", "Log", log), Tab::new("inspect", "Inspect", Pane::Text(self.describe(pod)?))]) } } ``` The IDE switches tabs without calling the plugin. A tab is a `Pane::Message`, read-only `Pane::Text` (at most 1 MiB) or a `ProcessLog`: the IDE asks for the pane again and runs that program (it needs the plugin's `process:<program>` grant) in the project root, following its output in the log Docker containers use (ANSI stripped, the last 5000 lines and 16 KiB per line, Soft-wrap, Clear, the exit code at the end). A toggle appends its `args` and starts the program again. It stops when the node's pane closes or the Services window is hidden. #### Web views A view or custom editor can instead be a web page of the package, for UIs the declarative bodies can't express (a drawing canvas, a diagram editor) — no code needed: ```toml [[contributes.customEditors]] id = "canvas" title = "Drawing" patterns = ["*.excalidraw"] layout = "preview" web = "web/index.html" # the page; its directory is what it can load ``` The page's directory (never the package root) is part of the declaration: every file under it is checked (at most 4096 files and 256 MiB, no links), installed and signed with the package. The IDE loads the page from its own `openidea-plugin` URL scheme into an `<iframe sandbox="allow-scripts">`: an opaque origin, no network, no cookies or storage, no IDE API, and only the page's own files (relative URLs, ES modules, fonts, WebAssembly) — so build it with relative paths (Vite: `base: "./"`). The same `openidea` object as in `html` bodies is there before the page's scripts run, plus: | `window.openidea` | What it does | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `document.get()` | `{ version, text, readOnly, name }` of the custom editor's document (`name`: the file's, without its directory; none in a tool window) | | `document.edit(version, edits)` | Applies `[{ from, to, insert }]` (UTF-16 offsets) as one change of the document's undo history; rejects with `stale` when not at `version`; resolves `{ version }` | | `document.replace(version, text)` | The same with the whole text (sent as the smallest edit) | | `document.onChange(fn)` | `{ version, text }` after changes made elsewhere: the text editor, Undo, another window | | `saveFile({ name, data, filters? })` | The IDE's save dialog; `data` is a string, bytes or a Blob; the chosen path, or `null`. The page never names a path itself | | `openFile({ filters? })` | The IDE's open dialog; `{ name, data }` (bytes), or `null` | | `postMessage` / `onMessage` | To and from the plugin's code, when it has some | Save, the dirty mark and Undo are the document's, as for every custom editor. IDE shortcuts the page doesn't handle itself (Save, Close, Find Action…) still work while it has focus; editing keys (⌘C, ⌘V, ⌘Z…) stay with the page. With the `clipboard` capability the page may use `navigator.clipboard`. `plugins/official/excalidraw` is a complete example: a Vite + React page in `web-src/`, built into `web/` by `plugins/build.sh`. `plugins/official/graphviz` pairs a page with code: the page renders (Graphviz compiled to WebAssembly) and sends what Graphviz reports to the plugin, which publishes it as problems. The built page is not checked in (it is tens of MB): run `plugins/build.sh official/excalidraw` before installing the plugin from the repository. ### Translations A package translates its own text with `locales/<tag>.json`, one file per language (`fr.json`, `zh-CN.json`), no manifest entry needed. A catalog maps the English source text to its translation, like the IDE's text catalogs: ```json { "Count Words": "Compter les mots", "Refresh": "Actualiser" } ``` The IDE applies the catalog of the selected language (the exact tag, then the language, then a variant such as `zh-TW` for `zh`) to the titles, categories, labels and tooltips of the manifest's commands, views, editors, status bar items, settings (and their properties), file templates, themes and secrets, and to what views show: toolbar buttons, tree and list items, form field labels, table column names, messages and the status line, and status bar items set by code. Text without an entry, and every text when the language has no catalog, shows as written. A catalog applies to its own plugin only and never changes the IDE's text. `openidea-plugin check` refuses a catalog that is not a JSON object of strings. Text the plugin builds itself (notifications, error messages, `html` bodies, cell data) is not translated: read `host::locale()` when making it. It follows a change of language, and `MockHost::set_locale` sets it in tests. ### Settings ```toml [contributes.settings] schema = "settings.schema.json" ``` ```json { "properties": { "context": { "type": "string", "title": "Context", "default": "" }, "tailLines": { "type": "integer", "title": "Log lines", "default": 200 }, "shell": { "type": "string", "enum": ["sh", "bash"], "default": "sh" } } } ``` Types are `boolean`, `string` (optionally `enum`, `multiline`), `number` and `integer`, each with a `default`. The IDE generates the page under Settings › Plugins (searchable; projects can override values) and the plugin reads values with `host::setting*`; a stored value of the wrong type falls back to the default. ### Remote projects Plugin code runs where the project is. When a window opens a remote project, the desktop sends its enabled code plugins and their grants to `openidea-server`, which runs them next to the files: `read_file`, `find_files`, `run_process`, `fetch` and diagnostics all act on the remote machine, and `process:` programs are resolved on its `PATH`. Changes on the desktop (install, disable, grants) follow within a second. Secrets stay in the desktop's keychain; the server asks for one when a request header needs it. Status items, notifications, tasks, views, custom editors and terminals of remote plugins show in that window; a custom editor edits the remote document. Project plugins (`.openidea/plugins`) don't run in remote projects yet, and a plugin's storage on the server starts empty. ### Sandbox and limits Each plugin gets its own wasmtime `Store`: no preopened directories, no environment, no network, no inherited stdio — only clocks and randomness from WASI, plus the host functions above. Every call into the plugin is bounded: - **CPU:** a fuel budget (≈ 5·10⁹ wasm instructions) per call; - **time:** 5 s wall clock for commands, activation and deactivation; editor providers (syntax, completion, code actions, navigation, line markers) get 50 ms — a later result is dropped, but the instance keeps running — and are interrupted at 500 ms; - **memory:** 256 MiB of linear memory. Each active plugin runs on its own thread; calls are queued, never run concurrently in one instance. A panic, trap, permission violation or exceeded limit drops the instance: the user is told once, and the plugin starts afresh the next time it is needed, after a pause that doubles with each crash in a row (1 s, 2 s, …). Three crashes in a row mark it _Failed_ for the session (with the reason in Settings › Plugins); toggling it off and on, or _Reload_, starts it again. A call that returns normally resets the count. Settings › Plugins shows activations, call latency (p95), crashes, late editor results and reclaims. The IDE itself is never affected. ## 4. Themes ```json { "id": "harbor-night", "name": "Harbor Night", "kind": "dark", "tokens": { "--bg-app": "#14171c", "--accent": "#2b9e93", "--syn-keyword": "#e0a46e" } } ``` Tokens are the CSS variables of `src/styles/tokens.css` (`--bg-*`, `--fg-*`, `--border*`, `--accent`, `--syn-*`, `--vcs-*`, `--gutter-*`, …). Unlisted tokens keep the values of the built-in base named by `kind`. Values must be plain CSS values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply_. Start with `openidea-plugin new <dir> --template theme` (or a copy of [`official/harbor-night`](official/harbor-night)); the full guide, token reference and IntelliJ / VS Code porting tables are in [`docs/plugins/themes.md`](../docs/plugins/themes.md). ## 5. File icon themes ```toml [[contributes.iconThemes]] id = "material" label = "Material Icons" path = "icons/theme.json" ``` `theme.json` maps names to SVGs (paths relative to the package): ```json { "iconDefinitions": { "rust": "icons/rust.svg", "folder-src": "icons/folder-src.svg" }, "file": "file", "folder": "folder", "folderExpanded": "folder-open", "fileExtensions": { "rs": "rust", "d.ts": "typescript-def" }, "fileNames": { "cargo.toml": "cargo" }, "folderNames": { "src": "folder-src" } } ``` Files match by exact name, then the longest extension (`a.test.ts` tries `test.ts`, then `ts`), then `file`. Folders match by name, then `folder`/`folderExpanded`; folders with a role (source root, excluded, module) keep their built-in icons. Names are case-insensitive. Up to 5000 icons of at most 64 KiB each. Every SVG is sanitised on install: allow-listed elements and attributes only, no scripts, event handlers, external references, `url()` or entities. Apply a theme in Settings › Plugins; see [`official/material-icons`](official/material-icons) for a complete one. Optional fields: ```json { "iconDefinitions": { "rust": { "light": "icons/rust.svg", "dark": "icons/rust_dark.svg" } }, "folderOpen": "folder-open", "patterns": [ { "pattern": "^.*\\.spec\\.ts$", "icon": "test-ts", "ignoreCase": true }, { "pattern": "/fixtures/", "icon": "fixture", "path": true, "group": "tests" }, { "pattern": "^src$", "icon": "folder-src", "target": "folder", "openIcon": "folder-src-open" } ], "overrides": { "chevron-right": "arrow-right", "symbol-class": "class" }, "groups": [{ "id": "tests", "name": "Tests", "enabled": false }], "choices": [ { "id": "arrows", "name": "Arrows", "default": "plain", "options": [ { "id": "plain", "name": "Default" }, { "id": "plus", "name": "Plus-Minus", "overrides": { "chevron-right": "plus" } } ] } ], "accent": true, "settings": [ { "title": "Icons", "items": [ { "option": "hideFiles", "invert": true, "label": "Show file icons", "hint": "In trees, lists and tabs" }, { "option": "groups", "columns": 2 }, { "option": "choice", "choice": "arrows" }, { "option": "iconSize", "label": "Icon size", "default": 16 } ] } ] } ``` - `iconDefinitions` values may be `{ "light", "dark" }`; the dark SVG is used in dark themes. Icon ids may not end in `_dark`. - `patterns` are regular expressions checked in order, before `fileNames` and `fileExtensions`. `target` is `file` (default) or `folder`; with `path` a pattern matches the whole path (`/` separators) instead of the name; `ignoreCase` ignores case. Patterns that could backtrack badly (a repeated group, too many ways to match one name), lookaround, inline flags and named groups are refused. - `openIcon` (folder patterns) and `folderOpen` (other folders) are used for folders holding a file open in the editor when _Hollow folders_ is on. - `overrides` replace built-in IDE icons by name (`chevron-right`, `folder-source`, …); symbol icons (`symbol-<kind>`, `class`, `method`, …) are PSI icons. - `groups` are packs of patterns the user turns on or off; `enabled` is the default. - `choices` are variants the user picks one of; the picked option's `overrides` win over `overrides`. - `accent: true`: elements with `data-tint="true"` take the colour the user picks. - Elements with `data-themed="true"` take the accent colour of Settings › Appearance, or the theme's custom accent colour. - `settings` describe the theme's page in Settings › Plugins › _theme label_; a theme without them has no page. Sections have a `title` and `items`; an item names an `option` the IDE implements, with the theme's `label`, optional `hint` and, where the option needs one, `default`: | `option` | Control | Item fields | | -------------------------------------------------------------------------- | -------------------- | ---------------------------------------------------- | | `hideFiles`, `hideFolders`, `hideUiIcons`, `hidePsiIcons`, `hollowFolders` | checkbox | `invert`: checked means off | | `groups` | a checkbox per group | `columns` (1–4), `label?` (group names are shown) | | `choice` | the choice's options | `choice` (its id), `label?` (default: its name) | | `accent`, `monochrome` | checkbox and colour | `default` (`#rrggbb`); `accent` needs `accent: true` | | `saturation` (0–20), `opacity` (10–100), `iconSize`, `rowHeight` (px) | checkbox and number | `default` within the range | `iconSize` is 12–32 and `rowHeight` 16–48. Values are kept per theme in `appearance.fileIconThemeOptions` and apply while the theme is selected. The first icon theme of a plugin is selected when the plugin is installed or enabled while no icon theme is. ## 6. Languages `languages/<id>.toml` files use the IDE's own language format; see any file in [`languages/`](../languages); [`official/solidity`](official/solidity) is a complete example. Plugin languages join the IDE's language registry like built-in ones: file detection (`extensions`, `filenames`, `first-line`), comments, indentation, `ui-mode`, `framework-ui-modes`, and the `language-servers` and `debugger` they declare (each needs its `process:` grant). A plugin cannot replace a built-in or user definition: their ids, extensions and file names win (users can still map files to a plugin language in Settings › File Types). In a remote project the definitions are sent to the server, which starts the servers there. `[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`) replace `ui-mode` for the files of a framework's projects: the first whose root marker is in a directory above the file, up to the project folder, applies (see [`languages/html.toml`](../languages/html.toml), Angular templates). Markers are matched like a language server's `root-markers`. ### 6.1 Syntax for languages without a grammar The IDE parses its built-in languages with tree-sitter. For a plugin language it has no grammar for, the plugin's component can export `syntax-provider` (world `plugin-syntax`): flat highlight tokens, structure-view symbols and folds for a whole document, in UTF-8 byte offsets. With the SDK: ```rust use openidea_plugin::syntax::{Fold, Highlight, Symbol, SyntaxProvider, Token}; impl SyntaxProvider for MyPlugin { fn highlights(&mut self, language: &str, text: &str) -> Vec<Token> { … } fn outline(&mut self, language: &str, text: &str) -> Vec<Symbol> { … } // optional fn folding(&mut self, language: &str, text: &str) -> Vec<Fold> { … } // optional } register_plugin!(MyPlugin, syntax); ``` It runs on the editing path (once per document version, cached), so keep it fast — a hand-written lexer is typical (`syntax::bracket_folds` helps with folding). Add `activation = ["on-language:<id>"]` so the component loads with the first file. Results are validated (clamped to the text, overlaps dropped). ### 6.2 Run configurations ```toml [[contributes.runDetectors]] id = "forge-test" name = "forge test" files = ["foundry.toml"] # offered in projects with any of these at the root command = "forge" # one program; needs process:forge args = ["test"] test = true # results go to the test tree ``` The configuration appears among the detected ones while `process:<command>` is granted, and starts the approved program. ## 7. Installing and developing - **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package folder) or _Install Plugin from Package File…_ (a `.oiplugin`, a `.zip` with the package at its root or in its only folder, or a folder's `plugin.toml`; a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install page lists every capability and whether the package is signed by a publisher the marketplace trusts; nothing is copied until you accept. - **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/` with an `active` pointer that is switched only once the new version is in place. An update whose code fails to start the first time is rolled back to 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 `<project>/.openidea/plugins/<name>/`. They load only in trusted projects and only after you approve their capabilities. - **Where:** installing, removing, enabling, granting and reloading plugins work on the welcome screen and in trusted projects. A project opened in restricted mode can browse the marketplace and show a package's install page, but not change which plugin code runs. - Commands appear in Search Everywhere › Actions under their category, and can be bound in Settings › Keymap like any other action. - **Troubleshooting:** `openidea-plugin check` says why a package would be refused. Settings › Plugins shows each plugin's state, why it failed if it did, and its call stats (§3, _Sandbox and limits_). `host::log` lines go to the IDE log (Help › _Show Log in Finder_ / _Explorer_ / _Files_) tagged with the plugin id; debug and trace lines are written when the IDE is started with `RUST_LOG=info,ide_plugin=trace`. `eprintln!` output is logged as well, and a panic's message is shown with the crash. ## 8. Distribution A **package** is a zip named `<id>-<version>.oiplugin`: the manifest, the files it references, the component, licence files and, when signed, `SIGNATURE.json` (Ed25519 over the plugin id, version and the digest of its files). ```sh 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 any web server or folder. It lists publishers and their public keys (the trust root: publisher `acme` owns plugin ids `acme.*`), the plugins with their digests, and revocations. Settings › Plugins › Available reads it from the URL you set; installing downloads the package, checks it against the index entry and the publisher's key, then shows the install page. - A package whose signature doesn't hold (changed after signing, a key that isn't the publisher's, another publisher's id prefix, a revoked key) is refused. Unsigned packages install from disk with a warning. - **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. - **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 The host's integration tests (`crates/ide-host/tests/plugins.rs`) load the checked-in components of the official plugins and of the test fixtures (`crates/ide-plugin/tests/fixtures/`). Rebuild them after changing a plugin, the SDK or the WIT files: ```sh plugins/build.sh # all of them plugins/build.sh official/solidity # one plugins/build.sh --check # what CI runs: nothing stale, everything builds cargo test -p ide-host --test plugins ``` Next to each `dist/plugin.wasm`, `dist/plugin.wasm.sources` fingerprints the sources it was built from (the plugin, the SDK and `wit/`); builds are not byte-reproducible across machines, so `--check` compares fingerprints. The SDK carries a copy of `wit/` in `sdk/rust/openidea-plugin/wit`; keep them identical (a test in `crates/ide-plugin/tests/api_compat.rs` checks). Changing the API itself follows _Evolving the plugin API_ below. ## 10. Evolving the plugin API Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36, 1.1.0 with 0.1.37 and 1.2.0 with 0.1.38. wasmtime decides what stays compatible: it resolves interface names by semver (a component importing `openidea:plugin/x@1.0.0` links against a host defining `@1.1.0`, and the host finds its exports the same way), but function types must match exactly — a record with another field count, or a variant or enum with another case count, doesn't link. So a released 1.x only grows: 1. **Released types and signatures never change**, whichever way the data flows: no new record fields, variant or enum cases, renames or parameters. 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, 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 host would not find them in older components. New capabilities get new interfaces (e.g. a `hover-provider`) in new optional worlds. 4. **The host knows each component's API version** (the highest version of its `openidea:plugin/*` imports and exports, `PluginEngine::check_component`). Installing and `openidea-plugin check` refuse another major or a newer minor with a message that says to update the IDE or rebuild with the SDK. They also link the component, so one that imports a function this host lacks under the same version (built from unreleased WIT) is refused too. 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: - SDK `tests/api_frozen.rs`: `tests/api/released/` holds the WIT of the latest released version; every interface, type, function and world import or export in it must still exist unchanged in `wit/` (rules 1–3). Under the released version `wit/` must match it exactly, so additions start with a version bump. Each snapshot passed this check against the one before, so one snapshot covers every release. - Releasing: `pnpm release:prepare <version>` makes the release commit's changes; when the `wit/` version changed, it copies `wit/*.wit` over `tests/api/released/`. The release workflow refuses a tag whose `wit/` version differs from the snapshot's. - `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy matches, the version check (rules 4, 5); a simulated next-minor host (every import stubbed at the newer version, plus extra functions and an extra interface) links every shipped component and finds its exports; another major doesn't link; a host missing one function doesn't link. A new host function touches: | Where | Checked by | | --------------------------------------------------------- | ---------------------------------------------- | | `wit/*.wit`, copied to `sdk/rust/openidea-plugin/wit` | `api_compat.rs::the_sdk_carries_the_hosts_wit` | | `crates/ide-plugin/src/wit_host.rs`: binding, capability | the compiler (bindgen's traits) | | `crates/ide-plugin/src/runtime.rs`: `HostApi` method | the compiler | | `crates/ide-host/src/plugins/api.rs`: implementation | the compiler (desktop and `openidea-server`) | | SDK `src/host.rs`: the wasm and the mock `imp` | the compiler (CI builds both targets) | | SDK public wrapper | SDK `tests/wit_coverage.rs` | | Capability: `ide-extension/src/capability.rs`, consent UI | review; a test that a missing grant is denied | | The API table above | review | Then run `plugins/build.sh`; CI's `plugins` job runs `plugins/build.sh --check` plus fmt, clippy (host and wasm) and the tests of the SDK, the official plugins and the fixtures. ### Directory selection and opening projects (API 1.3) `host::pick_directory(title, initial)` opens the window's native folder chooser and returns an absolute path, or `None` when dismissed. Like `pick`, it is only available during a command or event from a window; waiting for the user does not consume the plugin's call budget. `initial` is an optional absolute directory. `host::open_project(path, new_window)` requests the normal File › Open flow in that window (`false`) or a new window (`true`). Existing project windows are reused as usual, and the normal trust prompt and unsaved-change guard apply. The call acknowledges the request; cancellation or opening errors are handled by the window. Neither API needs a capability. Paths belong to the UI machine, including when plugin code runs on a remote host. ### Project HTTP endpoints (API 1.3) A plugin with `project.read` can export `endpoints-provider` to share its routes with Search Everywhere's Endpoints section, request URL completion in `.http` files, and the declaration's **Open in HTTP Client** gutter action. The action opens the generated-requests scratch; requests start at `http://localhost:8080` and path parameters become HTTP client variables. Adjust the host for the app. ```toml engines.openidea = "^1.3" [capabilities] required = ["project.read"] [[contributes.endpoints]] files = ["config/routes.rb", "routes/**/*.php"] ``` Implement `endpoints::EndpointsProvider` and use `register_plugin!(MyPlugin, endpoints)` in the Rust SDK. `endpoints(ctx)` receives an explicit project invocation. It runs the first time the IDE needs that project's routes, and again after a file matching `files` is created, changed or removed; in between the host answers from its cache. Return the complete list (an empty list clears it). `method = None` accepts any method; the optional handler is a display name. Each endpoint has a required declaration location inside that project, with zero-based lines and UTF-16 columns. The generated request uses GET for any-method routes. Calls run in the background and cannot open prompts. The host validates a whole batch before replacing the last valid result (up to 10,000 endpoints, 4 MiB of route metadata, 16 MiB of declaration documents). Disabling, uninstalling, reloading or crashing a plugin withdraws its routes; idle instance reclamation keeps them cached. Closing a project withdraws its cache. Windows only receive routes for their current project. This API does not add an Endpoints tool window. ### Notifications with commands `host::notify_with_actions(level, title, body, actions)` shows up to three buttons. Build each with `NotificationAction::new(label, command)` and an optional `.argument(text)`. Commands must be declared by that plugin; choosing one dismisses the balloon and runs the command in the receiving window with its normal editor and project context. No capability is needed. Invalid or undeclared actions reject the whole notification. Labels are limited to 256 bytes and arguments to 64 KiB. Plain `host::notify` behaves as before.