feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)

A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <[email protected]>
Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-04 02:54:31 +08:00
1 джерело cfdbd3872a
коміт 1c5a92f952
450 files changed
+47018 -3058

No files matched your search

+668 -116
Переглянути файл
@@ -1,87 +1,210 @@
# Writing OpenIDEA plugins
OpenIDEA plugins are sandboxed **WebAssembly components** (wasmtime, component
model, WASI preview 2), in the style of Zed extensions. A plugin can register
commands (they become IDE actions you can search for and bind to keys), read
project files, insert text into the editor, show notifications and contribute
themes and languages. Plugins that only contribute themes or languages need no
code at all.
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: until it is declared
> stable the API (`openidea:[email protected]`) may still change, and the official
> plugins are versioned `0.x`. See _Evolving the plugin API_ below.
```
plugins/
├─ build.sh # builds Rust plugins into components
├─ hello/ # Rust plugin: two commands
└─ language-toy/ # manifest-only: a language definition
└─ 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 API is defined in [`wit/plugin.wit`](../wit/plugin.wit) (`openidea:plugin/[email protected]`).
The plugin API is defined in [`wit/`](../wit/) (`openidea:plugin@1.0.0`, world
`plugin`).
## 1. The package
## 1. Quick start
The `openidea-plugin` tool scaffolds, builds, checks and links plugins:
```sh
cargo install --path crates/ide-host --bin openidea-plugin # or: cargo run -p 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
```
`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. 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`:
```toml
id = "hello" # [a-z0-9][a-z0-9_-]*, unique
name = "Hello"
version = "0.1.0" # semver
description = "Inserts today's date and counts words."
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
openidea-api = "0.1" # API version this plugin targets
wasm = "dist/plugin.wasm" # omit for contribution-only plugins
permissions = ["project-read", "editor-read", "editor-write"]
homepage = "https://example.com" # optional
license = "MIT" # optional
change-notes = "1.2.0 — …" # shown under What's New
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"]
# Optional static contributions (active even before / without code):
[[contributes.commands]]
id = "insert-date" # [a-z0-9][a-z0-9._-]*
title = "Hello: Insert Date"
category = "Hello" # group in Search Everywhere / Keymap
[[contributes.themes]]
path = "themes/my-theme.json"
[[contributes.languages]]
path = "languages/my-lang.toml"
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
```
All paths are relative to the package directory and may not leave it.
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.
### Permissions
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.
| Permission | Unlocks |
| -------------- | ------------------------------------------------- |
| `project-read` | `workspace-root`, `read-file` (project root only) |
| `editor-read` | `active-file` |
| `editor-write` | `insert-text`, `replace-selection` |
### Contribution points
`log`, `register-command`, `show-notification`, `register-language` and
`register-theme` are always available. Calling a gated function without its
permission **traps**: the plugin is stopped and the user is told which
permission is missing.
| 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`, `tools`), `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?`, `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 |
## 2. Writing code (Rust)
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.
`wasm32-wasip2` produces a component directly, so all you need is rustup:
### Activation
```sh
rustup target add wasm32-wasip2
```
Code is instantiated lazily: when one of the plugin's commands runs, or when
an event in `activation` fires:
`Cargo.toml`:
| 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.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 | the clipboard API in the plugin's web views (keyboard copy / paste work without it) |
`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`:
```toml
[package]
name = "my-plugin"
name = "openidea-plugin-word-tools"
version = "0.1.0"
edition = "2024"
[workspace] # keep it out of any parent workspace
[workspace] # keep it out of any parent workspace
[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]
[dependencies]
wit-bindgen = "0.63"
openidea-plugin = { path = "…/sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
@@ -93,62 +216,298 @@ panic = "abort"
`src/lib.rs`:
```rust
wit_bindgen::generate!({ world: "plugin", path: "path/to/openidea/wit" });
use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};
use openidea::plugin::host::{self, LogLevel, NotificationLevel};
#[derive(Default)]
struct WordTools;
struct MyPlugin;
impl Guest for MyPlugin {
fn activate(_ctx: Context) -> Result<(), String> {
host::register_command("shout", "My Plugin: Shout Selection", Some("My Plugin"));
Ok(())
}
fn deactivate() {}
fn run_command(id: String, arg: Option<String>) -> Result<Option<String>, String> {
match id.as_str() {
"shout" => {
host::replace_selection(&arg.unwrap_or_default().to_uppercase());
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)
}
_ => Err(format!("unknown command {id}")),
// 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}"))),
}
}
}
export!(MyPlugin);
register_plugin!(WordTools);
```
Build (copy `build.sh` or run it with your plugin's path):
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`:
```sh
plugins/build.sh hello # → plugins/hello/dist/plugin.wasm
plugins/build.sh /path/to/my-plugin
```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");
}
```
Any language with component-model tooling works (e.g. `componentize-py`,
`jco componentize`, TinyGo): the only contract is the `plugin` world.
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.
### Host functions
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`.
| Function | Notes |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `log(level, msg)` | Goes to the IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `register-command(id, title, category?)` | Becomes action `plugin.<plugin-id>.<id>`; call again to rename. Max 256 commands. |
| `show-notification(level, title, body)` | Balloon in the invoking window. |
| `workspace-root()` | Root of the project the command runs in. |
| `active-file()` | Absolute path of the focused editor's file. |
| `read-file(path)` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB. Unsaved editor contents win over disk. |
| `insert-text(text)` | Inserts at every cursor of the active editor (cursor ends after the text). |
| `replace-selection(text)` | Replaces every selection and selects the new text. |
| `register-language(toml)` | Same format as `languages/<id>.toml`. |
| `register-theme(json)` | See _Themes_ below. |
### The API
`run-command` receives an optional string argument: `executeAction("plugin.hello.insert-date", "arg")`
| Rust SDK | WIT | Notes |
| ------------------------------------------------------------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Plugin::run_command(id, ctx, arg)` | export `commands.run` | `ctx` is the window: `ctx.project()`, `ctx.editor()`. An error shows _"<title> 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. |
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
| `Project::vcs_status()`, `vcs_fetch()` | `vcs` | Fetch runs git, so only in trusted projects; call it inside a task. |
| `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::notify(level, title, body)` | `ui.show-notification` | Balloon in the window the command or events came from (every window otherwise). |
| `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::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::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.
Returning `Err(msg)` shows _"<title> failed: msg"_; the plugin keeps running.
### 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.
### 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"
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.
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.
### 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.
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.
#### 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.
### 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
@@ -157,15 +516,23 @@ 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 per call (epoch interruption);
- **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.
A panic, trap, permission violation or exceeded limit stops the plugin and
marks it _Failed_ for the session (with the reason in Settings › Plugins);
toggling it off and on, or _Reload_, starts a fresh instance. The IDE itself is
never affected.
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.
## 3. Themes
## 4. Themes
```json
{
@@ -183,42 +550,227 @@ 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 from [`templates/theme-plugin`](../templates/theme-plugin); the full guide,
token reference and IntelliJ / VS Code porting tables are in
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).
## 4. Languages
## 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.
## 6. Languages
`languages/<id>.toml` files use the IDE's own language format; see any file in
[`languages/`](../languages) (`id`, `name`, `extensions`, `filenames`,
`first-line`, `line-comment`, `block-comment`, `indent`, `ui-mode`,
`framework-ui-modes`, `language-servers`, `debugger`). `language-toy` is a
minimal example. `[[framework-ui-modes]]` entries (`ui-mode`, `root-markers`)
[`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`.
## 5. Installing and developing
### 6.1 Syntax for languages without a grammar
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (or the action
_Install Plugin from Disk…_) and pick the package folder. The manifest and the
files it references are copied to `<app-data>/plugins/<id>/`.
- **Dev plugins:** put (or symlink) the package in `<project>/.openidea/plugins/<name>/`.
It loads when the project is opened, is listed with a _dev_ tag, and _Reload_
(or the _Reload Plugins_ action) picks up a rebuilt `plugin.wasm` without
restarting the IDE.
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). 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.
- **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.
- Commands appear in Search Everywhere › Actions under their category, and can
be bound in Settings › Keymap like any other action.
## 6. Testing
## 8. Distribution
The host's integration tests (`crates/ide-plugin/tests/host.rs`) load the
checked-in `hello` and test-fixture components; rebuild them after changing the
WIT file:
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
plugins/build.sh hello
plugins/build.sh crates/ide-plugin/tests/fixtures/misbehave
cargo test -p ide-plugin
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
```
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. A publisher rotates keys by adding the new one; older
keys stay valid until revoked.
## 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
While the plugin system is a preview the API may still change freely. Once it
is declared stable wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` 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) are strings for this reason.
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.
5. **One version number**: the `wit/` package version, `ENGINE_VERSION` and
`API_VERSION` agree, and the SDK's `wit/` copy is identical to `wit/`.
Checked automatically:
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each
released version; every interface, type, function and world import or
export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/`
there when releasing; there is no snapshot before the first release.
- `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.
+121 -12
Переглянути файл
@@ -1,30 +1,139 @@
#!/usr/bin/env bash
# Builds Rust plugins into WebAssembly components (wasm32-wasip2 emits a
# component directly; no cargo-component / wasm-tools needed).
# Builds the repository's plugins: Rust code into WebAssembly components
# (wasm32-wasip2 emits a component directly; no cargo-component / wasm-tools),
# and web pages (`web-src/`, a Vite project) into `web/`.
#
# plugins/build.sh # all plugins with a Cargo.toml
# plugins/build.sh hello # one plugin
# plugins/build.sh # every plugin with code or a web page
# plugins/build.sh official/csv-editor # one plugin (a path under plugins/, or any path)
# plugins/build.sh --check [...] # change nothing tracked; fail if a checked-in
# # component is stale or a plugin doesn't build (CI)
#
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml).
# Output: <plugin>/dist/plugin.wasm (referenced by `wasm = ...` in plugin.toml)
# and <plugin>/dist/plugin.wasm.sources, a fingerprint of the sources it was
# built from: the plugin (minus target/, dist/, web/ and web-src/), the SDK
# and wit/. Components are small and checked in; builds are not
# byte-reproducible across machines, so --check compares fingerprints, not
# bytes. A web page (web-src/, a pnpm project) is built into <plugin>/web/,
# which is not checked in (it can be tens of MB): build it before installing
# the plugin from the repository. web.sources (not checked in either) skips
# rebuilding an unchanged page; --check builds it too and checks the result.
# CARGO_TARGET_DIR, when set, is shared by all plugins.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(cd "$here/.." && pwd)"
rustup target list --installed | grep -qx wasm32-wasip2 || rustup target add wasm32-wasip2
check=0
if [ "${1:-}" = "--check" ]; then
check=1
shift
fi
sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
# Lists "<path> <sha256>" for the files git sees (tracked, or new and not
# ignored) under the given paths of directory $1, minus build output and Finder
# metadata; so a
# checkout fingerprints the same as the working tree that committed it.
list() {
local dir="$1"
shift
(cd "$dir" && git ls-files --cached --others --exclude-standard -- "$@") |
{ grep -Ev '(^|/)(dist|target|node_modules)/|(^|/)\.DS_Store$|^web(-src)?/|^web\.sources$' || true; } | LC_ALL=C sort -u | while read -r f; do
if [ -f "$dir/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$dir/$f" | cut -d' ' -f1)"; fi
done
}
# Prints the fingerprint of everything plugin $1's component is built from.
fingerprint() {
{ list "$1" .; list "$repo" sdk/rust/openidea-plugin/Cargo.toml sdk/rust/openidea-plugin/src sdk/rust/openidea-plugin/wit wit; } |
sha256 | cut -d' ' -f1
}
# Prints the fingerprint of plugin $1's web page sources.
web_fingerprint() {
(cd "$1/web-src" && git ls-files --cached --others --exclude-standard -- .) |
{ grep -Ev '(^|/)node_modules/|(^|/)\.DS_Store$' || true; } | LC_ALL=C sort -u | while read -r f; do
if [ -f "$1/web-src/$f" ]; then printf '%s %s\n' "$f" "$(sha256 <"$1/web-src/$f" | cut -d' ' -f1)"; fi
done | sha256 | cut -d' ' -f1
}
# Builds plugin $1's web page into web/ unless it was built from the current
# web-src/, then checks what the manifest's `web = "…"` entries need: each
# page is there and loads its files by relative URL (it is served from the package).
build_web() {
local dir="$1" rel="${1#"$repo"/}" want entry
want="$(web_fingerprint "$dir")"
if [ "$want" != "$(cat "$dir/web.sources" 2>/dev/null || true)" ] || [ ! -d "$dir/web" ]; then
echo "building the web page of $rel"
if ! { pnpm --dir "$dir/web-src" install --frozen-lockfile --ignore-workspace --silent &&
pnpm --dir "$dir/web-src" run build; }; then
echo "FAIL $rel: the web page does not build"
failed=1
return
fi
echo "$want" >"$dir/web.sources"
fi
for entry in $(sed -n 's/^web *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | sort -u); do
if [ ! -f "$dir/$entry" ]; then
echo "FAIL $rel: $entry was not built"
failed=1
elif grep -Eq '(src|href)="/' "$dir/$entry"; then
echo "FAIL $rel: $entry loads files by absolute URL; build it with relative paths (Vite: base: \"./\")"
failed=1
fi
done
echo "ok $rel (web, $(du -sh "$dir/web" | cut -f1))"
}
failed=0
build() {
local dir="$1"
local name
if [ -f "$dir/web-src/package.json" ]; then build_web "$dir"; fi
[ -f "$dir/Cargo.toml" ] || return 0
local name target out
name="$(sed -n 's/^name *= *"\(.*\)"/\1/p' "$dir/Cargo.toml" | head -1)"
echo "building $name ($dir)"
CARGO_TARGET_DIR="$dir/target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2
target="${CARGO_TARGET_DIR:-$dir/target}"
out="$target/wasm32-wasip2/release/${name//-/_}.wasm"
local rel="${dir#"$repo"/}"
if [ "$check" = 1 ]; then
if ! CARGO_TARGET_DIR="$target" cargo build -q --locked --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2; then
echo "FAIL $rel: does not build"
failed=1
return
fi
local want have
want="$(fingerprint "$dir")"
have="$(cat "$dir/dist/plugin.wasm.sources" 2>/dev/null || true)"
if [ "$want" != "$have" ]; then
echo "STALE $rel: dist/plugin.wasm was not built from the current sources; run plugins/build.sh $rel"
failed=1
else
echo "ok $rel"
fi
return
fi
echo "building $name ($rel)"
CARGO_TARGET_DIR="$target" cargo build --manifest-path "$dir/Cargo.toml" --release --target wasm32-wasip2
mkdir -p "$dir/dist"
cp "$dir/target/wasm32-wasip2/release/${name//-/_}.wasm" "$dir/dist/plugin.wasm"
echo " -> $dir/dist/plugin.wasm ($(wc -c < "$dir/dist/plugin.wasm" | tr -d " ") bytes)"
cp "$out" "$dir/dist/plugin.wasm"
fingerprint "$dir" >"$dir/dist/plugin.wasm.sources"
echo " -> $rel/dist/plugin.wasm ($(wc -c <"$dir/dist/plugin.wasm" | tr -d " ") bytes)"
}
if [ $# -gt 0 ]; then
for p in "$@"; do
case "$p" in /*) build "$p" ;; *) build "$here/$p" ;; esac
if [ -d "$here/$p" ]; then build "$here/$p"
elif [ -d "$repo/$p" ]; then build "$(cd "$repo/$p" && pwd)"
elif [ -d "$p" ]; then build "$(cd "$p" && pwd)"
else echo "no plugin at $p" >&2; exit 2
fi
done
else
for manifest in "$here"/*/Cargo.toml; do build "$(dirname "$manifest")"; done
# Every component (plugin.toml + Cargo.toml) and web page (plugin.toml + web-src/).
for manifest in "$here"/official/*/plugin.toml "$repo"/crates/ide-plugin/tests/fixtures/*/plugin.toml; do
dir="$(dirname "$manifest")"
if [ -f "$dir/Cargo.toml" ] || [ -f "$dir/web-src/package.json" ]; then build "$dir"; fi
done
fi
exit "$failed"
-21
Переглянути файл
@@ -1,21 +0,0 @@
[package]
name = "openidea-plugin-hello"
version = "0.1.0"
edition = "2024"
publish = false
# Standalone: not a member of the IDE workspace (different target).
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.63"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
-23
Переглянути файл
@@ -1,23 +0,0 @@
id = "hello"
name = "Hello"
version = "0.1.0"
description = "Sample plugin: inserts today's date and counts words in the active file."
authors = ["OpenIDEA contributors"]
change-notes = """
0.1.0 — first release: Insert Date and Word Count of Active File commands.
"""
openidea-api = "0.1"
wasm = "dist/plugin.wasm"
permissions = ["project-read", "editor-read", "editor-write"]
# Static declarations make the commands visible before the plugin activates;
# the plugin may also register more at runtime via `register-command`.
[[contributes.commands]]
id = "insert-date"
title = "Hello: Insert Date"
category = "Hello"
[[contributes.commands]]
id = "word-count"
title = "Hello: Word Count of Active File"
category = "Hello"
-70
Переглянути файл
@@ -1,70 +0,0 @@
//! Sample OpenIDEA plugin. Build with `../build.sh hello` (see plugins/README.md).
wit_bindgen::generate!({
world: "plugin",
path: "../../wit",
});
use openidea::plugin::host::{self, LogLevel, NotificationLevel};
struct Hello;
impl Guest for Hello {
fn activate(ctx: Context) -> Result<(), String> {
host::log(LogLevel::Info, &format!("hello {} activated", ctx.plugin_version));
host::register_command("insert-date", "Hello: Insert Date", Some("Hello"));
host::register_command("word-count", "Hello: Word Count of Active File", Some("Hello"));
Ok(())
}
fn deactivate() {
host::log(LogLevel::Info, "hello deactivated");
}
fn run_command(id: String, _arg: Option<String>) -> Result<Option<String>, String> {
match id.as_str() {
"insert-date" => {
let date = today();
host::insert_text(&date);
Ok(Some(date))
}
"word-count" => {
let path = host::active_file().ok_or("no file is open in the editor")?;
let text = host::read_file(&path)?;
let words = text.split_whitespace().count();
let lines = text.lines().count();
let name = path.rsplit(['/', '\\']).next().unwrap_or(&path);
let body = format!("{words} words, {lines} lines, {} characters", text.chars().count());
host::show_notification(NotificationLevel::Info, &format!("Word count: {name}"), &body);
Ok(Some(body))
}
other => Err(format!("unknown command: {other}")),
}
}
}
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
fn today() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
format!("{y:04}-{m:02}-{d:02}")
}
/// Howard Hinnant's days-to-civil algorithm.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = yoe + era * 400 + i64::from(m <= 2);
(y, m, d)
}
export!(Hello);
-10
Переглянути файл
@@ -1,10 +0,0 @@
id = "toy"
name = "Toy"
extensions = ["toy"]
filenames = ["Toyfile"]
first-line = "^#!.*\\btoy\\b"
line-comment = "#"
block-comment = ["(*", "*)"]
indent = { tab-size = 2, insert-spaces = true }
# Borrow an existing CodeMirror mode for instant highlighting.
ui-mode = "Properties files"
-12
Переглянути файл
@@ -1,12 +0,0 @@
# Contribution-only plugin: adds a language definition (same format as
# languages/<id>.toml in the IDE repo).
id = "language-toy"
name = "Toy Language"
version = "0.1.0"
description = "Language support for .toy files: detection, comments and indentation."
authors = ["OpenIDEA contributors"]
change-notes = "0.1.0 — .toy file detection, line comments and indentation."
openidea-api = "0.1"
[[contributes.languages]]
path = "languages/toy.toml"
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+334
Переглянути файл
@@ -0,0 +1,334 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-ai-completion"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+22
Переглянути файл
@@ -0,0 +1,22 @@
[package]
name = "openidea-plugin-openidea-ai-completion"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283
+44
Переглянути файл
@@ -0,0 +1,44 @@
id = "openidea.ai-completion"
name = "AI Completion"
version = "0.1.0"
description = "Inline code suggestions (ghost text) from a local model (Ollama) or OpenAI, as you type."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-command:*"]
[capabilities]
required = ["storage"]
# Grant the backends you use; the key is only ever sent to api.openai.com.
optional = ["net:localhost", "net:api.openai.com", "secrets"]
[[contributes.providers]]
kinds = ["inline-completion"]
languages = ["*"]
[[contributes.secrets]]
name = "api-key"
label = "OpenAI API key"
description = "Used only with the OpenAI backend."
[[contributes.commands]]
id = "use-local"
title = "AI Completion: Use Local Model (Ollama)"
category = "AI Completion"
[[contributes.commands]]
id = "use-openai"
title = "AI Completion: Use OpenAI"
category = "AI Completion"
[[contributes.commands]]
id = "toggle"
title = "AI Completion: Enable / Disable"
category = "AI Completion"
[[contributes.commands]]
id = "set-endpoint"
title = "AI Completion: Set Endpoint URL"
category = "AI Completion"
+214
Переглянути файл
@@ -0,0 +1,214 @@
//! AI Completion for OpenIDEA: inline suggestions from an OpenAI-compatible
//! `/v1/completions` endpoint (fill-in-the-middle with `prompt` + `suffix`).
//! The IDE cancels a request as soon as the user types on (the fetch returns
//! `Cancelled`); the API key is filled in by the IDE (`{{secret:api-key}}`)
//! and never seen here.
use openidea_plugin::host::{self, storage};
use openidea_plugin::providers::{Document, InlineSuggestion, Providers};
use openidea_plugin::text::offset;
use openidea_plugin::{Error, HttpRequest, Invocation, LogLevel, NotificationLevel, Plugin, Position, register_plugin};
use serde_json::{Value, json};
const LOCAL: &str = "http://localhost:11434";
const OPENAI: &str = "https://api.openai.com";
const LOCAL_MODEL: &str = "qwen2.5-coder:1.5b";
const OPENAI_MODEL: &str = "gpt-3.5-turbo-instruct";
/// Context sent around the caret.
const PREFIX_CHARS: usize = 3000;
const SUFFIX_CHARS: usize = 1000;
#[derive(Default)]
pub struct AiCompletion {
/// Why the last request failed, so it is logged once rather than per keystroke.
last_error: Option<String>,
}
struct Config {
endpoint: String,
model: String,
openai: bool,
enabled: bool,
}
fn config() -> Config {
let get = |k: &str| storage::get(k).ok().flatten();
let endpoint = get("endpoint").unwrap_or_else(|| LOCAL.into());
let openai = endpoint.starts_with(OPENAI);
let model = get("model").unwrap_or_else(|| (if openai { OPENAI_MODEL } else { LOCAL_MODEL }).into());
Config { endpoint, model, openai, enabled: get("enabled").as_deref() != Some("false") }
}
/// The last `max` characters of `s` (on a char boundary).
fn tail(s: &str, max: usize) -> &str {
match s.char_indices().rev().nth(max.saturating_sub(1)) {
Some((i, _)) if s.chars().count() > max => &s[i..],
_ => s,
}
}
fn head(s: &str, max: usize) -> &str {
s.char_indices().nth(max).map_or(s, |(i, _)| &s[..i])
}
/// The request body for the text around `at`, or `None` when there is
/// nothing to complete (in the middle of a word).
pub fn request_body(doc: &Document, at: Position, model: &str) -> Option<String> {
let at = offset(&doc.text, at)?;
let (before, after) = doc.text.split_at(at);
// Only at the end of a line or before closing punctuation.
let rest_of_line = after.split('\n').next().unwrap_or("");
if !rest_of_line.trim().chars().all(|c| ")]}>;,'\"`".contains(c)) {
return None;
}
let body = json!({
"model": model,
"prompt": tail(before, PREFIX_CHARS),
"suffix": head(after, SUFFIX_CHARS),
"max_tokens": 64,
"temperature": 0,
"stop": ["\n\n"],
});
Some(body.to_string())
}
/// The suggestion in a `/v1/completions` response.
pub fn suggestion(response: &str) -> Option<String> {
let v: Value = serde_json::from_str(response).ok()?;
let text = v["choices"][0]["text"].as_str()?.trim_end().to_owned();
(!text.trim().is_empty()).then_some(text)
}
impl Providers for AiCompletion {
fn inline_completion(&mut self, doc: &Document, at: Position) -> Option<InlineSuggestion> {
let c = config();
if !c.enabled {
return None;
}
let body = request_body(doc, at, &c.model)?;
let mut req = HttpRequest::post(&format!("{}/v1/completions", c.endpoint.trim_end_matches('/')), body)
.header("Content-Type", "application/json");
if c.openai {
if !host::secret_is_set("api-key") {
return None;
}
req = req.header("Authorization", "Bearer {{secret:api-key}}");
}
req.timeout_ms = Some(10_000);
match host::fetch(&req) {
Ok(r) if r.status == 200 => {
self.last_error = None;
suggestion(&r.text()).map(|insert_text| InlineSuggestion { insert_text, replace: None })
}
// Superseded by the next keystroke: nothing to report.
Err(Error::Cancelled) => None,
other => {
let msg = match other {
Ok(r) => format!("HTTP {}: {}", r.status, head(&r.text(), 200)),
Err(e) => e.to_string(),
};
if self.last_error.as_ref() != Some(&msg) {
host::log(LogLevel::Warn, &format!("{}: {msg}", c.endpoint));
self.last_error = Some(msg);
}
None
}
}
}
}
impl Plugin for AiCompletion {
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
let msg = match id {
"use-local" => {
storage::set("endpoint", LOCAL)?;
storage::delete("model")?;
format!("Using the local model {LOCAL_MODEL} (Ollama at {LOCAL}).")
}
"use-openai" => {
storage::set("endpoint", OPENAI)?;
storage::delete("model")?;
if host::secret_is_set("api-key") {
format!("Using OpenAI ({OPENAI_MODEL}).")
} else {
"Using OpenAI: store your API key in Settings › Plugins › AI Completion.".into()
}
}
"set-endpoint" => {
let url = arg.map(str::trim).filter(|u| u.starts_with("http://") || u.starts_with("https://"));
let url = url.ok_or("give the endpoint URL, e.g. http://localhost:11434")?;
storage::set("endpoint", url)?;
format!("Using {url}.")
}
"toggle" => {
let enabled = !config().enabled;
storage::set("enabled", if enabled { "true" } else { "false" })?;
(if enabled { "AI Completion is on." } else { "AI Completion is off." }).into()
}
_ => return Err(Error::NotFound(format!("command {id}"))),
};
host::notify(NotificationLevel::Info, "AI Completion", &msg);
Ok(Some(msg))
}
}
register_plugin!(AiCompletion, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::HttpResponse;
use openidea_plugin::mock::MockHost;
fn doc(text: &str) -> Document {
Document { path: "/p/a.rs".into(), language: "rust".into(), version: 1, text: text.into() }
}
#[test]
fn asks_with_prefix_and_suffix_and_returns_the_text() {
let host = MockHost::install().with_capabilities(&["storage", "net:localhost"]);
host.on_fetch(|r| {
let body: Value = serde_json::from_slice(r.body.as_ref().unwrap()).unwrap();
assert_eq!(body["prompt"], "fn add(a: i32, b: i32) -> i32 {\n ");
assert_eq!(body["suffix"], "\n}\n");
Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"a + b\n"}]}"#.to_vec() })
});
let s = AiCompletion::default()
.inline_completion(&doc("fn add(a: i32, b: i32) -> i32 {\n \n}\n"), Position::new(1, 4))
.unwrap();
assert_eq!(s.insert_text, "a + b");
assert_eq!(host.requests()[0].url, "http://localhost:11434/v1/completions");
}
#[test]
fn openai_uses_the_secret_and_only_when_set() {
let host = MockHost::install().with_capabilities(&["storage", "net:api.openai.com", "secrets"]);
host.on_fetch(|_| {
Ok(HttpResponse { status: 200, headers: vec![], body: br#"{"choices":[{"text":"x"}]}"#.to_vec() })
});
let mut p = AiCompletion::default();
p.run_command("use-openai", &host.invocation(), None).unwrap();
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "no key yet");
host.set_secret("api-key", "sk-test");
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_some());
let sent = &host.requests()[0];
assert!(sent.headers.contains(&("Authorization".into(), "Bearer sk-test".into())));
}
#[test]
fn quiet_when_cancelled_denied_or_mid_word() {
let host = MockHost::install().with_capabilities(&["storage"]);
let mut p = AiCompletion::default();
assert!(p.inline_completion(&doc("let x = "), Position::new(0, 8)).is_none(), "net denied");
assert!(request_body(&doc("let abc = d;"), Position::new(0, 5), "m").is_none(), "mid-word");
assert!(request_body(&doc("call(x)"), Position::new(0, 6), "m").is_some(), "before `)`");
let host2 = MockHost::install().with_capabilities(&["storage", "net:localhost"]);
host2.on_fetch(|_| Err(Error::Cancelled));
assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none());
assert!(host2.logs().is_empty(), "a cancelled request is not an error");
drop(host);
p.run_command("toggle", &host2.invocation(), None).unwrap();
assert!(p.inline_completion(&doc("x"), Position::new(0, 1)).is_none());
assert_eq!(host2.requests().len(), 1, "disabled: no request");
}
}
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853
+33
Переглянути файл
@@ -0,0 +1,33 @@
id = "openidea.checkstyle"
name = "Checkstyle"
version = "0.1.0"
description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-event:file-saved", "on-command:*"]
[capabilities]
# `process:checkstyle`: the `checkstyle` command line (e.g. `brew install checkstyle`).
required = ["project.read", "events", "storage", "process:checkstyle"]
[[contributes.providers]]
kinds = ["code-actions"]
languages = ["java"]
[[contributes.commands]]
id = "check"
title = "Checkstyle: Check Current File"
category = "Checkstyle"
[[contributes.commands]]
id = "use-sun"
title = "Checkstyle: Use Sun Checks"
category = "Checkstyle"
[[contributes.commands]]
id = "use-google"
title = "Checkstyle: Use Google Checks"
category = "Checkstyle"
+317
Переглянути файл
@@ -0,0 +1,317 @@
//! Checkstyle for OpenIDEA: runs the `checkstyle` command line on a Java file
//! when it is saved (as a cancellable task; a newer save cancels the running
//! check), publishes the problems as diagnostics, and offers quick fixes for
//! the ones it can fix textually.
//!
//! The configuration is the project's own when it has one (`checkstyle.xml`,
//! `config/checkstyle/checkstyle.xml` as Gradle and Maven projects keep it,
//! or `.checkstyle.xml`), else the Google or Sun checks chosen with the
//! commands.
use openidea_plugin::host::{self, storage};
use openidea_plugin::providers::{CodeAction, Document, Providers};
use openidea_plugin::{
Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit,
register_plugin,
};
use std::collections::BTreeMap;
/// Built into the Checkstyle jar.
const GOOGLE: &str = "/google_checks.xml";
const SUN: &str = "/sun_checks.xml";
/// Project configurations, by preference (project-relative; the check runs in the project root).
const PROJECT_CONFIGS: [&str; 3] = ["checkstyle.xml", "config/checkstyle/checkstyle.xml", ".checkstyle.xml"];
/// One reported problem.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
/// 0-based.
pub line: u32,
/// 0-based; `None` for the whole line.
pub column: Option<u32>,
pub severity: Severity,
pub message: String,
/// The check's simple name (`FileTabCharacter`).
pub check: String,
}
#[derive(Default)]
pub struct Checkstyle {
/// Last results per file (for quick fixes; rebuilt by the next save).
problems: BTreeMap<String, Vec<Problem>>,
}
fn unescape(s: &str) -> String {
s.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#10;", "\n")
.replace("&amp;", "&")
}
fn attr(tag: &str, name: &str) -> Option<String> {
let start = tag.find(&format!(" {name}=\""))? + name.len() + 3;
let end = tag[start..].find('"')? + start;
Some(unescape(&tag[start..end]))
}
/// Problems per file from Checkstyle's `-f xml` report.
pub fn parse_report(xml: &str) -> BTreeMap<String, Vec<Problem>> {
let mut out: BTreeMap<String, Vec<Problem>> = BTreeMap::new();
let mut file = None;
for tag in xml.split('<').skip(1) {
let tag = format!(" {}", tag.split('>').next().unwrap_or(""));
if tag.starts_with(" file ") {
file = attr(&tag, "name");
if let Some(f) = &file {
out.entry(f.clone()).or_default();
}
} else if tag.starts_with(" error ") {
let (Some(f), Some(line)) = (&file, attr(&tag, "line").and_then(|l| l.parse::<u32>().ok())) else {
continue;
};
let severity = match attr(&tag, "severity").as_deref() {
Some("error") => Severity::Error,
Some("info") => Severity::Info,
Some("ignore") => continue,
_ => Severity::Warning,
};
let check = attr(&tag, "source").unwrap_or_default();
// Checks configured with an `id` report it after a `#` (Google checks: `RegexpSinglelineCheck#noTrailingWhitespace`).
let check = check.split('#').next().unwrap_or_default();
let check = check.rsplit('.').next().unwrap_or(check).trim_end_matches("Check").to_owned();
out.entry(f.clone()).or_default().push(Problem {
line: line.saturating_sub(1),
column: attr(&tag, "column").and_then(|c| c.parse::<u32>().ok()).map(|c| c.saturating_sub(1)),
severity,
message: attr(&tag, "message").unwrap_or_default(),
check,
});
}
}
out
}
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()),
}
}
/// The project containing `path`.
fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
/// The configuration for files of `project`.
fn config_for(project: &Project) -> String {
PROJECT_CONFIGS
.iter()
.find(|c| project.read_file(c).is_ok())
.map(|c| (*c).to_owned())
.or_else(|| storage::get("config").ok().flatten())
.unwrap_or_else(|| GOOGLE.into())
}
impl Checkstyle {
/// Checks `path` and publishes the result. `Err(Cancelled)` when a newer
/// save (or the user) cancelled it.
fn check(&mut self, path: &str) -> Result<usize, Error> {
let project = project_of(path).ok_or_else(|| Error::NotFound(format!("{path} is not in an open project")))?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let task = host::begin_task(&format!("Checkstyle: {name}"), true);
let config = config_for(&project);
let args = vec!["-f".to_owned(), "xml".into(), "-c".into(), config, path.to_owned()];
let out = project.run_process("checkstyle", &args, None)?;
if task.is_cancelled() {
return Err(Error::Cancelled);
}
// Exit status is the number of errors; only missing output is a failure.
if !out.stdout.contains("<checkstyle") {
return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report"))));
}
let report = parse_report(&out.stdout);
let problems = report
.into_iter()
.find(|(f, _)| f == path || path.ends_with(f.as_str()))
.map(|(_, p)| p)
.unwrap_or_default();
let text = project.read_file(path).unwrap_or_default();
let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect();
host::publish_diagnostics(path, &diagnostics)?;
let n = problems.len();
self.problems.insert(path.to_owned(), problems);
Ok(n)
}
}
impl Providers for Checkstyle {
fn code_actions(&mut self, doc: &Document, selection: Range) -> Vec<CodeAction> {
let Some(problems) = self.problems.get(&doc.path) else { return Vec::new() };
let lines: Vec<&str> = doc.text.split('\n').collect();
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for p in problems.iter().filter(|p| p.line >= selection.start.line && p.line <= selection.end.line) {
let Some(line) = lines.get(p.line as usize) else { continue };
let whole = Range::new(Position::new(p.line, 0), Position::new(p.line, line.encode_utf16().count() as u32));
let (title, edit) = match p.check.as_str() {
"FileTabCharacter" => {
("Replace tabs with spaces", TextEdit { range: whole, text: line.replace('\t', " ") })
}
"RegexpSingleline" if p.message.to_lowercase().contains("trailing") => {
("Remove trailing whitespace", TextEdit { range: whole, text: line.trim_end().to_owned() })
}
"NewlineAtEndOfFile" => {
let last = lines.len() as u32 - 1;
let end = Position::new(last, lines[last as usize].encode_utf16().count() as u32);
(
"Add a newline at the end of the file",
TextEdit { range: Range::new(end, end), text: "\n".into() },
)
}
_ => continue,
};
if seen.insert((title, p.line)) {
out.push(CodeAction {
title: format!("Checkstyle: {title}"),
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
});
}
}
out
}
}
impl Plugin for Checkstyle {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"check" => {
let editor = ctx.editor().ok_or("no file is open")?;
let n = self.check(&editor.path())?;
Ok(Some(format!("{n} problems")))
}
"use-google" | "use-sun" => {
storage::set("config", if id == "use-sun" { SUN } else { GOOGLE })?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
let mut saved: Vec<&str> = events
.iter()
.filter_map(|e| match e {
Event::FileSaved(p) if p.ends_with(".java") => Some(p.as_str()),
_ => None,
})
.collect();
saved.sort_unstable();
saved.dedup();
for path in saved {
match self.check(path) {
Ok(_) => {}
Err(Error::Cancelled) => return, // a newer save takes over
Err(e) => {
host::notify(NotificationLevel::Warning, "Checkstyle", &e.to_string());
return;
}
}
}
}
}
register_plugin!(Checkstyle, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::ProcessOutput;
use openidea_plugin::mock::MockHost;
const REPORT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="10.17.0">
<file name="/project/src/A.java">
<error line="2" column="1" severity="warning" message="Line contains a tab character." source="com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck"/>
<error line="3" severity="error" message="Trailing whitespace is not allowed" source="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineCheck#noTrailingWhitespace"/>
</file>
</checkstyle>"#;
#[test]
fn parses_reports() {
let r = parse_report(REPORT);
let p = &r["/project/src/A.java"];
assert_eq!(p.len(), 2);
assert_eq!((p[0].line, p[0].column, p[0].check.as_str()), (1, Some(0), "FileTabCharacter"));
assert_eq!(
(p[1].severity, p[1].message.as_str(), p[1].check.as_str()),
(Severity::Error, "Trailing whitespace is not allowed", "RegexpSingleline")
);
}
#[test]
fn checks_saved_java_files_and_fixes() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
let text = "class A {\n\tint x;\n void f() {} \n}";
host.add_file("src/A.java", text);
host.on_process("checkstyle", |args, _| {
assert_eq!(&args[..4], ["-f", "xml", "-c", GOOGLE]);
Ok(ProcessOutput { status: Some(1), stdout: REPORT.into(), stderr: String::new() })
});
let mut p = Checkstyle::default();
p.handle_events(
&[Event::FileSaved("/project/src/A.java".into()), Event::FileSaved("/project/notes.txt".into())],
&host.invocation(),
);
assert_eq!(host.process_runs().len(), 1, "only Java files");
assert_eq!(host.process_runs()[0].cwd.as_deref(), Some("/project"));
let d = &host.diagnostics()["/project/src/A.java"];
assert_eq!(d.len(), 2);
assert_eq!(d[1].range, Range::new(Position::new(2, 0), Position::new(2, 16)), "whole line without a column");
assert!(host.tasks()[0].ended);
let doc =
Document { path: "/project/src/A.java".into(), language: "java".into(), version: 1, text: text.into() };
let actions = p.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0)));
let titles: Vec<_> = actions.iter().map(|a| a.title.as_str()).collect();
assert_eq!(titles, ["Checkstyle: Replace tabs with spaces", "Checkstyle: Remove trailing whitespace"]);
assert_eq!(actions[1].edits[0].1[0].text, " void f() {}");
}
#[test]
fn the_projects_configuration_wins() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
host.add_file("src/A.java", "class A {}");
host.on_process("checkstyle", |_, _| {
Ok(ProcessOutput { status: Some(0), stdout: "<checkstyle/>".into(), stderr: String::new() })
});
let mut p = Checkstyle::default();
p.run_command("use-sun", &host.invocation(), None).unwrap();
p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
host.add_file("config/checkstyle/checkstyle.xml", "<module name=\"Checker\"/>");
p.handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
let configs: Vec<String> = host.process_runs().iter().map(|r| r.args[3].clone()).collect();
assert_eq!(configs, [SUN, "config/checkstyle/checkstyle.xml"]);
}
#[test]
fn a_missing_checkstyle_is_reported_once() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage"]);
host.add_file("src/A.java", "class A {}");
Checkstyle::default().handle_events(&[Event::FileSaved("/project/src/A.java".into())], &host.invocation());
assert!(host.notifications()[0].body.contains("process:checkstyle"));
}
}
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-csv-editor"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-csv-editor"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
+23
Переглянути файл
@@ -0,0 +1,23 @@
id = "openidea.csv-editor"
name = "CSV Editor"
version = "0.1.0"
description = "Edit CSV and TSV files as a table. Edits go to the file's text, so the text view follows and undo is shared."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:table"]
[capabilities]
required = ["editor.read", "editor.write"]
[[contributes.customEditors]]
id = "table"
title = "Table"
patterns = ["*.csv", "*.tsv"]
priority = "default"
[contributes.settings]
title = "CSV Editor"
schema = "settings.schema.json"
+17
Переглянути файл
@@ -0,0 +1,17 @@
{
"properties": {
"firstRowIsHeader": {
"type": "boolean",
"title": "First row is the header",
"description": "Show the first record as column names instead of a row.",
"default": true
},
"delimiter": {
"type": "string",
"title": "Delimiter",
"description": "auto: tab for .tsv files, else the most frequent of , ; | in the first line.",
"enum": ["auto", ",", ";", "|", "tab"],
"default": "auto"
}
}
}
+319
Переглянути файл
@@ -0,0 +1,319 @@
//! CSV Editor for OpenIDEA: a custom editor showing `.csv` / `.tsv` files as
//! a table. It never keeps its own copy of the data: every render parses the
//! document, and every edit is a versioned text edit of the document, so the
//! text view of the same file follows at once and Undo is one history.
//!
//! Large files: the IDE asks only for the rows it shows; the parse of the
//! last document version is kept, so scrolling doesn't parse again.
use openidea_plugin::host;
use openidea_plugin::text::position;
use openidea_plugin::views::{Body, Button, Table, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, Range, TextEdit, register_plugin};
use std::ops::Range as Span;
/// One record: the byte spans of its fields and of the whole record (without
/// the line break).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub fields: Vec<Span<usize>>,
pub span: Span<usize>,
/// Where the next record starts (after the line break), or the text's end.
pub next: usize,
}
/// A parsed document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Csv {
pub delimiter: char,
pub records: Vec<Record>,
}
/// The field's text: quotes removed, `""` unescaped.
pub fn field_text(text: &str, span: &Span<usize>) -> String {
let raw = &text[span.clone()];
match raw.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
Some(inner) if raw.len() >= 2 => inner.replace("\"\"", "\""),
_ => raw.to_owned(),
}
}
/// `value` as a field: quoted when it contains the delimiter, a quote or a line break.
pub fn quote(value: &str, delimiter: char) -> String {
if value.contains([delimiter, '"', '\n', '\r']) || value.starts_with(' ') || value.ends_with(' ') {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_owned()
}
}
/// The delimiter: `tab` for .tsv, else the most frequent of `,;|\t` in the first line.
pub fn detect_delimiter(path: &str, text: &str, setting: &str) -> char {
match setting {
"," | ";" | "|" => return setting.chars().next().expect("non-empty"),
"tab" => return '\t',
_ => {}
}
if path.to_ascii_lowercase().ends_with(".tsv") {
return '\t';
}
let first = text.lines().next().unwrap_or("");
[',', ';', '\t', '|']
.into_iter()
.max_by_key(|d| first.matches(*d).count())
.filter(|d| first.contains(*d))
.unwrap_or(',')
}
/// RFC 4180 records (quoted fields may span lines); a trailing line break
/// doesn't start an empty record.
pub fn parse(text: &str, delimiter: char) -> Csv {
let bytes = text.as_bytes();
let d = delimiter as u8;
let mut records = Vec::new();
let mut i = 0;
while i < bytes.len() {
let start = i;
let mut fields = Vec::new();
loop {
let field_start = i;
if bytes.get(i) == Some(&b'"') {
i += 1;
while i < bytes.len() {
if bytes[i] == b'"' {
if bytes.get(i + 1) == Some(&b'"') {
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
}
while i < bytes.len() && bytes[i] != d && bytes[i] != b'\n' && bytes[i] != b'\r' {
i += 1;
}
fields.push(field_start..i);
if i < bytes.len() && bytes[i] == d {
i += 1;
continue;
}
break;
}
let end = i;
if bytes.get(i) == Some(&b'\r') {
i += 1;
}
if bytes.get(i) == Some(&b'\n') {
i += 1;
}
records.push(Record { fields, span: start..end, next: i });
}
Csv { delimiter, records }
}
#[derive(Default)]
pub struct CsvEditor {
/// (path, version) of the last parse, and the parse.
cache: Option<((String, u64), Csv)>,
}
/// Rows the IDE gets when it asks for no viewport.
const DEFAULT_ROWS: u32 = 200;
fn edit(text: &str, span: Span<usize>, new_text: String) -> TextEdit {
TextEdit { range: Range::new(position(text, span.start), position(text, span.end)), text: new_text }
}
impl CsvEditor {
fn parsed(&mut self, path: &str, version: u64, text: &str) -> &Csv {
let key = (path.to_owned(), version);
if self.cache.as_ref().is_none_or(|(k, _)| *k != key) {
let setting = host::setting_string("delimiter").unwrap_or_else(|| "auto".into());
let csv = parse(text, detect_delimiter(path, text, &setting));
self.cache = Some((key, csv));
}
&self.cache.as_ref().expect("just set").1
}
/// The edits for `event` on `text` (`header`: the first record is not a row).
pub fn edits_for(csv: &Csv, text: &str, header: bool, event: &ViewEvent) -> Result<Vec<TextEdit>, Error> {
let skip = usize::from(header && !csv.records.is_empty());
let d = csv.delimiter;
let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(1).max(1);
let empty_record = d.to_string().repeat(width - 1);
let record = |row: u32| csv.records.get(row as usize + skip);
let insert = |before: u32| match record(before) {
Some(r) => vec![edit(text, r.span.start..r.span.start, format!("{empty_record}\n"))],
None => {
// Append; the last record may lack its line break.
let end = text.len();
let sep = if text.is_empty() || text.ends_with('\n') { "" } else { "\n" };
vec![edit(text, end..end, format!("{sep}{empty_record}\n"))]
}
};
Ok(match event {
ViewEvent::Cell { row, column, value } => {
let r = record(*row).ok_or_else(|| Error::Invalid(format!("no row {row}")))?;
let column = *column as usize;
match r.fields.get(column) {
Some(span) => vec![edit(text, span.clone(), quote(value, d))],
// A short record: pad it with empty fields.
None => {
let pad = d.to_string().repeat(column + 1 - r.fields.len());
vec![edit(text, r.span.end..r.span.end, format!("{pad}{}", quote(value, d)))]
}
}
}
ViewEvent::InsertRow(before) => insert(*before),
ViewEvent::Button(b) if b == "add-row" => insert(u32::MAX),
ViewEvent::DeleteRows(rows) => {
let mut rows: Vec<u32> = rows.clone();
rows.sort_unstable();
rows.dedup();
rows.iter()
.filter_map(|row| record(*row))
.map(|r| edit(text, r.span.start..r.next, String::new()))
.collect()
}
_ => Vec::new(),
})
}
}
impl Plugin for CsvEditor {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for CsvEditor {
fn render(&mut self, view: &str, viewport: Option<Viewport>, ctx: &Invocation) -> Result<View, Error> {
if view != "table" {
return Err(Error::NotFound(view.into()));
}
let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?;
let doc = editor.document()?;
let path = editor.path();
let header = host::setting_bool("firstRowIsHeader").unwrap_or(true);
let csv = self.parsed(&path, doc.version, &doc.text).clone();
let skip = usize::from(header && !csv.records.is_empty());
let width = csv.records.iter().map(|r| r.fields.len()).max().unwrap_or(0);
let columns: Vec<String> = (0..width)
.map(|c| match (header, csv.records.first().and_then(|r| r.fields.get(c))) {
(true, Some(span)) => field_text(&doc.text, span),
_ => format!("Column {}", c + 1),
})
.collect();
let total = (csv.records.len() - skip) as u32;
let range = Viewport::range(viewport, total, DEFAULT_ROWS);
let rows = range
.clone()
.map(|row| {
let r = &csv.records[row as usize + skip];
(0..width).map(|c| r.fields.get(c).map(|s| field_text(&doc.text, s)).unwrap_or_default()).collect()
})
.collect();
let delimiter = match csv.delimiter {
'\t' => "tab".to_owned(),
d => format!("'{d}'"),
};
Ok(View::new(Body::Table(Table { columns, total_rows: total, first_row: range.start, rows, editable: true }))
.button(Button::new("add-row", "Add Row").icon("plus"))
.status(&format!("{total} rows × {width} columns, delimiter {delimiter}")))
}
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
if view != "table" {
return Err(Error::NotFound(view.into()));
}
let editor = ctx.editor().ok_or_else(|| Error::Invalid("the table editor needs a document".into()))?;
let doc = editor.document()?;
let path = editor.path();
let header = host::setting_bool("firstRowIsHeader").unwrap_or(true);
let csv = self.parsed(&path, doc.version, &doc.text).clone();
let edits = Self::edits_for(&csv, &doc.text, header, &event)?;
if !edits.is_empty() {
// Stale: the user typed in the text view meanwhile; the table renders again.
editor.apply_edits(doc.version, &edits)?;
}
Ok(())
}
}
register_plugin!(CsvEditor, views);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
const DATA: &str = "name,age,city\nAda,36,London\n\"Lovelace, A.\",\"say \"\"hi\"\"\",\"multi\nline\"\nBob,40\n";
#[test]
fn parses_quotes_line_breaks_and_short_records() {
let csv = parse(DATA, ',');
assert_eq!(csv.records.len(), 4);
let texts: Vec<String> = csv.records[2].fields.iter().map(|s| field_text(DATA, s)).collect();
assert_eq!(texts, ["Lovelace, A.", "say \"hi\"", "multi\nline"]);
assert_eq!(csv.records[3].fields.len(), 2);
assert_eq!(parse("a,b\r\nc,d", ',').records.len(), 2);
assert_eq!(detect_delimiter("x.csv", "a;b;c\n", "auto"), ';');
assert_eq!(detect_delimiter("x.tsv", "a,b\n", "auto"), '\t');
assert_eq!(quote("a,b", ','), "\"a,b\"");
assert_eq!(quote("plain", ','), "plain");
}
#[test]
fn renders_the_visible_rows_with_the_header() {
let host = MockHost::install();
host.open_editor("/project/people.csv", "csv", DATA, &[]);
let mut p = CsvEditor::default();
let view = p.render("table", Some(Viewport { first_row: 1, rows: 1 }), &host.invocation()).unwrap();
let Body::Table(t) = view.body else { panic!("a table") };
assert_eq!(t.columns, ["name", "age", "city"]);
assert_eq!((t.total_rows, t.first_row), (3, 1));
assert_eq!(t.rows, [vec!["Lovelace, A.", "say \"hi\"", "multi\nline"]]);
host.set_setting("firstRowIsHeader", "false");
let view = p.render("table", None, &host.invocation()).unwrap();
let Body::Table(t) = view.body else { panic!("a table") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
}
#[test]
fn edits_cells_and_rows_through_the_document() {
let host = MockHost::install();
host.open_editor("/project/people.csv", "csv", DATA, &[]);
let mut p = CsvEditor::default();
let cell = |row, column, value: &str| ViewEvent::Cell { row, column, value: value.into() };
p.handle("table", cell(0, 2, "Paris, FR"), &host.invocation()).unwrap();
assert_eq!(host.editor_text().lines().nth(1), Some("Ada,36,\"Paris, FR\""));
// A short record is padded.
p.handle("table", cell(2, 2, "Oslo"), &host.invocation()).unwrap();
assert!(host.editor_text().ends_with("Bob,40,Oslo\n"), "{}", host.editor_text());
p.handle("table", ViewEvent::InsertRow(0), &host.invocation()).unwrap();
assert_eq!(host.editor_text().lines().nth(1), Some(",,"));
p.handle("table", ViewEvent::DeleteRows(vec![0, 1]), &host.invocation()).unwrap();
assert!(host.editor_text().starts_with("name,age,city\n\"Lovelace"), "{}", host.editor_text());
p.handle("table", ViewEvent::Button("add-row".into()), &host.invocation()).unwrap();
assert!(host.editor_text().ends_with("Bob,40,Oslo\n,,\n"), "{}", host.editor_text());
}
#[test]
fn a_changed_document_is_not_overwritten() {
let host = MockHost::install();
host.open_editor("/project/a.csv", "csv", "a,b\n1,2\n", &[]);
let mut p = CsvEditor::default();
let csv = parse("a,b\n1,2\n", ',');
let edits =
CsvEditor::edits_for(&csv, "a,b\n1,2\n", true, &ViewEvent::Cell { row: 0, column: 0, value: "9".into() });
let editor = host.invocation().editor().unwrap();
host.type_in_editor("x");
assert_eq!(editor.apply_edits(0, &edits.unwrap()), Err(Error::Stale));
// The next event works on the current version.
p.handle("table", ViewEvent::Cell { row: 0, column: 1, value: "8".into() }, &host.invocation()).unwrap();
assert!(host.editor_text().contains(",8"), "{}", host.editor_text());
}
}
+4
Переглянути файл
@@ -0,0 +1,4 @@
web-src/node_modules/
# Built by plugins/build.sh, not checked in.
web/
web.sources
+28
Переглянути файл
@@ -0,0 +1,28 @@
# Third-party software
The page in `web/` is built from `web-src/` and bundles:
- **Excalidraw** (`@excalidraw/excalidraw` 0.18.1), MIT License, Copyright (c) 2020 Excalidraw —
https://github.com/excalidraw/excalidraw — with the libraries it loads on demand (among them
Mermaid, KaTeX and Cytoscape.js, MIT License).
- **React** and **React DOM**, MIT License, Copyright (c) Meta Platforms, Inc. and affiliates.
- The fonts Excalidraw ships in `web/fonts/` (Excalifont, Virgil, Xiaolai, Nunito, Lilita One,
Comic Shanns, Cascadia Code, Liberation Sans, Assistant), each under its own licence (SIL Open
Font License 1.1, or MIT for Comic Shanns); see the Excalidraw repository for their texts.
The MIT License:
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+5
Переглянути файл
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#F1EFFF" stroke="#6E5BD8"/>
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#6E5BD8" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4" cy="10.5" r="1" fill="#6E5BD8"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 411 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#2B2747" stroke="#A99CFF"/>
<path d="M4 10.5c1.2-2.6 2.4-3.9 3.6-3.9 1.5 0 1.1 2.4 2.6 2.4.9 0 1.5-.8 1.8-1.6" stroke="#A99CFF" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4" cy="10.5" r="1" fill="#A99CFF"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 411 B

+6
Переглянути файл
@@ -0,0 +1,6 @@
id = "excalidraw"
name = "Excalidraw"
extensions = ["excalidraw"]
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "JSON"
tree-sitter = "json"
+35
Переглянути файл
@@ -0,0 +1,35 @@
id = "openidea.excalidraw"
name = "Excalidraw"
version = "0.1.0"
description = "Draw and edit Excalidraw diagrams (.excalidraw). The drawing is the file's text, so the text editor follows, Undo and Save are shared, and the IDE theme applies."
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)."
engines.openidea = "^1.0"
[capabilities]
# Copy and paste through Excalidraw's menus (the keyboard works without it).
optional = ["clipboard"]
[[contributes.customEditors]]
id = "canvas"
title = "Drawing"
patterns = ["*.excalidraw", "*.excalidraw.json"]
priority = "default"
layout = "preview"
web = "web/index.html"
[[contributes.languages]]
path = "languages/excalidraw.toml"
[[contributes.fileIcons]]
id = "excalidraw"
patterns = ["*.excalidraw", "*.excalidraw.json"]
icon = "icons/excalidraw.svg"
iconDark = "icons/excalidraw_dark.svg"
[[contributes.fileTemplates]]
id = "drawing"
name = "Excalidraw Drawing"
extension = "excalidraw"
path = "templates/drawing.excalidraw"
+13
Переглянути файл
@@ -0,0 +1,13 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://openidea.dev",
"elements": [],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}
+32
Переглянути файл
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Excalidraw</title>
<style>
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.openidea-error {
box-sizing: border-box;
padding: 24px;
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
color: var(--openidea-fg);
}
.openidea-error pre {
white-space: pre-wrap;
color: var(--openidea-danger);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+26
Переглянути файл
@@ -0,0 +1,26 @@
{
"name": "openidea-plugin-excalidraw-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "The Excalidraw plugin's web page; `pnpm build` writes it to ../web.",
"scripts": {
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^26.6.6",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^30.1.3",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^5.0.5"
}
}
File diff suppressed because it is too large. Load diff
+6
Переглянути файл
@@ -0,0 +1,6 @@
/**
* Excalidraw loads its fonts from `EXCALIDRAW_ASSET_PATH` (else from a CDN,
* which the frame can't reach): the page's own directory, where the build
* copies them. Imported before Excalidraw.
*/
window.EXCALIDRAW_ASSET_PATH = new URL("./", window.location.href).href;
+25
Переглянути файл
@@ -0,0 +1,25 @@
/**
* The frame may not download files (no `allow-downloads`): Excalidraw's
* "Save to disk" and image export click a download link, which this turns
* into the IDE's save dialog (`openidea.saveFile`).
*/
const click = HTMLAnchorElement.prototype.click;
export function routeDownloadsToTheIde(bridge: OpenideaBridge) {
HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) {
const href = this.href;
if (!this.hasAttribute("download") || !/^(blob|data):/.test(href)) return click.call(this);
const name = this.download || "untitled";
void fetch(href)
.then((r) => r.blob())
.then((data) => bridge.saveFile({ name, data, filters: filtersFor(name) }))
.catch((e: unknown) => console.error("could not save", name, e));
};
}
function filtersFor(name: string): OpenideaFileFilter[] | undefined {
const ext = /\.([\w]+)$/.exec(name)?.[1]?.toLowerCase();
if (!ext) return undefined;
const label: Record<string, string> = { png: "PNG image", svg: "SVG image", excalidraw: "Excalidraw drawing" };
return [{ name: label[ext] ?? ext.toUpperCase(), extensions: [ext] }];
}
+123
Переглянути файл
@@ -0,0 +1,123 @@
/**
* The Excalidraw plugin's page: the canvas of the `.excalidraw` file its
* custom editor shows, kept in step with the file's text (see sync.ts), in
* the IDE's light or dark theme.
*/
import "./assets";
import { Excalidraw, MainMenu, THEME } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI, UIOptions } from "@excalidraw/excalidraw/types";
import { StrictMode, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { routeDownloadsToTheIde } from "./downloads";
import { applyScene, codec, exportName, loadSceneFonts, type Scene } from "./scene";
import { DocumentSync } from "./sync";
// The canvas is the file's: the IDE opens, saves and themes it, so the page
// keeps only what works on the drawing itself (no Open / Save to, no links
// out of a page without network).
const UI_OPTIONS: UIOptions = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: false,
loadScene: false,
saveAsImage: true,
saveToActiveFile: false,
toggleTheme: false,
},
};
function Menu() {
return (
<MainMenu>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
}
function useIdeTheme(bridge: OpenideaBridge): "dark" | "light" {
const [dark, setDark] = useState(bridge.theme?.dark ?? window.matchMedia?.("(prefers-color-scheme: dark)").matches);
useEffect(() => bridge.onThemeChange((t) => setDark(t.dark)), [bridge]);
return dark ? THEME.DARK : THEME.LIGHT;
}
type Start = { scene: Scene | null; error: string | null; readOnly: boolean; name: string };
function App({ bridge }: { bridge: OpenideaBridge }) {
const theme = useIdeTheme(bridge);
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
const [start, setStart] = useState<Start | null>(null);
const [error, setError] = useState<string | null>(null);
// The document's `source`: the canvas doesn't keep it, the file should.
const source = useRef<string | undefined>(undefined);
// `apply` reads the API when the document changes, which is after the canvas exists.
const sync = useMemo(() => {
let current: ExcalidrawImperativeAPI | null = null;
const s = new DocumentSync(bridge.document, codec, {
apply: (scene) => {
source.current = scene.source;
if (current) applyScene(current, scene);
},
error: setError,
});
return Object.assign(s, { bind: (a: ExcalidrawImperativeAPI | null) => void (current = a) });
}, [bridge]);
useEffect(() => {
sync.start().then(
(s) => {
source.current = s.scene?.source;
setStart(s);
setError(s.error);
},
(e: unknown) =>
setStart({ scene: null, error: String(e instanceof Error ? e.message : e), readOnly: true, name: "" }),
);
}, [sync]);
useEffect(() => {
sync.bind(api);
if (api) void loadSceneFonts(api);
}, [api, sync]);
if (!start) return null;
if (!start.scene && error)
return (
<div className="openidea-error">
<p>This file can't be shown as a drawing until its text is valid again:</p>
<pre>{error}</pre>
</div>
);
return (
<Excalidraw
excalidrawAPI={setApi}
initialData={start.scene ? { ...start.scene, scrollToContent: true } : null}
theme={theme}
viewModeEnabled={start.readOnly}
name={exportName(start.name)}
UIOptions={UI_OPTIONS}
onChange={(elements, appState, files) => sync.changed({ elements, appState, files, source: source.current })}
>
<Menu />
</Excalidraw>
);
}
const bridge = window.openidea;
const root = createRoot(document.getElementById("root")!);
if (bridge) {
routeDownloadsToTheIde(bridge);
root.render(
<StrictMode>
<App bridge={bridge} />
</StrictMode>,
);
} else {
root.render(<p className="openidea-error">This page runs inside OpenIDEA.</p>);
}
+41
Переглянути файл
@@ -0,0 +1,41 @@
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
interface OpenideaDocumentState {
version: number;
text: string;
}
interface OpenideaTextEdit {
from: number;
to: number;
insert: string;
}
interface OpenideaFileFilter {
name: string;
extensions: string[];
}
interface OpenideaBridge {
postMessage(text: string): void;
onMessage(fn: (text: string) => void): void;
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
document: {
/** `name`: the file's name, without its directory. */
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
onChange(fn: (state: OpenideaDocumentState) => void): void;
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
replace(version: number, text: string): Promise<{ version: number }>;
};
saveFile(options: {
name: string;
data: string | Uint8Array | ArrayBuffer | Blob;
filters?: OpenideaFileFilter[];
}): Promise<string | null>;
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
}
interface Window {
openidea?: OpenideaBridge;
EXCALIDRAW_ASSET_PATH?: string | string[];
}
+40
Переглянути файл
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { SOURCE, exportName, parseScene, serializeScene } from "./scene";
const scene = (text: string) => {
const parsed = parseScene(text);
if ("error" in parsed) throw new Error(parsed.error);
return parsed.scene;
};
describe("scene files", () => {
it("keeps the file's source and writes ours, never the page's address, when it had none", () => {
const own = JSON.parse(
serializeScene(scene('{"type":"excalidraw","source":"https://excalidraw.com","elements":[]}')),
);
expect(own.source).toBe("https://excalidraw.com");
const none = JSON.parse(serializeScene(scene('{"type":"excalidraw","elements":[]}')));
expect(none.source).toBe(SOURCE);
for (const page of ["openidea-plugin://localhost", "http://openidea-plugin.localhost"]) {
const fixed = JSON.parse(serializeScene(scene(`{"type":"excalidraw","source":"${page}","elements":[]}`)));
expect(fixed.source).toBe(SOURCE);
}
});
it("reads element skeletons and refuses what isn't a drawing", () => {
// (Labels become text elements in the browser; jsdom can't measure text.)
const s = scene('[{"type":"rectangle","x":0,"y":0,"width":10,"height":10}]');
expect(s.elements[0]).toMatchObject({ type: "rectangle", width: 10 });
expect(typeof s.elements[0]!.seed).toBe("number");
expect(parseScene("{not json")).toHaveProperty("error");
expect(parseScene('{"type":"other"}')).toHaveProperty("error");
expect(parseScene("")).toHaveProperty("scene");
});
it("names exports after the file", () => {
expect(exportName("diagram.excalidraw")).toBe("diagram");
expect(exportName("Flow.Excalidraw.json")).toBe("Flow");
expect(exportName("notes.v2.excalidraw")).toBe("notes.v2");
expect(exportName("")).toBe("drawing");
});
});
+170
Переглянути файл
@@ -0,0 +1,170 @@
/**
* The one module that talks to `@excalidraw/excalidraw`'s data API: reading a
* `.excalidraw` file into a scene (through Excalidraw's own `restore`, so old
* files and element skeletons work), writing it back in the format
* Excalidraw saves, and showing a scene that changed in the text.
*/
import {
CaptureUpdateAction,
convertToExcalidrawElements,
getNonDeletedElements,
getSceneVersion,
restore,
serializeAsJSON,
} from "@excalidraw/excalidraw";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState, BinaryFiles, ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import type { SceneCodec } from "./sync";
export interface Scene {
elements: readonly OrderedExcalidrawElement[];
appState: Partial<AppState>;
files: BinaryFiles;
/** The file's `source`, kept as it was. */
source?: string;
}
/** `source` of files that had none (Excalidraw would write the page's address). */
export const SOURCE = "https://openidea.dev";
type Skeleton = NonNullable<Parameters<typeof convertToExcalidrawElements>[0]>[number];
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
/**
* Hand-written or generated files often hold element skeletons
* (`{ type: "rectangle", x, y, label: { text } }`) rather than full elements:
* expand those, keep full elements (their ids and versions) as they are.
*/
function expandSkeletons(raw: readonly unknown[]): unknown[] {
const out: unknown[] = [];
for (const entry of raw) {
if (!isRecord(entry) || typeof entry.type !== "string") continue;
const skeleton = "label" in entry || typeof entry.seed !== "number";
if (!skeleton) {
out.push(entry);
continue;
}
try {
out.push(...convertToExcalidrawElements([entry as unknown as Skeleton], { regenerateIds: false }));
} catch {
out.push(entry);
}
}
return out;
}
export function parseScene(text: string): { scene: Scene } | { error: string } {
let data: unknown = {};
if (text.trim()) {
try {
data = JSON.parse(text);
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}
if (Array.isArray(data)) data = { elements: data };
if (!isRecord(data)) return { error: "An Excalidraw file is a JSON object (or an array of elements)." };
if (typeof data.type === "string" && !data.type.startsWith("excalidraw"))
return { error: `Not an Excalidraw file: its "type" is "${data.type}".` };
if (data.elements !== undefined && !Array.isArray(data.elements)) return { error: '"elements" must be an array.' };
const restored = restore(
{
elements: expandSkeletons((data.elements as unknown[]) ?? []) as never,
appState: (isRecord(data.appState) ? data.appState : {}) as Partial<AppState>,
files: (isRecord(data.files) ? data.files : {}) as BinaryFiles,
},
null,
null,
{ refreshDimensions: false, repairBindings: true },
);
// The page's own address (what Excalidraw writes here) is no source worth keeping.
const own = /^(openidea-plugin:|https?:\/\/openidea-plugin\.localhost)/;
const source =
typeof data.source === "string" && data.source.trim() && !own.test(data.source) ? data.source : undefined;
return { scene: { elements: restored.elements, appState: restored.appState, files: restored.files ?? {}, source } };
}
/**
* As Excalidraw saves to disk (`serializeAsJSON(…, "local")`), with a final
* newline, keeping the file's `source`: Excalidraw writes the page's origin,
* which here is the IDE's internal plugin URL.
*/
export function serializeScene(scene: Scene): string {
const json = serializeAsJSON(scene.elements, scene.appState, scene.files, "local");
const source = JSON.stringify(scene.source ?? SOURCE);
// `source` is the third key, before any element: the first match is the file's own.
return `${json.replace(/"source": "(?:[^"\\]|\\.)*"/, `"source": ${source}`)}\n`;
}
/** What is saved changes iff this does: pointer moves, zoom and selection don't. */
export function sceneSignature(scene: Scene): string {
const elements = getNonDeletedElements(scene.elements);
const a = scene.appState;
const files = Object.keys(scene.files).sort().join(",");
return [
getSceneVersion(elements),
elements.length,
a.viewBackgroundColor,
a.gridSize,
a.gridStep,
a.gridModeEnabled,
files,
].join(":");
}
export const codec: SceneCodec<Scene> = { parse: parseScene, serialize: serializeScene, signature: sceneSignature };
/** Shows a scene the text changed to, keeping the view (scroll, zoom) and the canvas's undo history. */
export function applyScene(api: ExcalidrawImperativeAPI, scene: Scene) {
const files = Object.values(scene.files);
if (files.length) api.addFiles(files);
const { viewBackgroundColor, gridSize, gridStep, gridModeEnabled } = scene.appState;
const appState: Pick<AppState, "viewBackgroundColor" | "gridSize" | "gridStep" | "gridModeEnabled"> = {
viewBackgroundColor: viewBackgroundColor ?? "#ffffff",
gridSize: gridSize ?? 20,
gridStep: gridStep ?? 5,
gridModeEnabled: gridModeEnabled ?? false,
};
api.updateScene({ elements: scene.elements, appState, captureUpdate: CaptureUpdateAction.NEVER });
void loadSceneFonts(api);
}
/**
* Excalidraw loads the fonts of the scene it starts with, not of scenes set
* later, and WebKit's canvas doesn't load a font by drawing with it: load
* them, then redraw text (new element objects drop Excalidraw's cached text
* canvases) without recording a change.
*/
export async function loadSceneFonts(api: ExcalidrawImperativeAPI) {
const fonts = new Set<string>();
for (const el of api.getSceneElements()) {
if (el.type === "text") fonts.add(`${el.fontSize}px ${fontStack(el.fontFamily)}`);
}
if (!fonts.size || !document.fonts?.load) return;
await Promise.allSettled([...fonts].map((f) => document.fonts.load(f)));
api.updateScene({
elements: api.getSceneElementsIncludingDeleted().map((el) => ({ ...el })),
captureUpdate: CaptureUpdateAction.NEVER,
});
}
/** Excalidraw's font family ids (`FONT_FAMILY`) → their CSS stack. */
function fontStack(id: number): string {
const names: Record<number, string> = {
1: "Virgil",
2: "Helvetica",
3: "Cascadia",
5: "Excalifont, Xiaolai",
6: "Nunito",
7: "Lilita One",
8: "Comic Shanns",
9: "Liberation Sans",
};
return `${names[id] ?? "Excalifont, Xiaolai"}, Segoe UI Emoji`;
}
/** What exports are called: the file's name without its extension. */
export function exportName(file: string): string {
return file.replace(/\.excalidraw(\.json)?$/i, "") || "drawing";
}
+107
Переглянути файл
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { DocumentSync, type SceneCodec, type SyncPort } from "./sync";
/** Scenes are plain strings here: the text is the scene. */
const codec: SceneCodec<string> = {
parse: (text) => (text.startsWith("!") ? { error: "bad" } : { scene: text }),
serialize: (scene) => scene,
signature: (scene) => scene,
};
function port(initial: string) {
let version = 0;
let text = initial;
let listener: (s: { version: number; text: string }) => void = () => {};
const p = {
get: vi.fn(async () => ({ version, text, readOnly: false, name: "a.excalidraw" })),
replace: vi.fn(async (at: number, next: string) => {
if (at !== version) throw new Error("stale");
text = next;
return { version: ++version };
}),
onChange: (fn: typeof listener) => void (listener = fn),
/** An edit made in the text editor. */
external(next: string) {
text = next;
listener({ version: ++version, text });
},
text: () => text,
} satisfies SyncPort & Record<string, unknown>;
return p;
}
const hooks = () => ({ apply: vi.fn(), error: vi.fn() });
describe("DocumentSync", () => {
it("starts from the document and writes canvas changes back, once per saved change", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
expect(await sync.start()).toEqual({ scene: "a", error: null, readOnly: false, name: "a.excalidraw" });
sync.changed("a");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
sync.changed("ab");
sync.changed("abc");
await sync.flush();
expect(p.replace).toHaveBeenCalledTimes(1);
expect(p.text()).toBe("abc");
});
it("shows edits made elsewhere and doesn't write them back", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
p.external("from the text editor");
expect(h.apply).toHaveBeenCalledWith("from the text editor");
sync.changed("from the text editor");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
// The next canvas edit is based on the new version.
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("canvas");
});
it("lets the document win a race: a stale write is dropped and the canvas takes the text", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
sync.changed("canvas");
// The document moved on without the sync hearing of it yet.
p.replace.mockImplementationOnce(async () => {
throw new Error("stale");
});
await sync.flush();
expect(p.get).toHaveBeenCalledTimes(2);
expect(p.text()).toBe("a");
});
it("never overwrites text that doesn't parse, and resumes when it does", async () => {
const p = port("a");
const h = hooks();
const sync = new DocumentSync(p, codec, h, 0);
await sync.start();
p.external("!broken");
expect(h.error).toHaveBeenLastCalledWith("bad");
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("!broken");
p.external("fixed");
expect(h.error).toHaveBeenLastCalledWith(null);
sync.changed("canvas");
await sync.flush();
expect(p.text()).toBe("canvas");
});
it("starts read-only and invalid documents without writing", async () => {
const p = port("!broken");
const sync = new DocumentSync(p, codec, hooks(), 0);
expect(await sync.start()).toEqual({ scene: null, error: "bad", readOnly: false, name: "a.excalidraw" });
sync.changed("x");
await sync.flush();
expect(p.replace).not.toHaveBeenCalled();
});
});
+128
Переглянути файл
@@ -0,0 +1,128 @@
/**
* Keeps the canvas and the file's text in step through the IDE's document
* bridge. The text is the truth: the canvas starts from it, its edits are
* written back (debounced, versioned) and edits made elsewhere (the text
* editor, Undo, another window) replace what the canvas shows.
*
* - Canvas changes that don't change what is saved (pointer moves, zoom,
* selection) write nothing: they're compared by `signature`.
* - A write that lost a race (`stale`) is dropped and the canvas takes the
* document's text.
* - Text that doesn't parse is never overwritten: the canvas stops writing
* until the text is valid again.
*/
export interface SyncPort {
get(): Promise<{ version: number; text: string; readOnly: boolean; name?: string }>;
replace(version: number, text: string): Promise<{ version: number }>;
onChange(fn: (state: { version: number; text: string }) => void): void;
}
export interface SceneCodec<S> {
parse(text: string): { scene: S } | { error: string };
serialize(scene: S): string;
/** Equal for scenes that save the same. */
signature(scene: S): string;
}
export interface SyncHooks<S> {
/** Show a scene the document changed to. */
apply(scene: S): void;
/** The document's text doesn't parse (`null`: it does again). */
error(message: string | null): void;
}
export class DocumentSync<S> {
private version = 0;
private text = "";
private readOnly = false;
private valid = true;
private signature: string | null = null;
private pending: S | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
private writing: Promise<void> | null = null;
constructor(
private readonly port: SyncPort,
private readonly codec: SceneCodec<S>,
private readonly hooks: SyncHooks<S>,
private readonly delayMs = 250,
) {}
/** The document's scene to start from. */
async start(): Promise<{ scene: S | null; error: string | null; readOnly: boolean; name: string }> {
const doc = await this.port.get();
this.version = doc.version;
this.text = doc.text;
this.readOnly = doc.readOnly;
this.port.onChange((state) => this.external(state));
const parsed = this.codec.parse(doc.text);
if ("error" in parsed) {
this.valid = false;
return { scene: null, error: parsed.error, readOnly: doc.readOnly, name: doc.name ?? "" };
}
this.signature = this.codec.signature(parsed.scene);
return { scene: parsed.scene, error: null, readOnly: doc.readOnly, name: doc.name ?? "" };
}
/** The canvas changed. */
changed(scene: S) {
if (this.readOnly || !this.valid) return;
const signature = this.codec.signature(scene);
if (signature === this.signature) return;
this.signature = signature;
this.pending = scene;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
void this.flush();
}, this.delayMs);
}
/** Writes what is pending now (also waits for a write in flight). */
async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
while (this.writing) await this.writing;
const scene = this.pending;
this.pending = null;
if (scene === null) return;
const text = this.codec.serialize(scene);
if (text === this.text) return;
this.writing = (async () => {
try {
const { version } = await this.port.replace(this.version, text);
this.version = version;
this.text = text;
} catch (e) {
if (!(e instanceof Error && e.message === "stale")) throw e;
// Someone else changed it first: theirs wins.
this.external(await this.port.get());
}
})().finally(() => {
this.writing = null;
});
await this.writing;
}
private external(state: { version: number; text: string }) {
this.version = state.version;
if (state.text === this.text) return;
this.text = state.text;
// The document moved on: what the canvas had pending is based on the old text.
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pending = null;
const parsed = this.codec.parse(state.text);
if ("error" in parsed) {
this.valid = false;
this.hooks.error(parsed.error);
return;
}
this.valid = true;
this.signature = this.codec.signature(parsed.scene);
this.hooks.error(null);
this.hooks.apply(parsed.scene);
}
}
+10
Переглянути файл
@@ -0,0 +1,10 @@
/**
* jsdom has no canvas: Excalidraw probes a 2D context when it loads (and
* measures text with it). A context whose methods do nothing is enough for
* its data API.
*/
const context = new Proxy(
{ filter: "none", measureText: (text: string) => ({ width: text.length * 8 }) } as Record<string | symbol, unknown>,
{ get: (target, key) => (key in target ? target[key] : () => undefined) },
);
HTMLCanvasElement.prototype.getContext = (() => context) as unknown as HTMLCanvasElement["getContext"];
+17
Переглянути файл
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}
+48
Переглянути файл
@@ -0,0 +1,48 @@
/**
* Builds the page into ../web (not checked in: plugins/build.sh builds it).
* Relative URLs throughout: the IDE serves the page from
* the plugin package under its own URL scheme. Excalidraw's fonts are copied
* next to it; the page points Excalidraw at them (src/assets.ts).
*/
import { cpSync, existsSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import react from "@vitejs/plugin-react";
import type { Plugin } from "vite";
import { defineConfig } from "vitest/config";
const require = createRequire(import.meta.url);
const outDir = path.resolve(import.meta.dirname, "../web");
function excalidrawFonts(): Plugin {
return {
name: "openidea:excalidraw-fonts",
apply: "build",
closeBundle() {
const fonts = path.resolve(path.dirname(require.resolve("@excalidraw/excalidraw")), "fonts");
if (!existsSync(fonts)) throw new Error(`no Excalidraw fonts at ${fonts}`);
rmSync(path.join(outDir, "fonts"), { recursive: true, force: true });
cpSync(fonts, path.join(outDir, "fonts"), { recursive: true });
},
};
}
export default defineConfig({
base: "./",
plugins: [react(), excalidrawFonts()],
define: { "process.env.IS_PREACT": JSON.stringify("false") },
build: {
outDir,
emptyOutDir: true,
target: "es2022",
sourcemap: false,
assetsInlineLimit: 0,
chunkSizeWarningLimit: 4096,
},
test: {
// Excalidraw needs a DOM even for its data API; its packages ship JSON and CSS imports Node can't load.
environment: "jsdom",
setupFiles: ["./src/test-setup.ts"],
server: { deps: { inline: [/@excalidraw/, /open-color/] } },
},
});
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-git-toolbox"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-git-toolbox"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083
+26
Переглянути файл
@@ -0,0 +1,26 @@
id = "openidea.git-toolbox"
name = "Git Toolbox"
version = "0.1.0"
description = "Fetches your repositories in the background and shows the branch with how far it is ahead of and behind its upstream in the status bar."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# Fetches on schedule (also after being reclaimed while idle) and refreshes
# the status bar when the focused editor or a repository changes.
activation = ["on-schedule:300", "on-event:editor-focused", "on-event:vcs-changed", "on-command:*"]
[capabilities]
# `vcs.write` runs `git fetch` (only in trusted projects).
required = ["project.read", "vcs.read", "vcs.write", "events", "storage"]
[[contributes.commands]]
id = "fetch"
title = "Git Toolbox: Fetch Now"
category = "Git Toolbox"
[[contributes.commands]]
id = "refresh"
title = "Git Toolbox: Refresh Status"
category = "Git Toolbox"
+242
Переглянути файл
@@ -0,0 +1,242 @@
//! Git Toolbox for OpenIDEA: background fetch of every open project's
//! repository (on the plugin's `on-schedule`, which also fires after the
//! instance was reclaimed while idle), and the focused project's branch with
//! its ahead/behind counts in the status bar.
use openidea_plugin::host::{self, storage};
use openidea_plugin::{
Error, Event, Invocation, NotificationLevel, Plugin, Project, RepositoryStatus, StatusItem, register_plugin,
};
const ITEM: &str = "branch";
const LAST_FETCH: &str = "last-fetch:";
/// Root of the project shown in the status bar.
const SHOWN: &str = "shown-root";
#[derive(Default)]
pub struct GitToolbox;
fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// `main ↑1 ↓2`; a detached HEAD shows its commit.
pub fn label(s: &RepositoryStatus) -> String {
let mut out = match (&s.branch, &s.commit) {
(Some(b), _) => b.clone(),
(None, Some(c)) => c.chars().take(8).collect(),
(None, None) => "(no commits)".to_owned(),
};
if s.upstream.is_some() {
if s.ahead > 0 {
out.push_str(&format!(" ↑{}", s.ahead));
}
if s.behind > 0 {
out.push_str(&format!(" ↓{}", s.behind));
}
}
if let Some(op) = &s.operation {
out.push_str(&format!(" ({op})"));
}
out
}
fn ago(secs: u64) -> String {
match secs {
0..60 => "just now".into(),
60..3600 => format!("{} min ago", secs / 60),
_ => format!("{} h ago", secs / 3600),
}
}
fn tooltip(s: &RepositoryStatus) -> String {
let mut lines = vec![s.root.clone()];
match &s.upstream {
Some(up) => lines.push(format!("{} ahead, {} behind {up}", s.ahead, s.behind)),
None => lines.push("No upstream branch".into()),
}
if s.changed_files > 0 {
lines.push(format!("{} changed files", s.changed_files));
}
let last = storage::get(&format!("{LAST_FETCH}{}", s.root)).ok().flatten().and_then(|v| v.parse::<u64>().ok());
lines.push(match last {
Some(t) => format!("Fetched {}", ago(now_secs().saturating_sub(t))),
None => "Not fetched yet".into(),
});
lines.push("Click to fetch now".into());
lines.join("\n")
}
/// Shows `project`'s repository in the status bar (or hides the item).
fn show(project: &Project) -> Result<(), Error> {
match project.vcs_status()? {
Some(s) => {
let _ = storage::set(SHOWN, &project.root()?);
host::set_status_item(
ITEM,
&StatusItem { text: label(&s), tooltip: Some(tooltip(&s)), command: Some("fetch".into()) },
);
}
None => host::remove_status_item(ITEM),
}
Ok(())
}
/// The project the status bar follows: the invoking window's, else the one
/// shown last, else the first open one.
fn current(ctx: &Invocation) -> Option<Project> {
if let Some(p) = ctx.project() {
return Some(p);
}
let projects = host::projects().ok()?;
let shown = storage::get(SHOWN).ok().flatten();
let index = projects.iter().position(|p| p.root().ok() == shown).unwrap_or(0);
projects.into_iter().nth(index)
}
/// Fetches `projects` in one cancellable task. Returns how many were fetched.
fn fetch(projects: &[Project]) -> Result<usize, Error> {
let task = host::begin_task("Fetching", true);
let total = projects.len() as u32;
let mut fetched = 0;
let mut last_error = None;
for (i, p) in projects.iter().enumerate() {
if task.is_cancelled() {
return Err(Error::Cancelled);
}
let root = p.root()?;
let name = root.rsplit(['/', '\\']).next().unwrap_or(&root).to_owned();
task.report(i as u32, Some(total), Some(&name));
if p.vcs_status()?.is_none() {
continue;
}
match p.vcs_fetch() {
Ok(()) => {
fetched += 1;
let _ = storage::set(&format!("{LAST_FETCH}{root}"), &now_secs().to_string());
}
Err(Error::Cancelled) => return Err(Error::Cancelled),
Err(e) => {
host::log(openidea_plugin::LogLevel::Warn, &format!("fetch {root}: {e}"));
last_error = Some(e);
}
}
}
task.report(total, Some(total), None);
match last_error {
Some(e) if fetched == 0 => Err(e),
_ => Ok(fetched),
}
}
impl Plugin for GitToolbox {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
let project = current(ctx).ok_or("no project is open")?;
match id {
"fetch" => {
let n = fetch(std::slice::from_ref(&project))?;
show(&project)?;
Ok((n == 0).then(|| "The project is not in a git repository.".to_owned()))
}
"refresh" => {
show(&project)?;
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], ctx: &Invocation) {
if events.contains(&Event::Scheduled) {
let projects = host::projects().unwrap_or_default();
if let Err(e) = fetch(&projects)
&& e != Error::Cancelled
{
host::notify(NotificationLevel::Warning, "Git Toolbox: fetch failed", &e.to_string());
}
}
if let Some(p) = current(ctx) {
let _ = show(&p);
}
}
}
register_plugin!(GitToolbox);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
fn status(ahead: u32, behind: u32) -> RepositoryStatus {
RepositoryStatus {
root: "/project".into(),
branch: Some("main".into()),
commit: Some("0123456789abcdef".into()),
upstream: Some("origin/main".into()),
ahead,
behind,
..RepositoryStatus::default()
}
}
#[test]
fn labels() {
assert_eq!(label(&status(0, 0)), "main");
assert_eq!(label(&status(1, 2)), "main ↑1 ↓2");
let detached = RepositoryStatus { branch: None, ..status(0, 0) };
assert_eq!(label(&detached), "01234567");
let rebasing = RepositoryStatus { operation: Some("rebase".into()), ..status(0, 3) };
assert_eq!(label(&rebasing), "main ↓3 (rebase)");
}
#[test]
fn scheduled_fetch_updates_the_status_bar() {
let host = MockHost::install();
host.add_project("/other");
host.set_vcs_status(Some(status(0, 4)));
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.fetches(), 2, "every open project");
let item = &host.status_items()["branch"];
assert_eq!(item.text, "main ↓4");
assert!(item.tooltip.as_deref().unwrap().contains("Fetched just now"));
assert_eq!(item.command.as_deref(), Some("fetch"));
let task = &host.tasks()[0];
assert!(task.ended && task.reports.last() == Some(&(2, Some(2), None)));
}
#[test]
fn focus_changes_only_refresh() {
let host = MockHost::install();
host.set_vcs_status(Some(status(1, 0)));
GitToolbox.handle_events(&[Event::EditorFocused(Some("/project/a.rs".into()))], &host.invocation());
assert_eq!(host.fetches(), 0);
assert_eq!(host.status_items()["branch"].text, "main ↑1");
host.set_vcs_status(None);
GitToolbox.handle_events(&[Event::VcsChanged("/project".into())], &host.invocation());
assert!(host.status_items().is_empty(), "not a repository any more");
}
#[test]
fn failures_and_cancellation() {
let host = MockHost::install();
host.set_vcs_status(Some(status(0, 0)));
host.fail_fetch(Some(Error::Failed("no network".into())));
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications()[0].body, "no network");
let err = GitToolbox.run_command("fetch", &host.invocation(), None).unwrap_err();
assert_eq!(err, Error::Failed("no network".into()));
host.fail_fetch(Some(Error::Cancelled));
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert_eq!(host.notifications().len(), 1, "a cancelled fetch is not an error to report");
}
#[test]
fn without_vcs_write_it_still_shows_the_branch() {
let host = MockHost::install().with_capabilities(&["project.read", "vcs.read", "storage"]);
host.set_vcs_status(Some(status(0, 1)));
GitToolbox.handle_events(&[Event::Scheduled], &host.invocation());
assert!(host.notifications()[0].body.contains("vcs.write"));
assert_eq!(host.status_items()["branch"].text, "main ↓1");
}
}
+4
Переглянути файл
@@ -0,0 +1,4 @@
web-src/node_modules/
# Built by plugins/build.sh, not checked in.
web/
web.sources
+334
Переглянути файл
@@ -0,0 +1,334 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-graphviz"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+22
Переглянути файл
@@ -0,0 +1,22 @@
[package]
name = "openidea-plugin-openidea-graphviz"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+28
Переглянути файл
@@ -0,0 +1,28 @@
# Third-party software
The page in `web/` is built from `web-src/` and bundles:
- **Viz.js** (`@viz-js/viz` 3.31.0), MIT License, Copyright (c) Michael Daines —
https://github.com/mdaines/viz-js
- **Graphviz** 16.1.0, compiled to WebAssembly inside Viz.js, Eclipse Public License 1.0 —
https://graphviz.org, https://gitlab.com/graphviz/graphviz
The MIT License:
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The Eclipse Public License 1.0 is at https://www.eclipse.org/legal/epl-v10.html. Graphviz's
source code is available from the links above.
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
+6
Переглянути файл
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5.2 5.6 7 10M10.8 5.6 9 10M6 4h4" stroke="#3574F0" stroke-width="1.1" stroke-linecap="round"/>
<circle cx="4" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
<circle cx="12" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
<circle cx="8" cy="12" r="2.5" fill="#3574F0" stroke="#3574F0"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 410 B

