дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
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"
|
||||
@@ -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"
|
||||
Vendored
BIN
Бінарний файл не відображається.
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
bf264a97dc918f24ac00f0e9e0d0373b68391a96921b44345b466c2a087c5283
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
6095c7e30658b93d79270e6355d42941a4cfe13eeb8bada07acc7ae462194853
|
||||
@@ -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"
|
||||
@@ -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(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", "\n")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
b96941af697ce7a6c1c8ef1539d34aa7c23d2d41dd1b2ccffdfda5648f90cf56
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
web-src/node_modules/
|
||||
# Built by plugins/build.sh, not checked in.
|
||||
web/
|
||||
web.sources
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,6 @@
|
||||
id = "excalidraw"
|
||||
name = "Excalidraw"
|
||||
extensions = ["excalidraw"]
|
||||
indent = { tab-size = 2, insert-spaces = true }
|
||||
ui-mode = "JSON"
|
||||
tree-sitter = "json"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "https://openidea.dev",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"gridSize": 20,
|
||||
"gridStep": 5,
|
||||
"gridModeEnabled": false,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
@@ -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] }];
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/] } },
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
ad545605b33c6f4e55a06a376333de3162739ed920b5d7d277942e50a4420083
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
|
||||
@@ -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 |
@@ -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 |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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]: {}
|
||||
@@ -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.";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
345c39f818e4950914bf3fcc795fac9e30ff2d87ad3e66f24c7bc00800f92980
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
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()
|
||||
@@ -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 |
@@ -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
Reference in new issue
Заблокувати користувача