+6
Переглянути файл
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5.2 5.6 7 10M10.8 5.6 9 10M6 4h4" stroke="#548AF7" stroke-width="1.1" stroke-linecap="round"/>
<circle cx="4" cy="4" r="2.5" fill="#25324D" stroke="#548AF7"/>
<circle cx="12" cy="4" r="2.5" fill="#25324D" stroke="#548AF7"/>
<circle cx="8" cy="12" r="2.5" fill="#548AF7" stroke="#548AF7"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 410 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
id = "dot"
name = "DOT"
extensions = ["dot", "gv"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
# No DOT mode in CodeMirror: C's highlights the strings, comments, numbers and braces.
ui-mode = "C"
+38
Переглянути файл
@@ -0,0 +1,38 @@
id = "openidea.graphviz"
name = "Graphviz"
version = "0.1.0"
description = "Graphviz DOT files (.dot, .gv) with a live preview beside the text: rendered as you type with any layout engine, syntax errors in the editor and the Problems view, export to SVG and PNG."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:preview"]
[capabilities]
# Problems: published for the project's file (project.read), on the lines
# Graphviz names (editor.read: the previewed text).
required = ["editor.read", "project.read"]
[[contributes.customEditors]]
id = "preview"
title = "Preview"
patterns = ["*.dot", "*.gv"]
priority = "default"
layout = "split"
web = "web/index.html"
[[contributes.languages]]
path = "languages/dot.toml"
[[contributes.fileIcons]]
id = "graphviz"
patterns = ["*.dot", "*.gv"]
icon = "icons/graphviz.svg"
iconDark = "icons/graphviz_dark.svg"
[[contributes.fileTemplates]]
id = "graph"
name = "Graphviz Graph"
extension = "dot"
path = "templates/graph.dot"
+141
Переглянути файл
@@ -0,0 +1,141 @@
//! Graphviz for OpenIDEA: `.dot` / `.gv` files open in the text editor with a
//! live preview beside them. The preview is a web page (`web-src/`: viz.js,
//! Graphviz compiled to WebAssembly) that renders the document as it changes;
//! what Graphviz reports it sends here, and the plugin publishes it as the
//! file's problems, on the line Graphviz names.
use openidea_plugin::views::{View, ViewEvent, Viewport, Views};
use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Position, Range, Severity, host, register_plugin};
use serde_json::Value;
pub const VIEW: &str = "preview";
/// Problems published for one file at most.
const MAX_PROBLEMS: usize = 100;
/// One message from Graphviz, as the page reports it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Report {
/// 1-based, when Graphviz names one.
pub line: Option<u32>,
pub message: String,
pub error: bool,
}
/// The page's `{"op": "problems", "items": [{"line", "message", "level"}]}`.
pub fn parse_reports(message: &str) -> Option<Vec<Report>> {
let v: Value = serde_json::from_str(message).ok()?;
if v.get("op").and_then(Value::as_str) != Some("problems") {
return None;
}
let items = v.get("items")?.as_array()?;
Some(
items
.iter()
.take(MAX_PROBLEMS)
.filter_map(|i| {
Some(Report {
line: i.get("line").and_then(Value::as_u64).and_then(|l| u32::try_from(l).ok()),
message: i.get("message")?.as_str()?.trim().to_owned(),
error: i.get("level").and_then(Value::as_str) != Some("warning"),
})
})
.collect(),
)
}
/// `reports` as diagnostics of `text`: on the named line (clamped to the
/// document), the whole of it; without a line, on the first.
pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
let lines: Vec<&str> = text.lines().collect();
reports
.iter()
.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()),
}
})
.collect()
}
#[derive(Default)]
pub struct Graphviz;
impl Plugin for Graphviz {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for Graphviz {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
// The preview is the plugin's web page; nothing is rendered here.
Ok(View::message("The preview is shown by the plugin's page."))
}
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
let ViewEvent::Message(message) = event else { return Ok(()) };
let Some(reports) = parse_reports(&message) else { return Ok(()) };
let editor = ctx.editor().ok_or_else(|| Error::Invalid("the preview needs its document".into()))?;
let text = editor.document()?.text;
// A file outside the open projects has no problems to publish to; its
// preview shows what went wrong anyway.
match host::publish_diagnostics(&editor.path(), &diagnostics(&text, &reports)) {
Err(Error::Invalid(_)) => Ok(()),
other => other,
}
}
}
register_plugin!(Graphviz, views);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn reports_become_problems_on_their_line() {
let msg = r#"{"op":"problems","items":[
{"line":2,"message":"syntax error in line 2 near '}'","level":"error"},
{"line":null,"message":"Warning: no such engine","level":"warning"},
{"line":99,"message":"past the end"},
{"nope":1}
]}"#;
let reports = parse_reports(msg).unwrap();
assert_eq!(reports.len(), 3);
let d = diagnostics("digraph {\n a -> }\n", &reports);
assert_eq!(
(d[0].range, d[0].severity),
(Range::new(Position::new(1, 0), Position::new(1, 8)), Severity::Error)
);
assert_eq!((d[1].range.start.line, d[1].severity), (0, Severity::Warning));
assert_eq!(d[2].range.start.line, 1, "clamped to the last line");
assert!(parse_reports(r#"{"op":"other"}"#).is_none());
assert!(parse_reports("garbage").is_none());
assert!(diagnostics("", &reports).iter().all(|d| d.range.start.line == 0));
}
#[test]
fn publishes_for_the_previewed_file_and_clears() {
let host = MockHost::install().with_capabilities(&["editor.read", "project.read"]);
host.open_editor("/project/a.dot", "dot", "digraph {\n a -> \n}\n", &[(0, 0, 0, 0)]);
let mut p = Graphviz;
let msg =
r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#;
p.handle(VIEW, ViewEvent::Message(msg.into()), &host.invocation()).unwrap();
assert_eq!(host.diagnostics()["/project/a.dot"][0].range.start.line, 2);
p.handle(VIEW, ViewEvent::Message(r#"{"op":"problems","items":[]}"#.into()), &host.invocation()).unwrap();
assert!(host.diagnostics().get("/project/a.dot").is_none_or(Vec::is_empty));
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
digraph G {
rankdir=LR;
node [shape=box, style=rounded];
start [label="Start"];
work [label="Do the work"];
done [label="Done", shape=doublecircle];
start -> work -> done;
work -> work [label="again"];
}
+25
Переглянути файл
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Graphviz</title>
<link rel="stylesheet" href="./src/style.css" />
</head>
<body>
<div class="toolbar" role="toolbar" aria-label="Preview">
<select id="engine" title="Layout engine" aria-label="Layout engine"></select>
<span class="sep"></span>
<button id="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
<button id="zoom-reset" title="Actual size" aria-label="Actual size"><span id="zoom-label">100%</span></button>
<button id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
<button id="zoom-fit" title="Fit to window" aria-label="Fit to window">Fit</button>
<span class="sep"></span>
<button id="export-svg" title="Export as SVG…">SVG</button>
<button id="export-png" title="Export as PNG…">PNG</button>
</div>
<div id="stage" class="stage"><div id="canvas" class="canvas"></div></div>
<div id="banner" class="banner" role="status" hidden></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
+20
Переглянути файл
@@ -0,0 +1,20 @@
{
"name": "openidea-plugin-graphviz-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "The Graphviz plugin's preview page; `pnpm build` writes it to ../web.",
"scripts": {
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"@viz-js/viz": "3.31.0"
},
"devDependencies": {
"@types/node": "^26.6.6",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^5.0.5"
}
}
+669
Переглянути файл
@@ -0,0 +1,669 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@viz-js/viz':
specifier: 3.31.0
version: 3.31.0
devDependencies:
'@types/node':
specifier: ^26.6.6
version: 26.6.6
typescript:
specifier: ~6.0.3
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.3.5(@types/[email protected])
vitest:
specifier: ^5.0.5
version: 5.0.5(@types/[email protected])([email protected](@types/[email protected]))
packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxc-project/[email protected]':
resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==}
'@rolldown/[email protected]':
resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected]':
resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected]':
resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected]':
resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected]':
resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected]':
resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected]':
resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected]':
resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected]':
resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected]':
resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected]':
resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/[email protected]':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/[email protected]':
resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==}
'@types/[email protected]':
resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==}
'@vitest/[email protected]':
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
'@viz-js/[email protected]':
resolution: {integrity: sha512-r7zlQdRvcwvIpjHGgs+KNWHeE/P5/Dq7k8ZAKaMCbZqomxCBJV78gVuQYaHzFVca+kB0mX0fMW9UFevCOBG50A==}
[email protected]:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
[email protected]:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
[email protected]:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
[email protected]:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
[email protected]:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
[email protected]:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
[email protected]:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
[email protected]:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
[email protected]:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
[email protected]:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
[email protected]:
resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==}
[email protected]:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
[email protected]:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
[email protected]:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
[email protected]:
resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==}
engines: {node: '>=20.0.0'}
[email protected]:
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
[email protected]:
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
[email protected]:
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.7.1 || ^0.8.0
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
[email protected]:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.5
'@vitest/browser-preview': 5.0.5
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.5
'@vitest/coverage-v8': 5.0.5
'@vitest/ui': 5.0.5
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
[email protected]:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
hasBin: true
snapshots:
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@oxc-project/[email protected]': {}
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 8.9.0
'@vitest/[email protected]([email protected](@types/[email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.5
estree-walker: 3.0.3
magic-string: 1.4.5
optionalDependencies:
vite: 8.3.5(@types/[email protected])
'@vitest/[email protected]': {}
'@viz-js/[email protected]': {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@types/estree': 1.0.10
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
picomatch: 4.0.7
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
optional: true
[email protected]:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
[email protected]:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.2
[email protected]:
dependencies:
'@oxc-project/types': 0.156.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.16
'@rolldown/binding-android-arm64': 1.2.16
'@rolldown/binding-darwin-arm64': 1.2.16
'@rolldown/binding-darwin-x64': 1.2.16
'@rolldown/binding-freebsd-x64': 1.2.16
'@rolldown/binding-linux-arm-gnueabihf': 1.2.16
'@rolldown/binding-linux-arm64-gnu': 1.2.16
'@rolldown/binding-linux-arm64-musl': 1.2.16
'@rolldown/binding-linux-ppc64-gnu': 1.2.16
'@rolldown/binding-linux-s390x-gnu': 1.2.16
'@rolldown/binding-linux-x64-gnu': 1.2.16
'@rolldown/binding-linux-x64-musl': 1.2.16
'@rolldown/binding-openharmony-arm64': 1.2.16
'@rolldown/binding-win32-arm64-msvc': 1.2.16
'@rolldown/binding-win32-x64-msvc': 1.2.16
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
fdir: 6.5.0([email protected])
picomatch: 4.0.7
[email protected]: {}
[email protected]: {}
[email protected](@types/[email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.32
rolldown: 1.2.16
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.6
fsevents: 2.3.3
[email protected](@types/[email protected])([email protected](@types/[email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.5([email protected](@types/[email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.4.5
obug: 3.0.0
picomatch: 4.0.7
std-env: 4.3.0
tinybench: 6.2.3
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.5(@types/[email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 26.6.6
transitivePeerDependencies:
- msw
[email protected]: {}
+213
Переглянути файл
@@ -0,0 +1,213 @@
/**
* The Graphviz plugin's preview: renders the `.dot` file it is opened beside
* (read only: the text editor next to it is where it changes) with viz.js,
* follows the IDE theme, zooms and pans, exports SVG and PNG through the
* IDE's save dialog, and sends what Graphviz reports to the plugin's code,
* which turns it into the file's problems.
*/
import { instance, type Viz } from "@viz-js/viz";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
const RENDER_DELAY_MS = 150;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** The most Fit enlarges a small graph to (opening a file never enlarges it). */
const MAX_FIT = 4;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
function start(bridge: OpenideaBridge) {
const stage = $<HTMLDivElement>("stage");
const canvas = $<HTMLDivElement>("canvas");
const banner = $<HTMLDivElement>("banner");
const engineSelect = $<HTMLSelectElement>("engine");
const zoomLabel = $<HTMLSpanElement>("zoom-label");
for (const e of ENGINES) engineSelect.add(new Option(e, e));
let viz: Viz | null = null;
let text = "";
let name = "graph.dot";
let engine: Engine = "dot";
let zoom = 1;
let fitted = true;
// How far fitting may enlarge: 1 until the user asks for Fit.
let fitCap = 1;
let timer = 0;
let lastProblems = "";
const foreground = () => bridge.theme?.vars["--openidea-fg"] ?? null;
function setZoom(z: number, keepFit = false) {
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
fitted = keepFit;
// `zoom`, not a transform: the scroll area follows the scaled size.
canvas.style.zoom = String(zoom);
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
}
function fit() {
const svg = canvas.querySelector("svg");
if (!svg) return;
// Graphviz sizes its SVG in points; these are CSS pixels at 100%.
const w = svg.width.baseVal.value;
const h = svg.height.baseVal.value;
if (!w || !h) return;
// The canvas's padding (16px a side) scales with it; a pixel spare for rounding.
const pad = 32;
const z = Math.min((stage.clientWidth - 1) / (w + pad), (stage.clientHeight - 1) / (h + pad), fitCap);
setZoom(z > 0 && Number.isFinite(z) ? z : 1, true);
}
/** What Graphviz said, to the plugin (only when it changed: each one republishes the problems). */
function report(problems: ReturnType<typeof problemsOf>) {
const key = JSON.stringify(problems);
if (key === lastProblems) return;
lastProblems = key;
bridge.postMessage(JSON.stringify({ op: "problems", items: problems }));
}
function render() {
if (!viz) return;
const result = viz.render(text, renderOptions(engine, foreground()));
const problems = problemsOf(result);
report(problems);
if (result.status === "success") {
canvas.innerHTML = result.output;
canvas.classList.remove("stale");
banner.hidden = true;
if (fitted) fit();
} else {
// The last good graph stays, dimmed, under what went wrong.
canvas.classList.add("stale");
banner.hidden = false;
banner.textContent = problems.map((p) => (p.line ? `Line ${p.line}: ${p.message}` : p.message)).join("\n");
}
}
const schedule = (delay = RENDER_DELAY_MS) => {
clearTimeout(timer);
timer = window.setTimeout(render, delay);
};
/** The graph as Graphviz draws it, without the theme's defaults. */
function exportSvg(): string | null {
if (!viz) return null;
const result = viz.render(text, renderOptions(engine, null));
return result.status === "success" ? result.output : null;
}
/** Runs an export; a failure (no dialog, the image not drawn) shows in the banner. */
async function exporting(run: () => Promise<void>) {
try {
await run();
} catch (e) {
banner.hidden = false;
banner.textContent = `Export failed: ${e instanceof Error ? e.message : String(e)}`;
}
}
async function saveSvg() {
const svg = exportSvg();
if (!svg) return;
await bridge.saveFile({
name: exportName(name, "svg"),
data: svg,
filters: [{ name: "SVG image", extensions: ["svg"] }],
});
}
async function savePng() {
const svg = exportSvg();
if (!svg) return;
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("the graph could not be drawn as an image"));
img.src = url;
});
const scale = 2;
const c = document.createElement("canvas");
c.width = Math.ceil(img.naturalWidth * scale);
c.height = Math.ceil(img.naturalHeight * scale);
const ctx = c.getContext("2d")!;
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
const png = await new Promise<Blob | null>((resolve) => c.toBlob(resolve, "image/png"));
if (!png) throw new Error("the image could not be encoded");
await bridge.saveFile({
name: exportName(name, "png"),
data: png,
filters: [{ name: "PNG image", extensions: ["png"] }],
});
} finally {
URL.revokeObjectURL(url);
}
}
engineSelect.addEventListener("change", () => {
engine = engineSelect.value as Engine;
fitted = true;
schedule(0);
});
$("zoom-in").addEventListener("click", () => setZoom(zoom * 1.25));
$("zoom-out").addEventListener("click", () => setZoom(zoom / 1.25));
$("zoom-fit").addEventListener("click", () => {
fitCap = MAX_FIT;
fit();
});
$("zoom-reset").addEventListener("click", () => setZoom(1));
$("export-svg").addEventListener("click", () => void exporting(saveSvg));
$("export-png").addEventListener("click", () => void exporting(savePng));
// ⌘/Ctrl + wheel (and pinch) zooms; the wheel alone scrolls.
stage.addEventListener(
"wheel",
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setZoom(zoom * Math.exp(-e.deltaY / 300));
},
{ passive: false },
);
// Dragging pans.
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
const [x, y, left, top] = [e.clientX, e.clientY, stage.scrollLeft, stage.scrollTop];
stage.setPointerCapture(e.pointerId);
stage.classList.add("panning");
const move = (m: PointerEvent) => {
stage.scrollLeft = left - (m.clientX - x);
stage.scrollTop = top - (m.clientY - y);
};
const up = () => {
stage.classList.remove("panning");
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerup", up);
};
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerup", up);
});
new ResizeObserver(() => fitted && fit()).observe(stage);
bridge.onThemeChange(() => schedule(0));
bridge.document.onChange((s) => {
text = s.text;
schedule();
});
void (async () => {
const [doc, v] = await Promise.all([bridge.document.get(), instance()]);
viz = v;
text = doc.text;
name = doc.name || name;
render();
})().catch((e: unknown) => {
banner.hidden = false;
banner.textContent = `The preview could not start: ${e instanceof Error ? e.message : String(e)}`;
});
}
const bridge = window.openidea;
if (bridge) start(bridge);
else document.body.textContent = "This page runs inside OpenIDEA.";
+40
Переглянути файл
@@ -0,0 +1,40 @@
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
interface OpenideaDocumentState {
version: number;
text: string;
}
interface OpenideaTextEdit {
from: number;
to: number;
insert: string;
}
interface OpenideaFileFilter {
name: string;
extensions: string[];
}
interface OpenideaBridge {
postMessage(text: string): void;
onMessage(fn: (text: string) => void): void;
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
document: {
/** `name`: the file's name, without its directory. */
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
onChange(fn: (state: OpenideaDocumentState) => void): void;
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
replace(version: number, text: string): Promise<{ version: number }>;
};
saveFile(options: {
name: string;
data: string | Uint8Array | ArrayBuffer | Blob;
filters?: OpenideaFileFilter[];
}): Promise<string | null>;
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
}
interface Window {
openidea?: OpenideaBridge;
}
+45
Переглянути файл
@@ -0,0 +1,45 @@
import { instance } from "@viz-js/viz";
import { describe, expect, it } from "vitest";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
describe("the preview's rendering", () => {
it("renders with the theme's colour as a default the graph can override", async () => {
const viz = await instance();
const themed = viz.render('digraph { a -> b; c [color="red"] }', renderOptions("dot", "rgb(200, 200, 200)"));
expect(themed.status).toBe("success");
const svg = themed.status === "success" ? themed.output : "";
expect(svg).toContain('stroke="#c8c8c8"');
expect(svg).toContain('stroke="red"');
const plain = viz.render("digraph { a -> b }", renderOptions("dot", null));
expect(plain.status === "success" && plain.output).not.toContain("#c8c8c8");
});
it("names the line of a syntax error", async () => {
const viz = await instance();
const result = viz.render("digraph {\n a -> b\n c -> }\n", renderOptions("dot", null));
expect(result.status).toBe("failure");
const [p] = problemsOf(result);
expect(p).toMatchObject({ line: 3, level: "error" });
expect(p!.message).toMatch(/syntax error/);
});
it("offers only engines this Graphviz has", async () => {
const viz = await instance();
for (const e of ENGINES) expect(viz.engines).toContain(e);
});
it("turns the theme's colours into Graphviz's", () => {
expect(graphvizColor("rgb(200, 200, 200)")).toBe("#c8c8c8");
expect(graphvizColor("rgba(0, 128, 255, 0.5)")).toBe("#0080ff80");
expect(graphvizColor("rgb(1 2 3 / 50%)")).toBe("#01020380");
expect(graphvizColor("#ABCDEF")).toBe("#abcdef");
expect(graphvizColor("var(--x)")).toBeNull();
expect(renderOptions("dot", "nonsense").nodeAttributes).toBeUndefined();
});
it("names exports after the file", () => {
expect(exportName("flow.dot", "svg")).toBe("flow.svg");
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
expect(exportName("", "svg")).toBe("graph.svg");
});
});
+68
Переглянути файл
@@ -0,0 +1,68 @@
/**
* The preview's logic apart from the page: rendering options, Graphviz's
* messages as problems, export names. Kept free of the DOM so it is tested
* against the real Graphviz in Node.
*/
import type { RenderOptions, RenderResult } from "@viz-js/viz";
/** Layout engines offered, the default first. */
export const ENGINES = ["dot", "neato", "fdp", "sfdp", "circo", "twopi", "osage", "patchwork"] as const;
export type Engine = (typeof ENGINES)[number];
export interface Problem {
/** 1-based, when Graphviz names one. */
line: number | null;
message: string;
level: "error" | "warning";
}
/**
* Defaults a graph can still override: transparent background and the IDE's
* text colour, so an unstyled graph reads in a dark theme too. Exports use
* none of them (`null`): they look as Graphviz draws them.
*/
export function renderOptions(engine: Engine, foreground: string | null): RenderOptions {
const options: RenderOptions = { format: "svg", engine };
const color = foreground && graphvizColor(foreground);
if (color) {
options.graphAttributes = { bgcolor: "transparent", color, fontcolor: color };
options.nodeAttributes = { color, fontcolor: color };
options.edgeAttributes = { color, fontcolor: color };
}
return options;
}
/**
* A CSS colour as Graphviz reads it (`#rrggbb[aa]`): the theme's variables
* are `rgb()` / `rgba()`, which it doesn't know. `null` when not one.
*/
export function graphvizColor(css: string): string | null {
const hex = (n: number) =>
Math.round(Math.min(255, Math.max(0, n)))
.toString(16)
.padStart(2, "0");
const value = css.trim();
if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) return value.toLowerCase();
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/i.exec(value);
if (!m) return null;
const alpha = m[4] === undefined ? 1 : Number(m[4]) / (m[5] ? 100 : 1);
return `#${hex(+m[1]!)}${hex(+m[2]!)}${hex(+m[3]!)}${alpha < 1 ? hex(alpha * 255) : ""}`;
}
/** What Graphviz reported, as problems (on the line it names, if any). */
export function problemsOf(result: RenderResult): Problem[] {
return result.errors.map((e) => {
const line = /\bline (\d+)\b/.exec(e.message);
return {
line: line ? Number(line[1]) : null,
message: e.message.replace(/^(Error|Warning):\s*/i, "").trim(),
level: e.level === "warning" ? "warning" : "error",
};
});
}
/** The name an export starts with: the file's, without `.dot` / `.gv`. */
export function exportName(file: string, extension: string): string {
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
return `${base}.${extension}`;
}
+89
Переглянути файл
@@ -0,0 +1,89 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
color: var(--openidea-fg);
background: var(--openidea-bg, transparent);
}
.toolbar {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--openidea-border);
border-radius: 6px;
background: var(--openidea-bg-elevated, var(--openidea-bg));
}
.toolbar button,
.toolbar select {
height: 24px;
min-width: 24px;
padding: 0 6px;
font: inherit;
font-size: 12px;
color: var(--openidea-fg);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.toolbar select {
border-color: var(--openidea-border);
background: var(--openidea-bg-input, transparent);
}
.toolbar button:hover {
background: var(--openidea-bg-hover);
}
.toolbar .sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--openidea-border);
}
#zoom-label {
display: inline-block;
min-width: 36px;
font-variant-numeric: tabular-nums;
}
.stage {
position: absolute;
inset: 0;
overflow: auto;
cursor: grab;
}
.stage.panning {
cursor: grabbing;
}
.canvas {
display: inline-block;
padding: 16px;
user-select: none;
}
.canvas svg {
display: block;
}
.canvas.stale {
opacity: 0.35;
}
.banner {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
z-index: 1;
padding: 6px 8px;
white-space: pre-wrap;
font-family: var(--openidea-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--openidea-danger);
border: 1px solid color-mix(in srgb, var(--openidea-danger) 40%, transparent);
border-radius: 6px;
background: var(--openidea-bg-elevated, var(--openidea-bg));
}
+16
Переглянути файл
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}
+18
Переглянути файл
@@ -0,0 +1,18 @@
/**
* Builds the page into ../web (not checked in: plugins/build.sh builds it).
* Relative URLs throughout: the IDE serves the page from the plugin package
* under its own URL scheme.
*/
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "./",
build: {
outDir: path.resolve(import.meta.dirname, "../web"),
emptyOutDir: true,
target: "es2022",
sourcemap: false,
chunkSizeWarningLimit: 4096,
},
});
+55
Переглянути файл
@@ -0,0 +1,55 @@
# Harbor Night
An OpenIDEA theme plugin, **Harbor Night**: a dark theme with slate surfaces, a
teal accent and a warm syntax palette. It is also where new themes start:
`openidea-plugin new --template theme` copies its theme files, or copy this
folder. Full documentation, the token reference and porting guides are in
[`docs/plugins/themes.md`](../../../docs/plugins/themes.md).
```
harbor-night/
├─ plugin.toml # manifest: id, name, version, the theme files
└─ themes/
├─ harbor-night.theme.json # UI colours, every themeable token, grouped like tokens.css
└─ harbor-night.scheme.json # editor colour scheme: syntax, editor, ANSI colours
```
A theme plugin is data only: no code, no WebAssembly, no build step.
## Make your own
1. Run `openidea-plugin new --template theme`, or copy the folder, for
example to `~/openidea-themes/my-theme`.
2. In `plugin.toml`, change `id` (lowercase, `a-z 0-9 - _ .`), `name`,
`description`, `authors` and the two paths.
3. Rename the theme files. In the theme file, set `id`, `name` and `kind`
(`"dark"` or `"light"`, the built-in base for anything you leave out).
4. Edit the colours. Each key is a CSS variable from `src/styles/tokens.css`,
and the reference in the docs gives the Islands Dark and Light values.
Comments (`//`, `/* */`) are allowed. Delete a line to keep the built-in value.
Porting an IntelliJ theme? `pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir>`
in the OpenIDEA repository generates a package in this layout.
## Package and install
The folder is the package. To share it, publish the folder as a Git repository
or a zip that users extract.
1. In OpenIDEA open **Settings › Plugins**, click the gear menu and choose
**Install Plugin from Disk…** (or run the _Install Plugin from Disk…_
action), then pick the folder that contains `plugin.toml`.
2. Select the plugin in the _Installed_ tab and press **Apply** next to the theme.
3. **Use Default Theme** in the same place goes back to the built-in theme.
## Iterate
Put (or symlink) the folder in `<project>/.openidea/plugins/` of an open
project. It loads as a _dev_ plugin, and **Reload** in Settings › Plugins (or
the _Reload Plugins_ action) re-reads your edits without reinstalling. Invalid
JSON or values are reported in Settings › Plugins with the file and line.
## Licence
Harbor Night is part of OpenIDEA (Apache-2.0). When you port
someone else's theme, keep its licence and credit its authors.
+16
Переглянути файл
@@ -0,0 +1,16 @@
# A theme plugin is data only: no `wasm`, no capabilities, nothing to compile.
# `openidea-plugin new --template theme` starts a new theme from this one.
id = "openidea.harbor-night"
name = "Harbor Night"
version = "0.1.0"
description = "A dark theme: slate surfaces, a teal accent and a warm syntax palette."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "1.0.0 — first release."
engines.openidea = "^1.0"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair).
[[contributes.themes]]
path = "themes/harbor-night.theme.json"
editor-scheme = "themes/harbor-night.scheme.json"
+120
Переглянути файл
@@ -0,0 +1,120 @@
// Harbor Night: editor colour scheme (syntax, editor surfaces, console ANSI colours),
// the counterpart of an IntelliJ .icls file. Merged over the UI theme's tokens.
{
"name": "Harbor Night",
"tokens": {
// Syntax
"--syn-keyword": "#e0a46e",
"--syn-string": "#9ccc7a",
"--syn-type": "#8fd4c9",
"--syn-function": "#f2d07a",
"--syn-function-call": "#d9dfe8",
"--syn-comment": "#6b7a8f",
"--syn-comment-style": "normal",
"--syn-doc-comment": "#74889c",
"--syn-docstring": "#8fb071",
"--syn-annotation": "#d7a0e0",
"--syn-decorator": "#d7a0e0",
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
"--syn-predefined": "#c4a0f2",
"--syn-escape": "#7fd1c4",
"--syn-regexp": "#7fd1c4",
"--syn-tag": "#e0a46e",
"--syn-attribute": "#9fc0e8",
"--syn-todo": "#7fd1c4",
"--syn-link": "#6fc3df",
"--syn-markup-heading": "#f2d07a",
"--syn-markup-code": "#9ccc7a",
"--syn-invalid": "#ff6b7a",
"--syn-inline-value": "#6b7a8f",
"--syn-rainbow-1": "#7fd1c4",
"--syn-rainbow-2": "#e0a46e",
"--syn-rainbow-3": "#b9a3f0",
"--syn-rainbow-4": "#9ccc7a",
"--syn-rainbow-5": "#e58fa3",
"--syn-bracket-1": "#f2d07a",
"--syn-bracket-2": "#d7a0e0",
"--syn-bracket-3": "#6fc3df",
// Editor
"--fg-editor": "#d9dfe8",
"--caret": "#5ce0d2",
"--fg-line-number": "#3f4d60",
"--fg-line-number-current": "#93a1b4",
"--border-gutter": "#1f252e",
"--border-indent-guide": "#2a313b",
"--border-indent-guide-active": "#424e5e",
"--border-right-margin": "#2a313b",
"--bg-editor-selection": "#2d7772",
"--bg-editor-selection-inactive": "#255854",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-matched-brace": "#38424f",
"--matched-brace-weight": "700",
"--bg-matched-tag": "#353e4a",
"--bg-fold-placeholder": "#303944",
"--fg-fold-placeholder": "#75869d",
"--fg-fold-marker": "#5f6f85",
"--fg-whitespace": "#5f6f85",
"--fg-unused": "#5f6f85",
"--bg-identifier-under-caret": "#2e353f",
"--bg-identifier-under-caret-write": "#402f33",
"--fg-inlay": "#75869d",
"--bg-inlay": "#303944",
"--wave-error": "#fa6675",
"--wave-warning": "#f2c55c",
"--editor-error-decoration": "underline wavy #fa6675",
"--editor-warning-decoration": "underline wavy #f2c55c",
"--editor-warning-background": "transparent",
"--bg-breakpoint-line": "#40252b",
"--fg-completion-match": "#68e4dc",
"--bg-editor-inline": "#1b2027",
"--bg-editor-banner": "#2a514f",
"--fg-weak-warning": "#5f6f85",
"--fg-bulb": "#f2c55c",
// ANSI palette
"--ansi-black": "#000000",
"--ansi-red": "#f0524f",
"--ansi-green": "#5c962c",
"--ansi-yellow": "#a68a0d",
"--ansi-blue": "#4cc1b9",
"--ansi-magenta": "#a771bf",
"--ansi-cyan": "#00a3a3",
"--ansi-white": "#6e7b8d",
"--ansi-bright-black": "#4c5561",
"--ansi-bright-red": "#ff4050",
"--ansi-bright-green": "#4fc414",
"--ansi-bright-yellow": "#e5bf00",
"--ansi-bright-blue": "#3be3d8",
"--ansi-bright-magenta": "#ed7eed",
"--ansi-bright-cyan": "#00e5e5",
"--ansi-bright-white": "#ffffff",
// Terminal ANSI palette
"--terminal-ansi-black": "#000000",
"--terminal-ansi-red": "#f0524f",
"--terminal-ansi-green": "#5c962c",
"--terminal-ansi-yellow": "#a68a0d",
"--terminal-ansi-blue": "#4cc1b9",
"--terminal-ansi-magenta": "#a771bf",
"--terminal-ansi-cyan": "#00a3a3",
"--terminal-ansi-white": "#6e7b8d",
"--terminal-ansi-bright-black": "#4c5561",
"--terminal-ansi-bright-red": "#ff4050",
"--terminal-ansi-bright-green": "#4fc414",
"--terminal-ansi-bright-yellow": "#e5bf00",
"--terminal-ansi-bright-blue": "#3be3d8",
"--terminal-ansi-bright-magenta": "#ed7eed",
"--terminal-ansi-bright-cyan": "#00e5e5",
"--terminal-ansi-bright-white": "#ffffff"
}
}
+147
Переглянути файл
@@ -0,0 +1,147 @@
// Harbor Night: UI theme. Keys are the CSS variables of src/styles/tokens.css,
// grouped like its Islands Light block. Delete a line to keep the built-in value
// of the base named by "kind". Comments are allowed (// and /* */).
// Full reference: docs/plugins/themes.md.
{
"id": "harbor-night",
"name": "Harbor Night",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "#14171c",
"--bg-elev": "#1b2027",
"--bg-popup": "#1f252e",
"--bg-floating-toolbar": "#1f252e",
"--bg-hover": "#262c33",
"--bg-selected": "#22484b",
"--bg-selected-strong": "#285a5d",
"--bg-current-line": "#191e25",
"--bg-debug-line": "#245a5c",
"--bg-backdrop": "#1f252e",
"--bg-input": "#14171c",
// Lines
"--border": "#1f252e",
"--border-popup": "#2a333f",
"--border-strong": "#36414f",
"--border-window": "#36414f",
"--control-off": "#515f72",
// Text
"--fg-strong": "#d9dfe6",
"--fg-emphasis": "#c9d1dc",
"--fg": "#c9d1dc",
"--fg-secondary": "#919fb1",
"--fg-muted": "#7c8ca0",
"--fg-subtle": "#637287",
"--fg-faint": "#515f72",
"--fg-gutter": "#3f4d60",
"--fg-icon": "#c6ced9",
// Accent
"--accent": "#2b9e93",
"--link": "#5cc8bd",
"--link-hover": "#8adcd3",
// Charts
"--chart-series": "#e07a7c",
// Status
"--danger": "#f57e84",
"--warning": "#d59637",
"--success": "#6db083",
"--db-connected": "#2fe02f",
"--db-connected-ring": "#1d6b1d",
"--info": "#4fb0d0",
// Vcs
"--vcs-modified": "#82ede6",
"--vcs-added": "#73bd79",
"--vcs-deleted": "#5f6f85",
"--vcs-untracked": "#e88f89",
"--vcs-ignored": "#d69a6b",
"--vcs-conflict": "#de6a66",
"--gutter-modified": "#469e98",
"--gutter-added": "#549159",
"--gutter-deleted": "#75869d",
"--diff-modified": "#2f4f63",
"--diff-inserted": "#294436",
"--diff-deleted": "#43494f",
// Shadows and window controls
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
"--shadow-dialog": "0 0 0 0.5px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.35), 0 24px 72px rgba(0, 0, 0, 0.45)",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#404955",
"--traffic-off-border": "#363e49",
// Additive
"--bg-tooltip": "#2a333f",
"--fg-tooltip": "#c9d1dc",
"--border-tooltip": "#2a333f",
"--bg-control": "#1f252e",
"--bg-control-hover": "#2a333f",
"--bg-active": "#36414f",
"--bg-scrim": "rgba(0, 0, 0, 0.45)",
"--fg-on-accent": "#ffffff",
"--shadow-tooltip": "0 4px 12px rgba(0, 0, 0, 0.4)",
"--shadow-balloon": "0 8px 24px rgba(0, 0, 0, 0.45)",
// Islands contract
"--bg-frame": "#1f252e",
"--bg-island": "#14171c",
"--bg-stripe-hover": "#ffffff17",
"--bg-stripe-selected": "#ffffff29",
"--fg-stripe-selected-active": "#ffffff",
"--fg-stripe-label": "#919fb1",
"--bg-tab-selected": "#1e3d40",
"--border-tab-selected": "#2b9e93",
"--bg-tab-selected-inactive": "#1f252e",
"--border-tab-selected-inactive": "#36414f",
"--bg-tab-hover": "#ffffff17",
"--border-editor-tabs": "#1f252e",
"--bg-tree-selected": "#22484b",
"--bg-tree-selected-inactive": "#2a333f",
"--bg-tree-hover": "#ffffff10",
"--bg-toolbar-hover": "#ffffff17",
"--bg-toolbar-pressed": "#ffffff29",
"--bg-toolbar-selected": "#285a5d",
"--bg-toolbar-selected-hover": "#2f6b6e",
"--bg-button-primary-hover": "#40a8a1",
"--fg-button-primary": "#ffffff",
"--bg-button": "transparent",
"--border-button": "#36414f",
"--bg-field": "#14171c",
"--bg-checkbox": "#14171c",
"--border-checkbox": "#515f72",
"--bg-toggle-on": "#338555",
"--fg-toggle-on": "#ffffff",
"--bg-toggle-off": "#14171c",
"--fg-toggle-off": "#637287",
"--border-toggle": "#515f72",
"--bg-toggle-knob": "#515f72",
"--focus-ring-soft": "#467c78",
"--scrollbar-thumb": "#ffffff33",
"--scrollbar-thumb-hover": "#ffffff59",
"--bg-search-match": "#ba9752",
"--bg-search-match-current": "#f2c55c",
"--fg-search-match": "#14171c",
"--bg-dialog": "#14171c",
"--bg-popup-inline": "#1f252e",
"--bg-popup-advertiser": "#1f252e",
"--bg-feedback-info": "#2a514f",
"--border-feedback-info": "#397e79",
"--bg-feedback-error": "#56272b",
"--border-feedback-error": "#80383e",
"--bg-feedback-warning": "#44321d",
"--border-feedback-warning": "#694820",
"--bg-feedback-success": "#203b2a",
"--border-feedback-success": "#29583c",
"--bg-counter": "#637287",
"--fg-counter": "#000000",
"--bg-progress-track": "#36414f"
}
}
+1
Переглянути файл
@@ -0,0 +1 @@
/target
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-key-promoter"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-key-promoter"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980
+31
Переглянути файл
@@ -0,0 +1,31 @@
id = "openidea.key-promoter"
name = "Key Promoter"
version = "0.1.0"
description = "Shows the keyboard shortcut of an action you ran with the mouse, and counts how often you could have used it."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: shortcut tips, statistics, per-action \"don't show again\"."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
# Actions arrive in batches (a few seconds apart while the plugin is idle),
# so it is not woken for every click.
activation = ["on-event:action-performed", "on-command:*"]
[capabilities]
# `events.input`: which actions you run and how (keyboard, menu, toolbar…).
required = ["events.input", "storage"]
[[contributes.commands]]
id = "statistics"
title = "Key Promoter: Show Statistics"
category = "Key Promoter"
[[contributes.commands]]
id = "reset"
title = "Key Promoter: Reset Statistics"
category = "Key Promoter"
[[contributes.commands]]
id = "suppress-last"
title = "Key Promoter: Don't Show Tips for the Last Action"
category = "Key Promoter"
+164
Переглянути файл
@@ -0,0 +1,164 @@
//! Key Promoter for OpenIDEA: when you run an action with the mouse (menu,
//! toolbar, context menu) that has a keyboard shortcut, it tells you the
//! shortcut. Counts persist in storage, so the tips survive restarts and the
//! instance being reclaimed while you work.
use openidea_plugin::host::{self, storage};
use openidea_plugin::{ActionSource, Error, Event, Invocation, NotificationLevel, Plugin, register_plugin};
/// Tips for one action are shown on these counts, then every `REPEAT_EVERY`.
const FIRST_TIPS: [u32; 3] = [1, 3, 10];
const REPEAT_EVERY: u32 = 25;
const COUNT: &str = "count:";
const TITLE: &str = "title:";
const SUPPRESSED: &str = "suppressed:";
const LAST: &str = "last-action";
#[derive(Default)]
pub struct KeyPromoter;
fn get_u32(key: &str) -> u32 {
storage::get(key).ok().flatten().and_then(|v| v.parse().ok()).unwrap_or(0)
}
impl Plugin for KeyPromoter {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"statistics" => {
let mut rows: Vec<(u32, String)> = storage::keys(COUNT)?
.into_iter()
.map(|k| {
let action = &k[COUNT.len()..];
let title = storage::get(&format!("{TITLE}{action}"))
.ok()
.flatten()
.unwrap_or_else(|| action.to_owned());
(get_u32(&k), title)
})
.collect();
rows.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
let body = if rows.is_empty() {
"No missed shortcuts yet.".to_owned()
} else {
rows.iter().take(15).map(|(n, t)| format!("{n}× {t}")).collect::<Vec<_>>().join("\n")
};
host::notify(NotificationLevel::Info, "Key Promoter: missed shortcuts", &body);
Ok(Some(body))
}
"reset" => {
for k in storage::keys("")? {
storage::delete(&k)?;
}
Ok(Some("Key Promoter statistics were reset.".into()))
}
"suppress-last" => {
let action = storage::get(LAST)?.ok_or("no tip was shown yet")?;
storage::set(&format!("{SUPPRESSED}{action}"), "1")?;
Ok(Some(format!("No more tips for {action}.")))
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
// One tip per batch at most: the last action that deserves one.
let mut tip = None;
for e in events {
let Event::ActionPerformed(a) = e else { continue };
if a.source == ActionSource::Keyboard {
continue;
}
let Some(shortcut) = &a.shortcut else { continue };
if storage::get(&format!("{SUPPRESSED}{}", a.action)).ok().flatten().is_some() {
continue;
}
let key = format!("{COUNT}{}", a.action);
let n = get_u32(&key) + 1;
if storage::set(&key, &n.to_string()).is_err() {
return; // storage denied or full: nothing to count with
}
let _ = storage::set(&format!("{TITLE}{}", a.action), &a.title);
if FIRST_TIPS.contains(&n) || n.is_multiple_of(REPEAT_EVERY) {
tip = Some((a.clone(), shortcut.clone(), n));
}
}
if let Some((a, shortcut, n)) = tip {
let _ = storage::set(LAST, &a.action);
let times = if n == 1 { "once".to_owned() } else { format!("{n} times") };
host::notify(
NotificationLevel::Info,
&format!("{}: {shortcut}", a.title),
&format!("You ran it with the mouse {times}. Press {shortcut} next time."),
);
}
}
}
register_plugin!(KeyPromoter);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::ActionPerformed;
use openidea_plugin::mock::MockHost;
fn action(id: &str, source: ActionSource, shortcut: Option<&str>) -> Event {
Event::ActionPerformed(ActionPerformed {
action: id.into(),
title: format!("Title of {id}"),
source,
shortcut: shortcut.map(str::to_owned),
})
}
#[test]
fn tips_for_mouse_use_of_actions_with_shortcuts() {
let host = MockHost::install();
let ctx = host.invocation();
let mut p = KeyPromoter;
p.handle_events(
&[
action("a", ActionSource::Keyboard, Some("Ctrl+A")),
action("b", ActionSource::Menu, None),
action("c", ActionSource::Toolbar, Some("Ctrl+C")),
],
&ctx,
);
let notes = host.notifications();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].title, "Title of c: Ctrl+C");
assert!(notes[0].body.contains("once"));
// Second use: counted, no tip (next at 3).
p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx);
assert_eq!(host.notifications().len(), 1);
p.handle_events(&[action("c", ActionSource::Menu, Some("Ctrl+C"))], &ctx);
assert!(host.notifications()[1].body.contains("3 times"));
assert_eq!(host.storage()["count:c"], "3");
}
#[test]
fn statistics_survive_a_new_instance_and_can_be_suppressed() {
let host = MockHost::install();
let ctx = host.invocation();
KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &ctx);
// A fresh instance (after idle reclaim) reads the counts back.
let stats = KeyPromoter.run_command("statistics", &ctx, None).unwrap().unwrap();
assert_eq!(stats, "1× Title of x");
KeyPromoter.run_command("suppress-last", &ctx, None).unwrap();
KeyPromoter.handle_events(&vec![action("x", ActionSource::Menu, Some("F2")); 2], &ctx);
assert_eq!(host.notifications().len(), 2, "the tip and the statistics; none after suppressing");
KeyPromoter.run_command("reset", &ctx, None).unwrap();
assert!(host.storage().is_empty());
}
#[test]
fn without_storage_it_stays_quiet() {
let host = MockHost::install().with_capabilities(&["events.input"]);
KeyPromoter.handle_events(&[action("x", ActionSource::Menu, Some("F2"))], &host.invocation());
assert!(host.notifications().is_empty());
assert_eq!(
KeyPromoter.run_command("statistics", &host.invocation(), None).unwrap_err(),
Error::Denied("storage".into())
);
}
}
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-license-header"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-license-header"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3
+35
Переглянути файл
@@ -0,0 +1,35 @@
id = "openidea.license-header"
name = "License Header"
version = "0.1.0"
description = "Keeps a copyright and SPDX license header at the top of source files: files without one are reported when they open, a quick fix adds it, and a project scan lists every file that still needs one."
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."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"]
[capabilities]
required = ["events", "project.read", "editor.read", "editor.write"]
[[contributes.providers]]
kinds = ["code-actions"]
languages = ["*"]
[[contributes.commands]]
id = "add"
title = "License Header: Add to Current File"
category = "License Header"
[[contributes.commands]]
id = "scan"
title = "License Header: Find Files Without a Header"
category = "License Header"
[[contributes.menus]]
menu = "tools"
command = "scan"
[contributes.settings]
title = "License Header"
schema = "settings.schema.json"
+21
Переглянути файл
@@ -0,0 +1,21 @@
{
"properties": {
"owner": {
"type": "string",
"title": "Copyright holder",
"description": "Written after the year, e.g. \"ACME Inc.\"; empty for none.",
"default": ""
},
"license": {
"type": "string",
"title": "License (SPDX identifier)",
"description": "e.g. Apache-2.0, MIT, GPL-3.0-or-later; empty for no SPDX line.",
"default": "Apache-2.0"
},
"reportOnOpen": {
"type": "boolean",
"title": "Report files without a header when they open",
"default": true
}
}
}
+395
Переглянути файл
@@ -0,0 +1,395 @@
//! License Header for OpenIDEA: a copyright and SPDX license header at the top
//! of source files, in the file's own comment syntax.
//!
//! - A file without one is reported (an info diagnostic on its first line)
//! when it opens, and again when it is saved, so adding the header clears it.
//! - The quick fix and *License Header: Add to Current File* insert it (after a
//! `#!` line or a Python encoding line, which must stay first).
//! - *Find Files Without a Header* scans the project in a background task and
//! reports every source file that needs one in the Problems view.
use openidea_plugin::host;
use openidea_plugin::providers::{CodeAction, Document, Providers};
use openidea_plugin::{
Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit,
register_plugin,
};
/// Lines looked at for an existing header.
const HEADER_LINES: usize = 15;
/// Files a scan reads at most.
const MAX_SCAN: u32 = 5000;
/// Files larger than this are skipped by a scan.
const MAX_FILE: usize = 1 << 20;
const CODE: &str = "missing-license-header";
/// How a file writes a header: line comments, or one block comment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Comment {
Line(&'static str),
Block(&'static str, &'static str),
}
/// The comment syntax for `path`, `None` for files that don't get a header.
pub fn comment_for(path: &str) -> Option<Comment> {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let lower = name.to_ascii_lowercase();
if matches!(lower.as_str(), "dockerfile" | "makefile" | "justfile") {
return Some(Comment::Line("#"));
}
let ext = lower.rsplit_once('.').map(|(_, e)| e)?;
Some(match ext {
"rs" | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" | "go" | "js" | "jsx" | "mjs" | "cjs" | "ts"
| "tsx" | "mts" | "cts" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" | "cs" | "swift"
| "dart" | "proto" | "sol" | "zig" | "v" => Comment::Line("//"),
"css" | "scss" | "less" => Comment::Block("/*", " */"),
"py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "r" | "ps1" | "yaml" | "yml" | "toml" | "cmake"
| "tf" | "nix" | "ex" | "exs" | "jl" => Comment::Line("#"),
"sql" | "lua" | "hs" | "elm" => Comment::Line("--"),
_ => return None,
})
}
/// Whether `text` already starts with a header (copyright or license notice).
pub fn has_header(text: &str) -> bool {
text.lines().take(HEADER_LINES).any(|l| {
let l = l.to_ascii_lowercase();
l.contains("copyright") || l.contains("spdx-license-identifier") || l.contains("licensed under")
})
}
/// The header's lines, without comment markers.
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
let owner = owner.trim();
let mut lines =
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
if !license.trim().is_empty() {
lines.push(format!("SPDX-License-Identifier: {}", license.trim()));
}
lines
}
/// The header as `comment` writes it, with a blank line after it.
pub fn render(comment: Comment, lines: &[String]) -> String {
let mut out = String::new();
match comment {
Comment::Line(prefix) => {
for l in lines {
out.push_str(&format!("{prefix} {l}\n"));
}
}
Comment::Block(open, close) => {
out.push_str(open);
out.push('\n');
for l in lines {
out.push_str(&format!(" * {l}\n"));
}
out.push_str(close);
out.push('\n');
}
}
out.push('\n');
out
}
/// The line the header goes on: after a `#!` line and a Python encoding line.
pub fn insert_line(text: &str) -> u32 {
let mut line = 0;
let mut lines = text.lines();
if lines.next().is_some_and(|l| l.starts_with("#!")) {
line = 1;
if lines.next().is_some_and(is_encoding_line) {
line = 2;
}
} else if text.lines().next().is_some_and(is_encoding_line) {
line = 1;
}
line
}
fn is_encoding_line(l: &str) -> bool {
l.starts_with('#') && (l.contains("coding:") || l.contains("coding="))
}
/// The edit that adds the header to `path`'s `text`, `None` when it has one or takes none.
pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str) -> Option<TextEdit> {
let comment = comment_for(path)?;
if has_header(text) {
return None;
}
let at = Position::new(insert_line(text), 0);
let mut insert = render(comment, &header_lines(year, owner, license));
// After a first line without a newline (a lone shebang).
if at.line > 0 && text.lines().count() == at.line as usize && !text.ends_with('\n') {
insert.insert(0, '\n');
}
Some(TextEdit { range: Range::new(at, at), text: insert })
}
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()),
}
}
/// Reports `path` when it takes a header and has none, clears it otherwise.
pub fn report(path: &str, text: &str) -> Result<bool, Error> {
let lacking = comment_for(path).is_some() && !has_header(text);
host::publish_diagnostics(path, &if lacking { vec![missing(text)] } else { Vec::new() })?;
Ok(lacking)
}
/// The current year (WASI wall clock), from days since the epoch.
fn this_year() -> i64 {
let secs =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
year_of_day(secs.div_euclid(86_400))
}
/// The proleptic Gregorian year of `day` days since 1970-01-01.
pub fn year_of_day(day: i64) -> i64 {
let z = day + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
yoe + era * 400 + i64::from(month <= 2)
}
fn settings() -> (String, String) {
(
host::setting_string("owner").unwrap_or_default(),
host::setting_string("license").unwrap_or_else(|| "Apache-2.0".into()),
)
}
fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
#[derive(Default)]
pub struct LicenseHeader;
impl LicenseHeader {
fn add_to_editor(&self, ctx: &Invocation) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?;
let (path, doc) = (editor.path(), editor.document()?);
if comment_for(&path).is_none() {
return Err(Error::Invalid("this kind of file takes no license header".into()));
}
let (owner, license) = settings();
let Some(edit) = header_edit(&path, &doc.text, this_year(), &owner, &license) else {
return Ok(Some("The file already has a license header.".into()));
};
editor.apply_edits(doc.version, &[edit])?;
Ok(None)
}
/// Reports every source file of the project without a header; returns (checked, lacking).
fn scan(&self, project: &Project) -> Result<(u32, u32), Error> {
let files: Vec<String> = project
.find_files("**/*", MAX_SCAN * 4)?
.into_iter()
.filter(|f| comment_for(f).is_some())
.take(MAX_SCAN as usize)
.collect();
let total = files.len() as u32;
let task = host::begin_task("License Header: checking files", true);
let (mut checked, mut lacking) = (0, 0);
for (i, path) in files.iter().enumerate() {
if task.is_cancelled() {
break;
}
if i % 20 == 0 {
let name = path.rsplit('/').next().unwrap_or(path);
task.report(i as u32, Some(total), Some(name));
}
let Ok(text) = project.read_file(path) else { continue };
if text.len() > MAX_FILE {
continue;
}
checked += 1;
if report(path, &text)? {
lacking += 1;
}
}
Ok((checked, lacking))
}
}
impl Plugin for LicenseHeader {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"add" => self.add_to_editor(ctx),
"scan" => {
let project = ctx.project().ok_or_else(|| Error::Invalid("open a project first".into()))?;
let (checked, lacking) = self.scan(&project)?;
let body = match lacking {
0 => format!("All {checked} source files have one."),
1 => format!("1 of {checked} source files has none; see the Problems view."),
n => format!("{n} of {checked} source files have none; see the Problems view."),
};
host::notify(NotificationLevel::Info, "License headers", &body);
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
for e in events {
let (path, opened) = match e {
Event::FileOpened(p) => (p, true),
Event::FileSaved(p) => (p, false),
_ => continue,
};
if comment_for(path).is_none() || (opened && !host::setting_bool("reportOnOpen").unwrap_or(true)) {
continue;
}
let Some(project) = project_of(path) else { continue };
if let Ok(text) = project.read_file(path) {
let _ = report(path, &text);
}
}
}
}
impl Providers for LicenseHeader {
fn code_actions(&mut self, doc: &Document, _selection: Range) -> Vec<CodeAction> {
let (owner, license) = settings();
let Some(edit) = header_edit(&doc.path, &doc.text, this_year(), &owner, &license) else { return Vec::new() };
vec![CodeAction {
title: "Add license header".into(),
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
}]
}
}
register_plugin!(LicenseHeader, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
fn doc(path: &str, text: &str) -> Document {
Document { path: path.into(), language: String::new(), version: 1, text: text.into() }
}
#[test]
fn writes_the_header_in_each_files_comment_syntax() {
let lines = header_lines(2026, "ACME Inc.", "MIT");
assert_eq!(
render(comment_for("a/b.rs").unwrap(), &lines),
"// Copyright (c) 2026 ACME Inc.\n// SPDX-License-Identifier: MIT\n\n"
);
assert_eq!(render(comment_for("x.py").unwrap(), &lines[..1]), "# Copyright (c) 2026 ACME Inc.\n\n");
assert_eq!(
render(comment_for("s.css").unwrap(), &lines),
"/*\n * Copyright (c) 2026 ACME Inc.\n * SPDX-License-Identifier: MIT\n */\n\n"
);
assert_eq!(comment_for("q.SQL"), Some(Comment::Line("--")));
assert_eq!(comment_for("Dockerfile"), Some(Comment::Line("#")));
assert_eq!(comment_for("README.md"), None);
assert_eq!(comment_for("noext"), None);
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
}
#[test]
fn keeps_shebang_and_encoding_lines_first() {
assert_eq!(insert_line("fn main() {}\n"), 0);
assert_eq!(insert_line("#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint()\n"), 2);
assert_eq!(insert_line("#!/bin/sh\necho\n"), 1);
assert_eq!(insert_line("# coding=latin-1\nx = 1\n"), 1);
let e = header_edit("run.sh", "#!/bin/sh", 2026, "", "").unwrap();
assert_eq!((e.range.start, e.text.as_str()), (Position::new(1, 0), "\n# Copyright (c) 2026\n\n"));
}
#[test]
fn an_existing_header_is_left_alone() {
assert!(has_header("// SPDX-License-Identifier: Apache-2.0\nfn x() {}"));
assert!(has_header("/*\n * Licensed under the MIT license\n */"));
assert!(has_header(&format!("{}// Copyright 2020\n", "\n".repeat(10))));
assert!(!has_header(&format!("{}// Copyright 2020\n", "\n".repeat(20))), "only the top of the file counts");
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
}
#[test]
fn years() {
assert_eq!(year_of_day(0), 1970);
assert_eq!(year_of_day(19_722), 2023); // 2023-12-31
assert_eq!(year_of_day(19_723), 2024); // 2024-01-01
assert_eq!(year_of_day(20_759), 2026);
}
#[test]
fn reports_on_open_and_save_and_fixes() {
let host = MockHost::install().with_capabilities(&["events", "project.read", "editor.read", "editor.write"]);
host.add_file("src/a.rs", "fn a() {}\n");
host.add_file("src/b.rs", "// Copyright (c) 2020 X\nfn b() {}\n");
let mut p = LicenseHeader;
p.handle_events(
&[Event::FileOpened("/project/src/a.rs".into()), Event::FileOpened("/project/src/b.rs".into())],
&host.invocation(),
);
let d = host.diagnostics();
assert_eq!(d["/project/src/a.rs"][0].code.as_deref(), Some(CODE));
assert!(d.get("/project/src/b.rs").is_none_or(Vec::is_empty));
// Off when opening, still on saving.
host.set_setting("reportOnOpen", "false");
host.add_file("src/c.rs", "fn c() {}\n");
p.handle_events(&[Event::FileOpened("/project/src/c.rs".into())], &host.invocation());
assert!(host.diagnostics().get("/project/src/c.rs").is_none_or(Vec::is_empty));
p.handle_events(&[Event::FileSaved("/project/src/c.rs".into())], &host.invocation());
assert_eq!(host.diagnostics()["/project/src/c.rs"].len(), 1);
host.set_setting("owner", "\"ACME\"");
let actions = p.code_actions(&doc("/project/src/a.rs", "fn a() {}\n"), Range::default());
assert_eq!(actions[0].title, "Add license header");
let (path, edits) = &actions[0].edits[0];
assert_eq!(path, "/project/src/a.rs");
assert!(
edits[0].text.starts_with(&format!(
"// Copyright (c) {} ACME\n// SPDX-License-Identifier: Apache-2.0\n",
this_year()
))
);
assert!(p.code_actions(&doc("/project/README.md", "x"), Range::default()).is_empty());
// The command edits the focused editor once.
host.open_editor("/project/src/a.rs", "rust", "fn a() {}\n", &[(0, 0, 0, 0)]);
assert_eq!(p.run_command("add", &host.invocation(), None).unwrap(), None);
assert!(has_header(&host.editor_text()));
assert!(p.run_command("add", &host.invocation(), None).unwrap().unwrap().contains("already"));
}
#[test]
fn scans_the_project_in_a_task() {
let host = MockHost::install().with_capabilities(&["events", "project.read"]);
host.add_file("src/a.rs", "fn a() {}\n");
host.add_file("src/b.py", "# Copyright 2024 X\n");
host.add_file("notes.md", "no header needed\n");
let mut p = LicenseHeader;
p.run_command("scan", &host.invocation(), None).unwrap();
let d = host.diagnostics();
assert_eq!(d["/project/src/a.rs"].len(), 1);
assert!(d.get("/project/src/b.py").is_none_or(Vec::is_empty));
assert!(!d.contains_key("/project/notes.md"));
assert_eq!(host.tasks().len(), 1);
assert!(host.notifications()[0].body.contains("1 of 2"), "{:?}", host.notifications());
}
}
+338
Переглянути файл
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Generates icons/*.svg and icons/theme.json of the Material Icons plugin.
Flat, original icons on the Material colour palette: language files are
coloured badges with a short label, well-known files get glyphs, folders are
tinted by role. Edit the tables below and run `python3 generate.py`, then
`openidea-plugin check .`. Not part of the package (plugin.toml does not
reference it), so it is not installed.
"""
import json
import pathlib
HERE = pathlib.Path(__file__).resolve().parent
OUT = HERE / "icons"
FONT = "system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
def svg(body: str) -> str:
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">{body}</svg>\n'
def badge(color: str, label: str, fg: str = "#fff") -> str:
size = {1: 9.5, 2: 7.4, 3: 5.6}[len(label)]
y = 8 + size * 0.36
label = label.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return svg(
f'<rect x="1" y="1" width="14" height="14" rx="3" fill="{color}"/>'
f'<text x="8" y="{y:.2f}" fill="{fg}" font-family="{FONT}" font-size="{size}" '
f'font-weight="700" text-anchor="middle" letter-spacing="-0.2">{label}</text>'
)
def doc(color: str, inner: str = "") -> str:
return svg(
f'<path fill="{color}" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>'
f'<path fill="#fff" fill-opacity=".45" d="M9.6 1v3.4H13z"/>{inner}'
)
def folder(color: str, open_: bool = False) -> str:
if open_:
return svg(
f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.4a1 1 0 0 1 1 1v1.5H4.2L2 13.5H1.5a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>'
f'<path fill="{color}" fill-opacity=".75" d="M4.2 7h11.3l-2.2 6.5H2z"/>'
)
return svg(
f'<path fill="{color}" d="M1.5 2.5h4.6L7.6 4h6.9a1 1 0 0 1 1 1v7.5a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/>'
)
def stroke(d: str, color: str = "#fff", width: float = 1.4) -> str:
return (
f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}" '
f'stroke-linecap="round" stroke-linejoin="round"/>'
)
def gear(color: str) -> str:
teeth = "".join(
f'<rect x="6.9" y="0.8" width="2.2" height="3.2" rx=".5" transform="rotate({a} 8 8)"/>'
for a in range(0, 360, 45)
)
return svg(f'<g fill="{color}">{teeth}</g><circle cx="8" cy="8" r="3.6" fill="none" stroke="{color}" stroke-width="2.8"/>')
def atom(color: str) -> str:
rings = "".join(
f'<ellipse cx="8" cy="8" rx="6.6" ry="2.5" transform="rotate({a} 8 8)"/>' for a in (0, 60, 120)
)
return svg(f'<g fill="none" stroke="{color}" stroke-width="1.1">{rings}</g><circle cx="8" cy="8" r="1.4" fill="{color}"/>')
def package(color: str) -> str:
return svg(
f'<path fill="{color}" d="M8 1l6.5 3.2v7.6L8 15l-6.5-3.2V4.2z"/>'
'<path fill="#000" fill-opacity=".22" d="M8 7.4V15l6.5-3.2V4.2z"/>'
'<path fill="#fff" fill-opacity=".35" d="M1.5 4.2L8 7.4l6.5-3.2L8 1z"/>'
)
GLYPHS = {
"file": doc("#90a4ae"),
"text": doc("#90a4ae", stroke("M5 7.5h6M5 10h6M5 12.5h4", width=1.1)),
"markdown": svg(
'<rect x="0.5" y="3" width="15" height="10" rx="1.6" fill="#42a5f5"/>'
+ stroke("M3 10.5v-5l2 2.5 2-2.5v5", width=1.3)
+ stroke("M11 5.5v5M9.2 8.8l1.8 1.8 1.8-1.8", width=1.3)
),
"image": svg(
'<rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="#26a69a"/>'
'<circle cx="5" cy="6" r="1.5" fill="#fff"/>'
'<path fill="#fff" d="M2.5 12l3.5-4 2.5 2.6L11 7.4l3 4.6z"/>'
),
"archive": svg(
'<rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/>'
'<path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/>'
'<rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/>'
),
"lock": svg(
'<rect x="3" y="7" width="10" height="8" rx="1.5" fill="#90a4ae"/>'
+ stroke("M5.5 7V5a2.5 2.5 0 0 1 5 0v2", "#90a4ae", 1.8)
+ '<circle cx="8" cy="11" r="1.2" fill="#fff"/>'
),
"git": svg(
'<path fill="#f4511e" d="M8 .6l7.4 7.4L8 15.4.6 8z"/>'
+ stroke("M6.3 5v6M6.3 6.4c0 1.8 3.7.4 3.7 2.1", width=1.1)
+ '<g fill="#fff"><circle cx="6.3" cy="5" r="1.1"/><circle cx="6.3" cy="11" r="1.1"/><circle cx="10" cy="8.6" r="1.1"/></g>'
),
"settings": gear("#78909c"),
"env": gear("#fbc02d"),
"readme": svg(
'<circle cx="8" cy="8" r="7" fill="#42a5f5"/>'
'<rect x="7.1" y="6.8" width="1.8" height="5.2" rx=".6" fill="#fff"/><circle cx="8" cy="4.6" r="1.1" fill="#fff"/>'
),
"license": svg(
'<rect x="1.5" y="2" width="13" height="9.5" rx="1" fill="#ffca28"/>'
+ stroke("M3.6 4.6h6.4M3.6 6.8h4", "#fff", 1.1)
+ '<path fill="#ef6c00" d="M9.6 11.4l-.7 3.4 2.1-1.1 2.1 1.1-.7-3.4z"/><circle cx="11" cy="10" r="2.3" fill="#ef6c00"/>'
),
"shell": svg(
'<rect x="1" y="2" width="14" height="12" rx="2" fill="#4caf50"/>'
+ stroke("M4 5.5l2.5 2.5L4 10.5", width=1.5)
+ stroke("M8 10.8h4", width=1.5)
),
"database": svg(
'<path fill="#ffca28" d="M2.5 3.5v9c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2v-9z"/>'
'<ellipse cx="8" cy="3.5" rx="5.5" ry="2" fill="#ffe082"/>'
+ stroke("M2.5 7.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2", "#ffe082", 0.9)
),
"html": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#e44d26"/>' + stroke("M6 5L3.5 8 6 11M10 5l2.5 3L10 11")),
"xml": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ff9800"/>' + stroke("M5.5 5L3 8l2.5 3M10.5 5L13 8l-2.5 3M9 4.5l-2 7", width=1.2)),
"json": badge("#fbc02d", "{}", "#5d4037"),
"react": atom("#00bcd4"),
"react-ts": atom("#0288d1"),
"docker": svg(
'<rect x="1" y="1" width="14" height="14" rx="3" fill="#0288d1"/>'
'<g fill="#fff"><rect x="3" y="7" width="2.2" height="2"/><rect x="5.6" y="7" width="2.2" height="2"/>'
'<rect x="8.2" y="7" width="2.2" height="2"/><rect x="5.6" y="4.6" width="2.2" height="2"/>'
'<rect x="8.2" y="4.6" width="2.2" height="2"/></g>'
'<path fill="#fff" d="M2.6 9.6h10.6c.4-.8 1-1.2 1.6-1.2-.3 3-2.6 4.6-6.4 4.6-3.2 0-5.2-1.2-5.8-3.4z"/>'
),
"npm": package("#cb3837"),
"cargo": package("#ff7043"),
"gomod": package("#00acc1"),
"pnpm": package("#f9ad00"),
"test": svg(
'<rect x="1" y="1" width="14" height="14" rx="3" fill="#8bc34a"/>' + stroke("M4.2 8.2l2.4 2.4 5.2-5.2", width=1.7)
),
"vite": badge("#9575cd", "V"),
"tsconfig": badge("#0288d1", "TS", "#bbdefb"),
"makefile": badge("#ef5350", "M"),
"editorconfig": gear("#90a4ae"),
"font": badge("#ef5350", "Aa"),
"pdf": badge("#e53935", "PDF"),
"svg": badge("#ffb300", "SVG", "#4e342e"),
"audio": svg('<rect x="1" y="1" width="14" height="14" rx="3" fill="#ec407a"/><path fill="#fff" d="M6 4.5l6-1.5v7.3a1.8 1.8 0 1 1-1.2-1.7V5.4L7.2 6.3v5.4a1.8 1.8 0 1 1-1.2-1.7z"/>'),
"video": svg('<rect x="1" y="2.5" width="14" height="11" rx="2" fill="#ff7043"/><path fill="#fff" d="M6.4 5.5v5l4-2.5z"/>'),
}
# extension → (icon id, colour, label[, label colour])
LANGUAGES = {
"rust": ("#ff7043", "RS", ["rs"]),
"typescript": ("#0288d1", "TS", ["ts", "mts", "cts"]),
"typescript-def": ("#0277bd", "D", ["d.ts", "d.mts", "d.cts"]),
"javascript": ("#fbc02d", "JS", ["js", "mjs", "cjs"], "#3e2723"),
"python": ("#3572a5", "PY", ["py", "pyi", "pyw"], "#ffd54f"),
"go": ("#00acc1", "GO", ["go"]),
"java": ("#e53935", "J", ["java"]),
"class": ("#8d6e63", "CL", ["class", "jar"]),
"kotlin": ("#7e57c2", "KT", ["kt", "kts"]),
"groovy": ("#4db6ac", "GR", ["groovy", "gradle"]),
"scala": ("#d32f2f", "SC", ["scala", "sc", "sbt"]),
"c": ("#5c6bc0", "C", ["c"]),
"cpp": ("#1e88e5", "C++", ["cpp", "cc", "cxx", "c++"]),
"header": ("#9575cd", "H", ["h", "hpp", "hh", "hxx", "inl"]),
"csharp": ("#388e3c", "C#", ["cs", "csx"]),
"fsharp": ("#378bba", "F#", ["fs", "fsi", "fsx"]),
"swift": ("#ff5722", "SW", ["swift"]),
"objc": ("#607d8b", "OC", ["m", "mm"]),
"ruby": ("#c62828", "RB", ["rb", "erb", "rake", "gemspec"]),
"php": ("#7986cb", "PHP", ["php"]),
"perl": ("#5c6bc0", "PL", ["pl", "pm"]),
"lua": ("#3949ab", "LUA", ["lua"]),
"r": ("#1976d2", "R", ["r", "rmd"]),
"julia": ("#9558b2", "JL", ["jl"]),
"dart": ("#40c4ff", "D", ["dart"], "#01579b"),
"elixir": ("#7e57c2", "EX", ["ex", "exs", "heex"]),
"erlang": ("#c2185b", "ER", ["erl", "hrl"]),
"haskell": ("#7e57c2", "HS", ["hs", "lhs"]),
"ocaml": ("#ef6c00", "ML", ["ml", "mli"]),
"clojure": ("#64dd17", "CLJ", ["clj", "cljs", "cljc", "edn"], "#1b5e20"),
"zig": ("#f9a825", "Z", ["zig", "zon"], "#3e2723"),
"nim": ("#ffc107", "NIM", ["nim"], "#3e2723"),
"solidity": ("#546e7a", "SOL", ["sol"]),
"vue": ("#41b883", "V", ["vue"]),
"svelte": ("#ff3e00", "S", ["svelte"]),
"astro": ("#ff5d01", "A", ["astro"]),
"css": ("#42a5f5", "#", ["css"]),
"sass": ("#ec407a", "#", ["scss", "sass"]),
"less": ("#1565c0", "LS", ["less"]),
"yaml": ("#e53935", "YML", ["yaml", "yml"]),
"toml": ("#8d6e63", "TML", ["toml"]),
"ini": ("#78909c", "INI", ["ini", "cfg", "conf", "properties"]),
"csv": ("#43a047", "CSV", ["csv", "tsv"]),
"graphql": ("#e535ab", "GQ", ["graphql", "gql"]),
"proto": ("#26a69a", "PB", ["proto"]),
"wasm": ("#654ff0", "WA", ["wasm", "wat"]),
"nix": ("#5277c3", "NIX", ["nix"]),
"terraform": ("#7b42bc", "TF", ["tf", "tfvars", "hcl"]),
"powershell": ("#0277bd", "PS", ["ps1", "psm1", "psd1"]),
"batch": ("#546e7a", "BAT", ["bat", "cmd"]),
"tex": ("#008080", "TEX", ["tex", "sty", "bib"]),
"vim": ("#43a047", "VI", ["vim"]),
"diff": ("#66bb6a", "+-", ["diff", "patch"]),
"log": ("#78909c", "LOG", ["log"]),
"notebook": ("#f57c00", "NB", ["ipynb"]),
"http": ("#3f51b5", "API", ["http", "rest"]),
}
GLYPH_EXTENSIONS = {
"text": ["txt", "text"],
"markdown": ["md", "markdown", "mdx"],
"image": ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif", "tif", "tiff", "icns"],
"svg": ["svg"],
"pdf": ["pdf"],
"archive": ["zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "zst"],
"lock": ["lock"],
"shell": ["sh", "bash", "zsh", "fish"],
"database": ["sql", "sqlite", "db", "prisma"],
"html": ["html", "htm", "xhtml"],
"xml": ["xml", "xsd", "xsl", "plist", "iml"],
"json": ["json", "jsonc", "json5"],
"react": ["jsx"],
"react-ts": ["tsx"],
"test": [f"{kind}.{ext}" for kind in ("test", "spec") for ext in ("ts", "tsx", "js", "jsx", "mjs", "mts")]
+ ["test.rs", "test.py"],
"font": ["ttf", "otf", "woff", "woff2"],
"audio": ["mp3", "wav", "ogg", "flac", "m4a"],
"video": ["mp4", "mov", "webm", "mkv", "avi"],
"env": ["env"],
}
FILE_NAMES = {
"readme": ["readme", "readme.md", "readme.txt", "readme.rst"],
"license": ["license", "licence", "license.md", "licence.md", "license.txt", "copying", "notice"],
"git": [".gitignore", ".gitattributes", ".gitmodules", ".gitkeep", ".mailmap"],
"docker": ["dockerfile", "containerfile", ".dockerignore", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"],
"npm": ["package.json", ".npmrc", ".npmignore"],
"cargo": ["cargo.toml", "rust-toolchain", "rust-toolchain.toml"],
"gomod": ["go.mod", "go.sum", "go.work"],
"pnpm": ["pnpm-workspace.yaml"],
"lock": ["cargo.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "poetry.lock", "uv.lock", "gemfile.lock", "composer.lock"],
"env": [".env", ".env.local", ".env.development", ".env.production", ".env.example"],
"tsconfig": ["tsconfig.json", "jsconfig.json", "tsconfig.base.json"],
"vite": ["vite.config.ts", "vite.config.js", "vite.config.mts", "vitest.config.ts", "vitest.config.mts"],
"makefile": ["makefile", "gnumakefile", "justfile", "cmakelists.txt"],
"editorconfig": [".editorconfig", ".prettierrc", ".prettierignore", ".oxlintrc.json", ".eslintrc", ".eslintrc.json", "mise.toml"],
"settings": [".clippy.toml", "clippy.toml", "rustfmt.toml", ".rustfmt.toml", "deny.toml"],
}
# folder name → (icon id, colour)
FOLDERS = {
"folder-src": ("#4caf50", ["src", "source", "sources", "lib", "libs", "pkg", "internal", "crates", "packages"]),
"folder-test": ("#8bc34a", ["test", "tests", "__tests__", "spec", "specs", "e2e", "fixtures", "testdata"]),
"folder-docs": ("#42a5f5", ["doc", "docs", "documentation", "wiki"]),
"folder-dist": ("#e57373", ["dist", "build", "out", "target", "bin", "obj", "release", "coverage"]),
"folder-deps": ("#9ccc65", ["node_modules", "vendor", ".venv", "venv", "third_party", "deps"]),
"folder-git": ("#ff7043", [".git", ".github", ".gitlab", ".husky"]),
"folder-config": ("#78909c", ["config", "configs", ".config", ".vscode", ".idea", ".cargo", "settings", "etc"]),
"folder-assets": ("#26a69a", ["assets", "static", "public", "images", "img", "icons", "fonts", "media", "resources", "res"]),
"folder-scripts": ("#ffb74d", ["scripts", "script", "tools", "tasks", "ci"]),
"folder-components": ("#ffca28", ["components", "ui", "widgets", "views", "pages", "features"]),
"folder-styles": ("#ec407a", ["styles", "css", "scss", "theme", "themes"]),
"folder-plugins": ("#ab47bc", ["plugins", "extensions", "addons", "sdk"]),
"folder-i18n": ("#5c6bc0", ["i18n", "locales", "locale", "lang", "translations"]),
"folder-api": ("#26c6da", ["api", "server", "routes", "handlers", "rpc"]),
"folder-db": ("#ffa726", ["db", "database", "migrations", "sql", "schema"]),
}
def main() -> None:
OUT.mkdir(exist_ok=True)
for old in OUT.glob("*.svg"):
old.unlink()
icons: dict[str, str] = {}
def add(name: str, content: str) -> None:
assert name not in icons, f"duplicate icon {name}"
icons[name] = content
(OUT / f"{name}.svg").write_text(content)
for name, content in GLYPHS.items():
add(name, content)
add("folder", folder("#90a4ae"))
add("folder-open", folder("#90a4ae", open_=True))
extensions: dict[str, str] = {}
def map_ext(ext: str, icon: str) -> None:
assert ext not in extensions, f"extension {ext} mapped twice"
extensions[ext] = icon
for name, (color, label, exts, *fg) in LANGUAGES.items():
add(name, badge(color, label, *fg))
for ext in exts:
map_ext(ext, name)
for icon, exts in GLYPH_EXTENSIONS.items():
for ext in exts:
map_ext(ext, icon)
file_names = {n: icon for icon, names in FILE_NAMES.items() for n in names}
folder_names: dict[str, str] = {}
for icon, (color, names) in FOLDERS.items():
add(icon, folder(color))
for n in names:
assert n not in folder_names, f"folder {n} mapped twice"
folder_names[n] = icon
for mapping in (extensions, file_names, folder_names):
for key, icon in mapping.items():
assert icon in icons, f"{key} → unknown icon {icon}"
theme = {
"iconDefinitions": {name: f"icons/{name}.svg" for name in sorted(icons)},
"file": "file",
"folder": "folder",
"folderExpanded": "folder-open",
"fileExtensions": dict(sorted(extensions.items())),
"fileNames": dict(sorted(file_names.items())),
"folderNames": dict(sorted(folder_names.items())),
}
(OUT / "theme.json").write_text(json.dumps(theme, indent=2) + "\n")
print(f"{len(icons)} icons, {len(extensions)} extensions, {len(file_names)} file names, {len(folder_names)} folders")
if __name__ == "__main__":
main()
+1
Переглянути файл
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="1" width="12" height="14" rx="1.5" fill="#af8e5b"/><path fill="#fff" d="M7 1h2v1.6H7zm0 3.2h2v1.6H7zM6.4 7.6h3.2v4.4H6.4z"/><rect x="7.2" y="9.8" width="1.6" height="1.2" fill="#af8e5b"/></svg>

Після

Ширина:  |  Висота:  |  Розмір: 269 B

+1
Переглянути файл
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#ff5d01"/><text x="8" y="11.42" fill="#fff" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="9.5" font-weight="700" text-anchor="middle" letter-spacing="-0.2">A</text></svg>

Після

Ширина:  |  Висота:  |  Розмір: 319 B

Loaded 100 of 450 files, more files were not shown because too many files have changed in this diff. Show more