feat(plugins): Graphviz, Regex Tester, License Header and Harbor Night official plugins

- Graphviz: `.dot` / `.gv` files open beside a live preview (a web view
  rendering with Graphviz compiled to WebAssembly) in any layout engine,
  with zoom, pan, Fit and SVG / PNG export through the save dialog. What
  Graphviz reports goes from the page to the plugin's code, which publishes
  it as problems on the named line. A DOT language, file icons and a
  New Graphviz Graph template.
- Regex Tester: a tool window whose html view sends pattern, flags, text and
  replacement as you type; the plugin matches with Rust's regex (linear time)
  and answers with matches, groups and the replacement in UTF-16 offsets.
  Use Selection and Test Selection take the editor's selection.
- License Header: files without a copyright / SPDX header are reported when
  they open and on save; a quick fix and a command add one in the file's
  comment syntax (after a shebang or encoding line); a project scan in a
  background task reports the rest. Holder and license are settings.
- Harbor Night becomes an official plugin and the one source of the theme
  files: `openidea-plugin new --template theme`, the theme docs and tests
  start from it.
- Tool windows' renders and events carry the window's active editor, as
  commands do, so a view's buttons can act on what the user is editing.
- knip leaves plugins' web pages (projects of their own) alone.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 джерело 3b78d15d6d
коміт ac71b7008e
54 files changed
+3966 -99

No files matched your search

+3 -3
Переглянути файл
@@ -45,7 +45,7 @@ pub fn render(template: Template, vars: &Vars) -> Vec<(String, String)> {
.replace("__NAME__", vars.name)
.replace("__CRATE__", &crate_name)
.replace("__SDK__", vars.sdk.unwrap_or(""))
// The theme files come from templates/theme-plugin.
// The theme files come from the Harbor Night plugin.
.replace("harbor-night", &slug)
.replace("Harbor Night", vars.name)
};
@@ -210,8 +210,8 @@ path = "themes/__SLUG__.theme.json"
editor-scheme = "themes/__SLUG__.scheme.json"
"#;
const THEME_UI: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.theme.json");
const THEME_SCHEME: &str = include_str!("../../../../../templates/theme-plugin/themes/harbor-night.scheme.json");
const THEME_UI: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.theme.json");
const THEME_SCHEME: &str = include_str!("../../../../../plugins/official/harbor-night/themes/harbor-night.scheme.json");
const ICON_MANIFEST: &str = r#"id = "__ID__"
name = "__NAME__"
+2
Переглянути файл
@@ -421,6 +421,7 @@ impl RemotePlugins {
c.arg("view")?,
c.arg("viewport")?,
c.arg("doc")?,
c.arg("editor")?,
)
.await
}
@@ -438,6 +439,7 @@ impl RemotePlugins {
c.arg("view")?,
c.arg("event")?,
c.arg("doc")?,
c.arg("editor")?,
)
.await
}
+45 -6
Переглянути файл
@@ -2,16 +2,23 @@
//! window commands `plugin_view_render` / `plugin_view_event` serve them —
//! the desktop for local projects, `openidea-server` for remote ones.
use super::PluginHost;
use super::{PluginEditor, PluginHost};
use ide_core::{DocId, IdeError, IdeResult};
use ide_plugin::{EditorContext, Invocation, View, ViewEvent, Viewport};
use std::sync::Arc;
/// The window's project, plus the document a custom editor shows.
pub fn view_invocation(host: &crate::Host, window: &str, doc: Option<DocId>) -> IdeResult<Invocation> {
/// The window's project, plus the editor the call is about: the document a
/// custom editor shows, else (a tool window) the window's active editor, as
/// a command gets it, so a view's buttons can act on what the user is editing.
pub fn view_invocation(
host: &crate::Host,
window: &str,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<Invocation> {
let project = host.project(window).ok();
let editor = match doc {
None => None,
None => editor.map(PluginEditor::context),
Some(doc) => {
let project = project.as_ref().ok_or_else(|| IdeError::not_found("no project is open in this window"))?;
let info = project.documents.snapshot(doc)?;
@@ -34,11 +41,13 @@ pub async fn render(
view: String,
viewport: Option<Viewport>,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<View> {
let invocation = view_invocation(host, window, doc)?;
let invocation = view_invocation(host, window, doc, editor)?;
blocking(move || plugins.render_view(&plugin_id, &view, viewport, invocation)).await
}
#[allow(clippy::too_many_arguments)]
pub async fn event(
host: &crate::Host,
plugins: Arc<PluginHost>,
@@ -47,7 +56,37 @@ pub async fn event(
view: String,
event: ViewEvent,
doc: Option<DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<()> {
let invocation = view_invocation(host, window, doc)?;
let invocation = view_invocation(host, window, doc, editor)?;
blocking(move || plugins.view_event(&plugin_id, &view, event, invocation)).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Host, HostConfig};
#[tokio::test]
async fn a_tool_window_call_carries_the_active_editor() {
let tmp = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 4));
let active = PluginEditor { path: "/p/a.rs".into(), language: "rust".into(), selections: vec![range] };
let inv = view_invocation(&host, "main", None, Some(active)).unwrap();
let editor = inv.editor.expect("the active editor");
assert_eq!((editor.path.to_string_lossy().as_ref(), editor.selections.as_slice()), ("/p/a.rs", &[range][..]));
assert_eq!(inv.client.as_deref(), Some("main"));
assert!(view_invocation(&host, "main", None, None).unwrap().editor.is_none());
}
}
+146 -38
Переглянути файл
@@ -116,7 +116,7 @@ fn sample(name: &str) -> PathBuf {
}
fn theme_template() -> PathBuf {
repo().join("templates/theme-plugin")
repo().join("plugins/official/harbor-night")
}
fn misbehave_pkg() -> PathBuf {
@@ -640,7 +640,7 @@ fn contribution_only_plugins() {
let theme = f.install(&theme_template());
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
assert_eq!(f.theme_kinds("theme-harbor-night")[0].0, "harbor-night");
assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night");
let lang = f.install(&sample("language-toy"));
assert_eq!(lang.status, PluginStatus::Static);
assert_eq!(f.host.languages()[0].id.as_str(), "toy");
@@ -648,14 +648,14 @@ fn contribution_only_plugins() {
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
f.take_events();
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
let active = f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#14171c");
assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a");
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
assert_eq!(f.host.list(None).active_theme, Some(active));
// Disabling the plugin withdraws its theme and language.
f.host.set_enabled("theme-harbor-night", false).unwrap();
f.host.set_enabled("openidea.harbor-night", false).unwrap();
assert!(f.host.active_theme().is_none());
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
f.host.set_enabled("language-toy", false).unwrap();
@@ -668,10 +668,10 @@ fn theme_template_installs_with_its_editor_scheme() {
let f = fixture(Limits::default());
let info = f.install(&theme_template());
assert_eq!(info.status, PluginStatus::Static);
assert_eq!(f.theme_kinds("theme-harbor-night"), [("harbor-night".to_owned(), ThemeKind::Dark)]);
assert_eq!(f.theme_kinds("openidea.harbor-night"), [("harbor-night".to_owned(), ThemeKind::Dark)]);
// The scheme file is copied with the package and merged over the UI tokens.
assert!(f.installed("theme-harbor-night").join("themes/harbor-night.scheme.json").is_file());
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
assert!(f.installed("openidea.harbor-night").join("themes/harbor-night.scheme.json").is_file());
let active = f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap().unwrap();
let tokens = &active.theme.tokens;
assert!(
tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red")
@@ -751,7 +751,7 @@ fn state_persists_across_restarts() {
f.install(&sample("hello"));
f.install(&theme_template());
f.host.set_enabled("hello", false).unwrap();
f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap();
f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap();
f.host.set_theme_frame(Some("#1f252e")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
@@ -1395,17 +1395,17 @@ fn catalog_lists_sample_packages() {
let catalog = f.host.catalog(&[
repo().join("crates/ide-plugin/tests/fixtures"),
repo().join("templates"),
repo().join("plugins/official"),
tmp.path().to_path_buf(),
]);
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
for id in ["hello", "theme-harbor-night", "language-toy", "x"] {
for id in ["hello", "openidea.harbor-night", "language-toy", "x"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
assert_eq!(hello.contributions.iter().find(|c| c.point == "commands").unwrap().count, 2);
let theme = catalog.iter().find(|c| c.id == "theme-harbor-night").unwrap();
let theme = catalog.iter().find(|c| c.id == "openidea.harbor-night").unwrap();
assert!(!theme.has_code);
assert_eq!(theme.contributions[0].count, 1);
let x = catalog.iter().find(|c| c.id == "x").unwrap();
@@ -1737,6 +1737,52 @@ fn official_checkstyle_checks_on_save() {
assert_eq!(info.stats.crashes, 0);
}
/// License Header: reported when a file opens, fixed through the provider,
/// and a project scan in a task reports the rest.
#[test]
fn official_license_header_reports_fixes_and_scans() {
let f = fixture(Limits::default());
let info = f.install(&official("license-header"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let a = f.project.join("src/a.rs");
let b = f.project.join("src/b.py");
std::fs::write(&a, "fn a() {}\n").unwrap();
std::fs::write(&b, "#!/usr/bin/env python3\nprint()\n").unwrap();
let diagnostics = |path: &Path| {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
assert_eq!((d.len(), d[0].code.as_deref()), (1, Some("missing-license-header")));
assert!(diagnostics(&b).is_none(), "only the opened file");
let doc = ide_plugin::ProviderDocument {
path: b.clone(),
language: "python".into(),
version: 1,
text: std::fs::read_to_string(&b).unwrap(),
};
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
assert_eq!(actions.len(), 1, "{actions:?}");
let edit = &actions[0].1.edits[0].1[0];
assert_eq!(edit.range.start.line, 1, "after the shebang");
assert!(
edit.new_text.starts_with("# Copyright (c) 20")
&& edit.new_text.contains("# SPDX-License-Identifier: Apache-2.0")
);
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
}
/// MyBatis Navigator: interface ↔ XML through the providers, on a
/// real project tree (`find-files`), within the edit-path budget.
#[test]
@@ -1868,6 +1914,34 @@ fn sample_plugin_tool_window_with_a_tree() {
}
}
/// Regex Tester: an `html` body its script talks to; the plugin answers in
/// view messages, in UTF-16 offsets.
#[test]
fn official_regex_tester_answers_its_page() {
let f = fixture(Limits::default());
let info = f.install(&official("regex-tester"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let view = f.host.render_view("openidea.regex-tester", "regex", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Html(page) = &view.body else { panic!("{view:?}") };
assert!(page.contains("openidea.postMessage") && page.contains(r#"{"flags":"m"}"#));
f.take_events();
let run = serde_json::json!({ "op": "run", "seq": 3, "pattern": r"(\d+)", "flags": "", "text": "é 12 345" });
let event = ide_plugin::ViewEvent::Message { text: run.to_string() };
f.host.view_event("openidea.regex-tester", "regex", event, f.inv()).unwrap();
let reply = f
.take_events()
.into_iter()
.find_map(|(_, e)| match e {
PluginEvent::ViewMessage { view, message, .. } if view == "regex" => Some(message),
_ => None,
})
.expect("a reply");
let reply: serde_json::Value = serde_json::from_str(&reply).unwrap();
assert_eq!((reply["seq"].clone(), reply["count"].clone()), (serde_json::json!(3), serde_json::json!(2)));
assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4]));
}
/// A tree filled from a pinned program's output, and a terminal for the
/// activated entry in the invoking window, titled and sized by the plugin's
/// settings (the `jobs` fixture).
@@ -2075,34 +2149,8 @@ fn listing_while_projects_change_does_not_deadlock() {
/// file icon, template and language — no code.
#[test]
fn official_excalidraw_installs_and_serves_its_page() {
// Its page is built by plugins/build.sh, not checked in: the package's own
// files, with a stand-in for the build (what build.sh checks it produces).
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("excalidraw");
let src = official("excalidraw");
for entry in std::fs::read_dir(&src).unwrap().flatten() {
let name = entry.file_name();
if ["web", "web-src", "web.sources"].iter().any(|n| name == *n) {
continue;
}
let to = pkg.join(&name);
if entry.path().is_dir() {
copy_dir(&entry.path(), &to);
} else {
std::fs::create_dir_all(&pkg).unwrap();
std::fs::copy(entry.path(), to).unwrap();
}
}
std::fs::create_dir_all(pkg.join("web/assets")).unwrap();
std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap();
std::fs::write(
pkg.join("web/index.html"),
r#"<!doctype html><html><head><script type="module" src="./assets/index.js"></script></head></html>"#,
)
.unwrap();
std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap();
std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap();
let pkg = with_stand_in_page(&official("excalidraw"), tmp.path());
let f = fixture(Limits::default());
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
@@ -2122,6 +2170,66 @@ fn official_excalidraw_installs_and_serves_its_page() {
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
/// A copy of the official plugin at `src` in `dir`, with a stand-in for its
/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh
/// checks what the build produces): an entry loading a script and a font by
/// relative URL.
fn with_stand_in_page(src: &Path, dir: &Path) -> PathBuf {
let pkg = dir.join(src.file_name().unwrap());
std::fs::create_dir_all(&pkg).unwrap();
for entry in std::fs::read_dir(src).unwrap().flatten() {
let name = entry.file_name();
if ["web", "web-src", "web.sources", "target"].iter().any(|n| name == *n) {
continue;
}
let to = pkg.join(&name);
if entry.path().is_dir() {
copy_dir(&entry.path(), &to)
} else {
std::fs::copy(entry.path(), to).map(|_| ()).unwrap()
}
}
std::fs::create_dir_all(pkg.join("web/assets")).unwrap();
std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap();
std::fs::write(
pkg.join("web/index.html"),
r#"<!doctype html><html><head><script type="module" src="./assets/index.js"></script></head></html>"#,
)
.unwrap();
std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap();
std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap();
pkg
}
/// Graphviz: a web view with code. What the page reports about the graph
/// becomes the previewed file's problems.
#[test]
fn official_graphviz_turns_its_pages_reports_into_problems() {
let tmp = tempfile::tempdir().unwrap();
let f = fixture(Limits::default());
let info = f.install(&with_stand_in_page(&official("graphviz"), tmp.path()));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot"));
assert!(f.host.web_page("openidea.graphviz", "preview").is_ok());
std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap();
let editor = f.workspace.open("flow.dot", &[]);
let inv = Invocation { editor: Some(editor), ..f.inv() };
let report =
r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#;
let event = ide_plugin::ViewEvent::Message { text: report.into() };
f.host.view_event("openidea.graphviz", "preview", event, inv.clone()).unwrap();
let path = f.project.join("flow.dot");
let problems =
|f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.graphviz".into(), path.clone())).cloned();
let d = problems(&f).expect("published");
assert_eq!((d.len(), d[0].range.start.line), (1, 2));
let cleared = ide_plugin::ViewEvent::Message { text: r#"{"op":"problems","items":[]}"#.into() };
f.host.view_event("openidea.graphviz", "preview", cleared, inv).unwrap();
assert_eq!(problems(&f).map(|d| d.len()), Some(0));
}
/// Zips `dir` as an archiver does: the folder itself at the top, plus macOS metadata.
fn zip_folder(dir: &Path, out: &Path) {
use std::io::Write;
+4 -2
Переглянути файл
@@ -17,7 +17,8 @@ my-theme/
└─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls)
```
The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin).
The quickest way to start is `openidea-plugin new --template theme`, or a copy of
[`plugins/official/harbor-night`](../../plugins/official/harbor-night).
It contains *Harbor Night*, a complete example dark theme that sets every
themeable token. The rest of this page covers the file format, the token
reference, porting themes from IntelliJ IDEA and VS Code, and testing.
@@ -106,7 +107,8 @@ built-in theme returns.
There is no build step. The package *is* the folder.
1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files.
1. Run `openidea-plugin new --template theme` (or copy
`plugins/official/harbor-night`) and rename `id`, `name` and the theme files.
2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds).
3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the
*Install Plugin from Disk…* action) and pick the package folder, or
+2
Переглянути файл
@@ -10,6 +10,8 @@ const config: KnipConfig = {
"perf/**/*.ts",
"e2e/**/*.ts",
],
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
ignoreExportsUsedInFile: true,
// lint-staged formats Rust files with the toolchain's rustfmt.
ignoreBinaries: ["rustfmt"],
+14 -5
Переглянути файл
@@ -14,6 +14,7 @@ menus, settings) need no code at all.
```
plugins/
└─ official/
├─ harbor-night/ # data only: a colour theme (UI and editor scheme)
├─ material-icons/ # data only: a file icon theme
├─ solidity/ # language + syntax provider + run configurations
├─ string-tools/ # 26 text commands, versioned multi-cursor edits
@@ -23,7 +24,10 @@ plugins/
├─ 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
├─ 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
```
@@ -384,7 +388,10 @@ 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.
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,
@@ -454,7 +461,9 @@ 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`. The built page is not
`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.
@@ -537,8 +546,8 @@ values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A
manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor
colours, merged over the theme). Pick a theme in Settings › Plugins › _Apply_.
Start with `openidea-plugin new <dir> --template theme` (or
[`templates/theme-plugin`](../templates/theme-plugin)); the full guide, token
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).
+2 -2
Переглянути файл
@@ -1,6 +1,6 @@
/**
* Builds the page into ../web (checked in, like the official plugins'
* dist/plugin.wasm). Relative URLs throughout: the IDE serves the page from
* 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).
*/
+4
Переглянути файл
@@ -0,0 +1,4 @@
web-src/node_modules/
# Built by plugins/build.sh, not checked in.
web/
web.sources
+334
Переглянути файл
@@ -0,0 +1,334 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-graphviz"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"serde_json",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+22
Переглянути файл
@@ -0,0 +1,22 @@
[package]
name = "openidea-plugin-openidea-graphviz"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
+28
Переглянути файл
@@ -0,0 +1,28 @@
# Third-party software
The page in `web/` is built from `web-src/` and bundles:
- **Viz.js** (`@viz-js/viz` 3.31.0), MIT License, Copyright (c) Michael Daines —
https://github.com/mdaines/viz-js
- **Graphviz** 16.1.0, compiled to WebAssembly inside Viz.js, Eclipse Public License 1.0 —
https://graphviz.org, https://gitlab.com/graphviz/graphviz
The MIT License:
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The Eclipse Public License 1.0 is at https://www.eclipse.org/legal/epl-v10.html. Graphviz's
source code is available from the links above.
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
+6
Переглянути файл
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M5.2 5.6 7 10M10.8 5.6 9 10M6 4h4" stroke="#3574F0" stroke-width="1.1" stroke-linecap="round"/>
<circle cx="4" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
<circle cx="12" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
<circle cx="8" cy="12" r="2.5" fill="#3574F0" stroke="#3574F0"/>
</svg>

Після

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

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

Після

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

+8
Переглянути файл
@@ -0,0 +1,8 @@
id = "dot"
name = "DOT"
extensions = ["dot", "gv"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
# No DOT mode in CodeMirror: C's highlights the strings, comments, numbers and braces.
ui-mode = "C"
+38
Переглянути файл
@@ -0,0 +1,38 @@
id = "openidea.graphviz"
name = "Graphviz"
version = "0.1.0"
description = "Graphviz DOT files (.dot, .gv) with a live preview beside the text: rendered as you type with any layout engine, syntax errors in the editor and the Problems view, export to SVG and PNG."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: live preview (Graphviz compiled to WebAssembly), layout engines, zoom, errors as problems, SVG and PNG export."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:preview"]
[capabilities]
# Problems: published for the project's file (project.read), on the lines
# Graphviz names (editor.read: the previewed text).
required = ["editor.read", "project.read"]
[[contributes.customEditors]]
id = "preview"
title = "Preview"
patterns = ["*.dot", "*.gv"]
priority = "default"
layout = "split"
web = "web/index.html"
[[contributes.languages]]
path = "languages/dot.toml"
[[contributes.fileIcons]]
id = "graphviz"
patterns = ["*.dot", "*.gv"]
icon = "icons/graphviz.svg"
iconDark = "icons/graphviz_dark.svg"
[[contributes.fileTemplates]]
id = "graph"
name = "Graphviz Graph"
extension = "dot"
path = "templates/graph.dot"
+141
Переглянути файл
@@ -0,0 +1,141 @@
//! Graphviz for OpenIDEA: `.dot` / `.gv` files open in the text editor with a
//! live preview beside them. The preview is a web page (`web-src/`: viz.js,
//! Graphviz compiled to WebAssembly) that renders the document as it changes;
//! what Graphviz reports it sends here, and the plugin publishes it as the
//! file's problems, on the line Graphviz names.
use openidea_plugin::views::{View, ViewEvent, Viewport, Views};
use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Position, Range, Severity, host, register_plugin};
use serde_json::Value;
pub const VIEW: &str = "preview";
/// Problems published for one file at most.
const MAX_PROBLEMS: usize = 100;
/// One message from Graphviz, as the page reports it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Report {
/// 1-based, when Graphviz names one.
pub line: Option<u32>,
pub message: String,
pub error: bool,
}
/// The page's `{"op": "problems", "items": [{"line", "message", "level"}]}`.
pub fn parse_reports(message: &str) -> Option<Vec<Report>> {
let v: Value = serde_json::from_str(message).ok()?;
if v.get("op").and_then(Value::as_str) != Some("problems") {
return None;
}
let items = v.get("items")?.as_array()?;
Some(
items
.iter()
.take(MAX_PROBLEMS)
.filter_map(|i| {
Some(Report {
line: i.get("line").and_then(Value::as_u64).and_then(|l| u32::try_from(l).ok()),
message: i.get("message")?.as_str()?.trim().to_owned(),
error: i.get("level").and_then(Value::as_str) != Some("warning"),
})
})
.collect(),
)
}
/// `reports` as diagnostics of `text`: on the named line (clamped to the
/// document), the whole of it; without a line, on the first.
pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
let lines: Vec<&str> = text.lines().collect();
reports
.iter()
.map(|r| {
let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32);
let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
Diagnostic {
range: Range::new(Position::new(line, 0), Position::new(line, len)),
severity: if r.error { Severity::Error } else { Severity::Warning },
message: r.message.clone(),
code: Some("graphviz".into()),
}
})
.collect()
}
#[derive(Default)]
pub struct Graphviz;
impl Plugin for Graphviz {
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
Err(Error::NotFound(format!("command {id}")))
}
}
impl Views for Graphviz {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
// The preview is the plugin's web page; nothing is rendered here.
Ok(View::message("The preview is shown by the plugin's page."))
}
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
let ViewEvent::Message(message) = event else { return Ok(()) };
let Some(reports) = parse_reports(&message) else { return Ok(()) };
let editor = ctx.editor().ok_or_else(|| Error::Invalid("the preview needs its document".into()))?;
let text = editor.document()?.text;
// A file outside the open projects has no problems to publish to; its
// preview shows what went wrong anyway.
match host::publish_diagnostics(&editor.path(), &diagnostics(&text, &reports)) {
Err(Error::Invalid(_)) => Ok(()),
other => other,
}
}
}
register_plugin!(Graphviz, views);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
#[test]
fn reports_become_problems_on_their_line() {
let msg = r#"{"op":"problems","items":[
{"line":2,"message":"syntax error in line 2 near '}'","level":"error"},
{"line":null,"message":"Warning: no such engine","level":"warning"},
{"line":99,"message":"past the end"},
{"nope":1}
]}"#;
let reports = parse_reports(msg).unwrap();
assert_eq!(reports.len(), 3);
let d = diagnostics("digraph {\n a -> }\n", &reports);
assert_eq!(
(d[0].range, d[0].severity),
(Range::new(Position::new(1, 0), Position::new(1, 8)), Severity::Error)
);
assert_eq!((d[1].range.start.line, d[1].severity), (0, Severity::Warning));
assert_eq!(d[2].range.start.line, 1, "clamped to the last line");
assert!(parse_reports(r#"{"op":"other"}"#).is_none());
assert!(parse_reports("garbage").is_none());
assert!(diagnostics("", &reports).iter().all(|d| d.range.start.line == 0));
}
#[test]
fn publishes_for_the_previewed_file_and_clears() {
let host = MockHost::install().with_capabilities(&["editor.read", "project.read"]);
host.open_editor("/project/a.dot", "dot", "digraph {\n a -> \n}\n", &[(0, 0, 0, 0)]);
let mut p = Graphviz;
let msg =
r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#;
p.handle(VIEW, ViewEvent::Message(msg.into()), &host.invocation()).unwrap();
assert_eq!(host.diagnostics()["/project/a.dot"][0].range.start.line, 2);
p.handle(VIEW, ViewEvent::Message(r#"{"op":"problems","items":[]}"#.into()), &host.invocation()).unwrap();
assert!(host.diagnostics().get("/project/a.dot").is_none_or(Vec::is_empty));
}
}
+11
Переглянути файл
@@ -0,0 +1,11 @@
digraph G {
rankdir=LR;
node [shape=box, style=rounded];
start [label="Start"];
work [label="Do the work"];
done [label="Done", shape=doublecircle];
start -> work -> done;
work -> work [label="again"];
}
+25
Переглянути файл
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Graphviz</title>
<link rel="stylesheet" href="./src/style.css" />
</head>
<body>
<div class="toolbar" role="toolbar" aria-label="Preview">
<select id="engine" title="Layout engine" aria-label="Layout engine"></select>
<span class="sep"></span>
<button id="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
<button id="zoom-reset" title="Actual size" aria-label="Actual size"><span id="zoom-label">100%</span></button>
<button id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
<button id="zoom-fit" title="Fit to window" aria-label="Fit to window">Fit</button>
<span class="sep"></span>
<button id="export-svg" title="Export as SVG…">SVG</button>
<button id="export-png" title="Export as PNG…">PNG</button>
</div>
<div id="stage" class="stage"><div id="canvas" class="canvas"></div></div>
<div id="banner" class="banner" role="status" hidden></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
+19
Переглянути файл
@@ -0,0 +1,19 @@
{
"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": {
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vitest": "^5.0.5"
}
}
+651
Переглянути файл
@@ -0,0 +1,651 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@viz-js/viz':
specifier: 3.31.0
version: 3.31.0
devDependencies:
typescript:
specifier: ~6.0.3
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.3.5
vitest:
specifier: ^5.0.5
version: 5.0.5([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==}
'@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-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]': {}
'@vitest/[email protected]([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
'@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]:
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.32
rolldown: 1.2.16
tinyglobby: 0.2.17
optionalDependencies:
fsevents: 2.3.3
[email protected]([email protected]):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.5([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
why-is-node-running: 3.2.1
transitivePeerDependencies:
- msw
[email protected]: {}
+213
Переглянути файл
@@ -0,0 +1,213 @@
/**
* The Graphviz plugin's preview: renders the `.dot` file it is opened beside
* (read only: the text editor next to it is where it changes) with viz.js,
* follows the IDE theme, zooms and pans, exports SVG and PNG through the
* IDE's save dialog, and sends what Graphviz reports to the plugin's code,
* which turns it into the file's problems.
*/
import { instance, type Viz } from "@viz-js/viz";
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
const RENDER_DELAY_MS = 150;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 8;
/** The most Fit enlarges a small graph to (opening a file never enlarges it). */
const MAX_FIT = 4;
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
function start(bridge: OpenideaBridge) {
const stage = $<HTMLDivElement>("stage");
const canvas = $<HTMLDivElement>("canvas");
const banner = $<HTMLDivElement>("banner");
const engineSelect = $<HTMLSelectElement>("engine");
const zoomLabel = $<HTMLSpanElement>("zoom-label");
for (const e of ENGINES) engineSelect.add(new Option(e, e));
let viz: Viz | null = null;
let text = "";
let name = "graph.dot";
let engine: Engine = "dot";
let zoom = 1;
let fitted = true;
// How far fitting may enlarge: 1 until the user asks for Fit.
let fitCap = 1;
let timer = 0;
let lastProblems = "";
const foreground = () => bridge.theme?.vars["--openidea-fg"] ?? null;
function setZoom(z: number, keepFit = false) {
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
fitted = keepFit;
// `zoom`, not a transform: the scroll area follows the scaled size.
canvas.style.zoom = String(zoom);
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
}
function fit() {
const svg = canvas.querySelector("svg");
if (!svg) return;
// Graphviz sizes its SVG in points; these are CSS pixels at 100%.
const w = svg.width.baseVal.value;
const h = svg.height.baseVal.value;
if (!w || !h) return;
// The canvas's padding (16px a side) scales with it; a pixel spare for rounding.
const pad = 32;
const z = Math.min((stage.clientWidth - 1) / (w + pad), (stage.clientHeight - 1) / (h + pad), fitCap);
setZoom(z > 0 && Number.isFinite(z) ? z : 1, true);
}
/** What Graphviz said, to the plugin (only when it changed: each one republishes the problems). */
function report(problems: ReturnType<typeof problemsOf>) {
const key = JSON.stringify(problems);
if (key === lastProblems) return;
lastProblems = key;
bridge.postMessage(JSON.stringify({ op: "problems", items: problems }));
}
function render() {
if (!viz) return;
const result = viz.render(text, renderOptions(engine, foreground()));
const problems = problemsOf(result);
report(problems);
if (result.status === "success") {
canvas.innerHTML = result.output;
canvas.classList.remove("stale");
banner.hidden = true;
if (fitted) fit();
} else {
// The last good graph stays, dimmed, under what went wrong.
canvas.classList.add("stale");
banner.hidden = false;
banner.textContent = problems.map((p) => (p.line ? `Line ${p.line}: ${p.message}` : p.message)).join("\n");
}
}
const schedule = (delay = RENDER_DELAY_MS) => {
clearTimeout(timer);
timer = window.setTimeout(render, delay);
};
/** The graph as Graphviz draws it, without the theme's defaults. */
function exportSvg(): string | null {
if (!viz) return null;
const result = viz.render(text, renderOptions(engine, null));
return result.status === "success" ? result.output : null;
}
/** Runs an export; a failure (no dialog, the image not drawn) shows in the banner. */
async function exporting(run: () => Promise<void>) {
try {
await run();
} catch (e) {
banner.hidden = false;
banner.textContent = `Export failed: ${e instanceof Error ? e.message : String(e)}`;
}
}
async function saveSvg() {
const svg = exportSvg();
if (!svg) return;
await bridge.saveFile({
name: exportName(name, "svg"),
data: svg,
filters: [{ name: "SVG image", extensions: ["svg"] }],
});
}
async function savePng() {
const svg = exportSvg();
if (!svg) return;
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("the graph could not be drawn as an image"));
img.src = url;
});
const scale = 2;
const c = document.createElement("canvas");
c.width = Math.ceil(img.naturalWidth * scale);
c.height = Math.ceil(img.naturalHeight * scale);
const ctx = c.getContext("2d")!;
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
const png = await new Promise<Blob | null>((resolve) => c.toBlob(resolve, "image/png"));
if (!png) throw new Error("the image could not be encoded");
await bridge.saveFile({
name: exportName(name, "png"),
data: png,
filters: [{ name: "PNG image", extensions: ["png"] }],
});
} finally {
URL.revokeObjectURL(url);
}
}
engineSelect.addEventListener("change", () => {
engine = engineSelect.value as Engine;
fitted = true;
schedule(0);
});
$("zoom-in").addEventListener("click", () => setZoom(zoom * 1.25));
$("zoom-out").addEventListener("click", () => setZoom(zoom / 1.25));
$("zoom-fit").addEventListener("click", () => {
fitCap = MAX_FIT;
fit();
});
$("zoom-reset").addEventListener("click", () => setZoom(1));
$("export-svg").addEventListener("click", () => void exporting(saveSvg));
$("export-png").addEventListener("click", () => void exporting(savePng));
// ⌘/Ctrl + wheel (and pinch) zooms; the wheel alone scrolls.
stage.addEventListener(
"wheel",
(e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
setZoom(zoom * Math.exp(-e.deltaY / 300));
},
{ passive: false },
);
// Dragging pans.
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
const [x, y, left, top] = [e.clientX, e.clientY, stage.scrollLeft, stage.scrollTop];
stage.setPointerCapture(e.pointerId);
stage.classList.add("panning");
const move = (m: PointerEvent) => {
stage.scrollLeft = left - (m.clientX - x);
stage.scrollTop = top - (m.clientY - y);
};
const up = () => {
stage.classList.remove("panning");
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerup", up);
};
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerup", up);
});
new ResizeObserver(() => fitted && fit()).observe(stage);
bridge.onThemeChange(() => schedule(0));
bridge.document.onChange((s) => {
text = s.text;
schedule();
});
void (async () => {
const [doc, v] = await Promise.all([bridge.document.get(), instance()]);
viz = v;
text = doc.text;
name = doc.name || name;
render();
})().catch((e: unknown) => {
banner.hidden = false;
banner.textContent = `The preview could not start: ${e instanceof Error ? e.message : String(e)}`;
});
}
const bridge = window.openidea;
if (bridge) start(bridge);
else document.body.textContent = "This page runs inside OpenIDEA.";
+40
Переглянути файл
@@ -0,0 +1,40 @@
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
interface OpenideaDocumentState {
version: number;
text: string;
}
interface OpenideaTextEdit {
from: number;
to: number;
insert: string;
}
interface OpenideaFileFilter {
name: string;
extensions: string[];
}
interface OpenideaBridge {
postMessage(text: string): void;
onMessage(fn: (text: string) => void): void;
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
document: {
/** `name`: the file's name, without its directory. */
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
onChange(fn: (state: OpenideaDocumentState) => void): void;
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
replace(version: number, text: string): Promise<{ version: number }>;
};
saveFile(options: {
name: string;
data: string | Uint8Array | ArrayBuffer | Blob;
filters?: OpenideaFileFilter[];
}): Promise<string | null>;
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
}
interface Window {
openidea?: OpenideaBridge;
}
+45
Переглянути файл
@@ -0,0 +1,45 @@
import { instance } from "@viz-js/viz";
import { describe, expect, it } from "vitest";
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
describe("the preview's rendering", () => {
it("renders with the theme's colour as a default the graph can override", async () => {
const viz = await instance();
const themed = viz.render('digraph { a -> b; c [color="red"] }', renderOptions("dot", "rgb(200, 200, 200)"));
expect(themed.status).toBe("success");
const svg = themed.status === "success" ? themed.output : "";
expect(svg).toContain('stroke="#c8c8c8"');
expect(svg).toContain('stroke="red"');
const plain = viz.render("digraph { a -> b }", renderOptions("dot", null));
expect(plain.status === "success" && plain.output).not.toContain("#c8c8c8");
});
it("names the line of a syntax error", async () => {
const viz = await instance();
const result = viz.render("digraph {\n a -> b\n c -> }\n", renderOptions("dot", null));
expect(result.status).toBe("failure");
const [p] = problemsOf(result);
expect(p).toMatchObject({ line: 3, level: "error" });
expect(p!.message).toMatch(/syntax error/);
});
it("offers only engines this Graphviz has", async () => {
const viz = await instance();
for (const e of ENGINES) expect(viz.engines).toContain(e);
});
it("turns the theme's colours into Graphviz's", () => {
expect(graphvizColor("rgb(200, 200, 200)")).toBe("#c8c8c8");
expect(graphvizColor("rgba(0, 128, 255, 0.5)")).toBe("#0080ff80");
expect(graphvizColor("rgb(1 2 3 / 50%)")).toBe("#01020380");
expect(graphvizColor("#ABCDEF")).toBe("#abcdef");
expect(graphvizColor("var(--x)")).toBeNull();
expect(renderOptions("dot", "nonsense").nodeAttributes).toBeUndefined();
});
it("names exports after the file", () => {
expect(exportName("flow.dot", "svg")).toBe("flow.svg");
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
expect(exportName("", "svg")).toBe("graph.svg");
});
});
+68
Переглянути файл
@@ -0,0 +1,68 @@
/**
* The preview's logic apart from the page: rendering options, Graphviz's
* messages as problems, export names. Kept free of the DOM so it is tested
* against the real Graphviz in Node.
*/
import type { RenderOptions, RenderResult } from "@viz-js/viz";
/** Layout engines offered, the default first. */
export const ENGINES = ["dot", "neato", "fdp", "sfdp", "circo", "twopi", "osage", "patchwork"] as const;
export type Engine = (typeof ENGINES)[number];
export interface Problem {
/** 1-based, when Graphviz names one. */
line: number | null;
message: string;
level: "error" | "warning";
}
/**
* Defaults a graph can still override: transparent background and the IDE's
* text colour, so an unstyled graph reads in a dark theme too. Exports use
* none of them (`null`): they look as Graphviz draws them.
*/
export function renderOptions(engine: Engine, foreground: string | null): RenderOptions {
const options: RenderOptions = { format: "svg", engine };
const color = foreground && graphvizColor(foreground);
if (color) {
options.graphAttributes = { bgcolor: "transparent", color, fontcolor: color };
options.nodeAttributes = { color, fontcolor: color };
options.edgeAttributes = { color, fontcolor: color };
}
return options;
}
/**
* A CSS colour as Graphviz reads it (`#rrggbb[aa]`): the theme's variables
* are `rgb()` / `rgba()`, which it doesn't know. `null` when not one.
*/
export function graphvizColor(css: string): string | null {
const hex = (n: number) =>
Math.round(Math.min(255, Math.max(0, n)))
.toString(16)
.padStart(2, "0");
const value = css.trim();
if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) return value.toLowerCase();
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/i.exec(value);
if (!m) return null;
const alpha = m[4] === undefined ? 1 : Number(m[4]) / (m[5] ? 100 : 1);
return `#${hex(+m[1]!)}${hex(+m[2]!)}${hex(+m[3]!)}${alpha < 1 ? hex(alpha * 255) : ""}`;
}
/** What Graphviz reported, as problems (on the line it names, if any). */
export function problemsOf(result: RenderResult): Problem[] {
return result.errors.map((e) => {
const line = /\bline (\d+)\b/.exec(e.message);
return {
line: line ? Number(line[1]) : null,
message: e.message.replace(/^(Error|Warning):\s*/i, "").trim(),
level: e.level === "warning" ? "warning" : "error",
};
});
}
/** The name an export starts with: the file's, without `.dot` / `.gv`. */
export function exportName(file: string, extension: string): string {
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
return `${base}.${extension}`;
}
+89
Переглянути файл
@@ -0,0 +1,89 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
color: var(--openidea-fg);
background: var(--openidea-bg, transparent);
}
.toolbar {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--openidea-border);
border-radius: 6px;
background: var(--openidea-bg-elevated, var(--openidea-bg));
}
.toolbar button,
.toolbar select {
height: 24px;
min-width: 24px;
padding: 0 6px;
font: inherit;
font-size: 12px;
color: var(--openidea-fg);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.toolbar select {
border-color: var(--openidea-border);
background: var(--openidea-bg-input, transparent);
}
.toolbar button:hover {
background: var(--openidea-bg-hover);
}
.toolbar .sep {
width: 1px;
height: 16px;
margin: 0 2px;
background: var(--openidea-border);
}
#zoom-label {
display: inline-block;
min-width: 36px;
font-variant-numeric: tabular-nums;
}
.stage {
position: absolute;
inset: 0;
overflow: auto;
cursor: grab;
}
.stage.panning {
cursor: grabbing;
}
.canvas {
display: inline-block;
padding: 16px;
user-select: none;
}
.canvas svg {
display: block;
}
.canvas.stale {
opacity: 0.35;
}
.banner {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
z-index: 1;
padding: 6px 8px;
white-space: pre-wrap;
font-family: var(--openidea-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--openidea-danger);
border: 1px solid color-mix(in srgb, var(--openidea-danger) 40%, transparent);
border-radius: 6px;
background: var(--openidea-bg-elevated, var(--openidea-bg));
}
+16
Переглянути файл
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+18
Переглянути файл
@@ -0,0 +1,18 @@
/**
* Builds the page into ../web (not checked in: plugins/build.sh builds it).
* Relative URLs throughout: the IDE serves the page from the plugin package
* under its own URL scheme.
*/
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
base: "./",
build: {
outDir: path.resolve(import.meta.dirname, "../web"),
emptyOutDir: true,
target: "es2022",
sourcemap: false,
chunkSizeWarningLimit: 4096,
},
});
+11 -9
Переглянути файл
@@ -1,12 +1,13 @@
# Theme plugin template
# Harbor Night
A complete OpenIDEA theme plugin with one worked example, **Harbor Night**: a
dark theme with slate surfaces, a teal accent and a warm syntax palette. Copy
this folder to start your own theme. Full documentation, the token reference
and porting guides are in [`docs/plugins/themes.md`](../../docs/plugins/themes.md).
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).
```
theme-plugin/
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
@@ -17,8 +18,9 @@ A theme plugin is data only: no code, no WebAssembly, no build step.
## Make your own
1. Copy the folder, for example to `~/openidea-themes/theme-my-theme`.
2. In `plugin.toml`, change `id` (lowercase, `a-z 0-9 - _`), `name`,
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).
@@ -49,5 +51,5 @@ JSON or values are reported in Settings › Plugins with the file and line.
## Licence
The template and Harbor Night are part of OpenIDEA (Apache-2.0). When you port
Harbor Night is part of OpenIDEA (Apache-2.0). When you port
someone else's theme, keep its licence and credit its authors.
+7 -6
Переглянути файл
@@ -1,12 +1,13 @@
# A theme plugin is data only: no `wasm`, no permissions, nothing to compile.
# Rename the id, name and files when you start your own theme from this template.
id = "theme-harbor-night"
name = "Harbor Night Theme"
# 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 = "1.0.0"
description = "Example dark theme: slate surfaces, a teal accent and a warm syntax palette."
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."
openidea-api = "0.1"
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).
File renamed without changes.
File renamed without changes.
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-plugin-openidea-license-header"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+21
Переглянути файл
@@ -0,0 +1,21 @@
[package]
name = "openidea-plugin-openidea-license-header"
version = "0.1.0"
edition = "2024"
publish = false
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3
+35
Переглянути файл
@@ -0,0 +1,35 @@
id = "openidea.license-header"
name = "License Header"
version = "0.1.0"
description = "Keeps a copyright and SPDX license header at the top of source files: files without one are reported when they open, a quick fix adds it, and a project scan lists every file that still needs one."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: missing headers reported on open and save, Add License Header quick fix and command, project scan in the background."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"]
[capabilities]
required = ["events", "project.read", "editor.read", "editor.write"]
[[contributes.providers]]
kinds = ["code-actions"]
languages = ["*"]
[[contributes.commands]]
id = "add"
title = "License Header: Add to Current File"
category = "License Header"
[[contributes.commands]]
id = "scan"
title = "License Header: Find Files Without a Header"
category = "License Header"
[[contributes.menus]]
menu = "tools"
command = "scan"
[contributes.settings]
title = "License Header"
schema = "settings.schema.json"
+21
Переглянути файл
@@ -0,0 +1,21 @@
{
"properties": {
"owner": {
"type": "string",
"title": "Copyright holder",
"description": "Written after the year, e.g. \"ACME Inc.\"; empty for none.",
"default": ""
},
"license": {
"type": "string",
"title": "License (SPDX identifier)",
"description": "e.g. Apache-2.0, MIT, GPL-3.0-or-later; empty for no SPDX line.",
"default": "Apache-2.0"
},
"reportOnOpen": {
"type": "boolean",
"title": "Report files without a header when they open",
"default": true
}
}
}
+395
Переглянути файл
@@ -0,0 +1,395 @@
//! License Header for OpenIDEA: a copyright and SPDX license header at the top
//! of source files, in the file's own comment syntax.
//!
//! - A file without one is reported (an info diagnostic on its first line)
//! when it opens, and again when it is saved, so adding the header clears it.
//! - The quick fix and *License Header: Add to Current File* insert it (after a
//! `#!` line or a Python encoding line, which must stay first).
//! - *Find Files Without a Header* scans the project in a background task and
//! reports every source file that needs one in the Problems view.
use openidea_plugin::host;
use openidea_plugin::providers::{CodeAction, Document, Providers};
use openidea_plugin::{
Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit,
register_plugin,
};
/// Lines looked at for an existing header.
const HEADER_LINES: usize = 15;
/// Files a scan reads at most.
const MAX_SCAN: u32 = 5000;
/// Files larger than this are skipped by a scan.
const MAX_FILE: usize = 1 << 20;
const CODE: &str = "missing-license-header";
/// How a file writes a header: line comments, or one block comment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Comment {
Line(&'static str),
Block(&'static str, &'static str),
}
/// The comment syntax for `path`, `None` for files that don't get a header.
pub fn comment_for(path: &str) -> Option<Comment> {
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
let lower = name.to_ascii_lowercase();
if matches!(lower.as_str(), "dockerfile" | "makefile" | "justfile") {
return Some(Comment::Line("#"));
}
let ext = lower.rsplit_once('.').map(|(_, e)| e)?;
Some(match ext {
"rs" | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" | "go" | "js" | "jsx" | "mjs" | "cjs" | "ts"
| "tsx" | "mts" | "cts" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" | "cs" | "swift"
| "dart" | "proto" | "sol" | "zig" | "v" => Comment::Line("//"),
"css" | "scss" | "less" => Comment::Block("/*", " */"),
"py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "r" | "ps1" | "yaml" | "yml" | "toml" | "cmake"
| "tf" | "nix" | "ex" | "exs" | "jl" => Comment::Line("#"),
"sql" | "lua" | "hs" | "elm" => Comment::Line("--"),
_ => return None,
})
}
/// Whether `text` already starts with a header (copyright or license notice).
pub fn has_header(text: &str) -> bool {
text.lines().take(HEADER_LINES).any(|l| {
let l = l.to_ascii_lowercase();
l.contains("copyright") || l.contains("spdx-license-identifier") || l.contains("licensed under")
})
}
/// The header's lines, without comment markers.
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
let owner = owner.trim();
let mut lines =
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
if !license.trim().is_empty() {
lines.push(format!("SPDX-License-Identifier: {}", license.trim()));
}
lines
}
/// The header as `comment` writes it, with a blank line after it.
pub fn render(comment: Comment, lines: &[String]) -> String {
let mut out = String::new();
match comment {
Comment::Line(prefix) => {
for l in lines {
out.push_str(&format!("{prefix} {l}\n"));
}
}
Comment::Block(open, close) => {
out.push_str(open);
out.push('\n');
for l in lines {
out.push_str(&format!(" * {l}\n"));
}
out.push_str(close);
out.push('\n');
}
}
out.push('\n');
out
}
/// The line the header goes on: after a `#!` line and a Python encoding line.
pub fn insert_line(text: &str) -> u32 {
let mut line = 0;
let mut lines = text.lines();
if lines.next().is_some_and(|l| l.starts_with("#!")) {
line = 1;
if lines.next().is_some_and(is_encoding_line) {
line = 2;
}
} else if text.lines().next().is_some_and(is_encoding_line) {
line = 1;
}
line
}
fn is_encoding_line(l: &str) -> bool {
l.starts_with('#') && (l.contains("coding:") || l.contains("coding="))
}
/// The edit that adds the header to `path`'s `text`, `None` when it has one or takes none.
pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str) -> Option<TextEdit> {
let comment = comment_for(path)?;
if has_header(text) {
return None;
}
let at = Position::new(insert_line(text), 0);
let mut insert = render(comment, &header_lines(year, owner, license));
// After a first line without a newline (a lone shebang).
if at.line > 0 && text.lines().count() == at.line as usize && !text.ends_with('\n') {
insert.insert(0, '\n');
}
Some(TextEdit { range: Range::new(at, at), text: insert })
}
fn missing(text: &str) -> Diagnostic {
let line = insert_line(text);
let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
Diagnostic {
range: Range::new(Position::new(line, 0), Position::new(line, len)),
severity: Severity::Info,
message: "No license header".into(),
code: Some(CODE.into()),
}
}
/// Reports `path` when it takes a header and has none, clears it otherwise.
pub fn report(path: &str, text: &str) -> Result<bool, Error> {
let lacking = comment_for(path).is_some() && !has_header(text);
host::publish_diagnostics(path, &if lacking { vec![missing(text)] } else { Vec::new() })?;
Ok(lacking)
}
/// The current year (WASI wall clock), from days since the epoch.
fn this_year() -> i64 {
let secs =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
year_of_day(secs.div_euclid(86_400))
}
/// The proleptic Gregorian year of `day` days since 1970-01-01.
pub fn year_of_day(day: i64) -> i64 {
let z = day + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
yoe + era * 400 + i64::from(month <= 2)
}
fn settings() -> (String, String) {
(
host::setting_string("owner").unwrap_or_default(),
host::setting_string("license").unwrap_or_else(|| "Apache-2.0".into()),
)
}
fn project_of(path: &str) -> Option<Project> {
host::projects()
.ok()?
.into_iter()
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
#[derive(Default)]
pub struct LicenseHeader;
impl LicenseHeader {
fn add_to_editor(&self, ctx: &Invocation) -> Result<Option<String>, Error> {
let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?;
let (path, doc) = (editor.path(), editor.document()?);
if comment_for(&path).is_none() {
return Err(Error::Invalid("this kind of file takes no license header".into()));
}
let (owner, license) = settings();
let Some(edit) = header_edit(&path, &doc.text, this_year(), &owner, &license) else {
return Ok(Some("The file already has a license header.".into()));
};
editor.apply_edits(doc.version, &[edit])?;
Ok(None)
}
/// Reports every source file of the project without a header; returns (checked, lacking).
fn scan(&self, project: &Project) -> Result<(u32, u32), Error> {
let files: Vec<String> = project
.find_files("**/*", MAX_SCAN * 4)?
.into_iter()
.filter(|f| comment_for(f).is_some())
.take(MAX_SCAN as usize)
.collect();
let total = files.len() as u32;
let task = host::begin_task("License Header: checking files", true);
let (mut checked, mut lacking) = (0, 0);
for (i, path) in files.iter().enumerate() {
if task.is_cancelled() {
break;
}
if i % 20 == 0 {
let name = path.rsplit('/').next().unwrap_or(path);
task.report(i as u32, Some(total), Some(name));
}
let Ok(text) = project.read_file(path) else { continue };
if text.len() > MAX_FILE {
continue;
}
checked += 1;
if report(path, &text)? {
lacking += 1;
}
}
Ok((checked, lacking))
}
}
impl Plugin for LicenseHeader {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
"add" => self.add_to_editor(ctx),
"scan" => {
let project = ctx.project().ok_or_else(|| Error::Invalid("open a project first".into()))?;
let (checked, lacking) = self.scan(&project)?;
let body = match lacking {
0 => format!("All {checked} source files have one."),
1 => format!("1 of {checked} source files has none; see the Problems view."),
n => format!("{n} of {checked} source files have none; see the Problems view."),
};
host::notify(NotificationLevel::Info, "License headers", &body);
Ok(None)
}
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
for e in events {
let (path, opened) = match e {
Event::FileOpened(p) => (p, true),
Event::FileSaved(p) => (p, false),
_ => continue,
};
if comment_for(path).is_none() || (opened && !host::setting_bool("reportOnOpen").unwrap_or(true)) {
continue;
}
let Some(project) = project_of(path) else { continue };
if let Ok(text) = project.read_file(path) {
let _ = report(path, &text);
}
}
}
}
impl Providers for LicenseHeader {
fn code_actions(&mut self, doc: &Document, _selection: Range) -> Vec<CodeAction> {
let (owner, license) = settings();
let Some(edit) = header_edit(&doc.path, &doc.text, this_year(), &owner, &license) else { return Vec::new() };
vec![CodeAction {
title: "Add license header".into(),
kind: Some("quickfix".into()),
preferred: true,
edits: vec![(doc.path.clone(), vec![edit])],
}]
}
}
register_plugin!(LicenseHeader, providers);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
fn doc(path: &str, text: &str) -> Document {
Document { path: path.into(), language: String::new(), version: 1, text: text.into() }
}
#[test]
fn writes_the_header_in_each_files_comment_syntax() {
let lines = header_lines(2026, "ACME Inc.", "MIT");
assert_eq!(
render(comment_for("a/b.rs").unwrap(), &lines),
"// Copyright (c) 2026 ACME Inc.\n// SPDX-License-Identifier: MIT\n\n"
);
assert_eq!(render(comment_for("x.py").unwrap(), &lines[..1]), "# Copyright (c) 2026 ACME Inc.\n\n");
assert_eq!(
render(comment_for("s.css").unwrap(), &lines),
"/*\n * Copyright (c) 2026 ACME Inc.\n * SPDX-License-Identifier: MIT\n */\n\n"
);
assert_eq!(comment_for("q.SQL"), Some(Comment::Line("--")));
assert_eq!(comment_for("Dockerfile"), Some(Comment::Line("#")));
assert_eq!(comment_for("README.md"), None);
assert_eq!(comment_for("noext"), None);
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
}
#[test]
fn keeps_shebang_and_encoding_lines_first() {
assert_eq!(insert_line("fn main() {}\n"), 0);
assert_eq!(insert_line("#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint()\n"), 2);
assert_eq!(insert_line("#!/bin/sh\necho\n"), 1);
assert_eq!(insert_line("# coding=latin-1\nx = 1\n"), 1);
let e = header_edit("run.sh", "#!/bin/sh", 2026, "", "").unwrap();
assert_eq!((e.range.start, e.text.as_str()), (Position::new(1, 0), "\n# Copyright (c) 2026\n\n"));
}
#[test]
fn an_existing_header_is_left_alone() {
assert!(has_header("// SPDX-License-Identifier: Apache-2.0\nfn x() {}"));
assert!(has_header("/*\n * Licensed under the MIT license\n */"));
assert!(has_header(&format!("{}// Copyright 2020\n", "\n".repeat(10))));
assert!(!has_header(&format!("{}// Copyright 2020\n", "\n".repeat(20))), "only the top of the file counts");
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
}
#[test]
fn years() {
assert_eq!(year_of_day(0), 1970);
assert_eq!(year_of_day(19_722), 2023); // 2023-12-31
assert_eq!(year_of_day(19_723), 2024); // 2024-01-01
assert_eq!(year_of_day(20_759), 2026);
}
#[test]
fn reports_on_open_and_save_and_fixes() {
let host = MockHost::install().with_capabilities(&["events", "project.read", "editor.read", "editor.write"]);
host.add_file("src/a.rs", "fn a() {}\n");
host.add_file("src/b.rs", "// Copyright (c) 2020 X\nfn b() {}\n");
let mut p = LicenseHeader;
p.handle_events(
&[Event::FileOpened("/project/src/a.rs".into()), Event::FileOpened("/project/src/b.rs".into())],
&host.invocation(),
);
let d = host.diagnostics();
assert_eq!(d["/project/src/a.rs"][0].code.as_deref(), Some(CODE));
assert!(d.get("/project/src/b.rs").is_none_or(Vec::is_empty));
// Off when opening, still on saving.
host.set_setting("reportOnOpen", "false");
host.add_file("src/c.rs", "fn c() {}\n");
p.handle_events(&[Event::FileOpened("/project/src/c.rs".into())], &host.invocation());
assert!(host.diagnostics().get("/project/src/c.rs").is_none_or(Vec::is_empty));
p.handle_events(&[Event::FileSaved("/project/src/c.rs".into())], &host.invocation());
assert_eq!(host.diagnostics()["/project/src/c.rs"].len(), 1);
host.set_setting("owner", "\"ACME\"");
let actions = p.code_actions(&doc("/project/src/a.rs", "fn a() {}\n"), Range::default());
assert_eq!(actions[0].title, "Add license header");
let (path, edits) = &actions[0].edits[0];
assert_eq!(path, "/project/src/a.rs");
assert!(
edits[0].text.starts_with(&format!(
"// Copyright (c) {} ACME\n// SPDX-License-Identifier: Apache-2.0\n",
this_year()
))
);
assert!(p.code_actions(&doc("/project/README.md", "x"), Range::default()).is_empty());
// The command edits the focused editor once.
host.open_editor("/project/src/a.rs", "rust", "fn a() {}\n", &[(0, 0, 0, 0)]);
assert_eq!(p.run_command("add", &host.invocation(), None).unwrap(), None);
assert!(has_header(&host.editor_text()));
assert!(p.run_command("add", &host.invocation(), None).unwrap().unwrap().contains("already"));
}
#[test]
fn scans_the_project_in_a_task() {
let host = MockHost::install().with_capabilities(&["events", "project.read"]);
host.add_file("src/a.rs", "fn a() {}\n");
host.add_file("src/b.py", "# Copyright 2024 X\n");
host.add_file("notes.md", "no header needed\n");
let mut p = LicenseHeader;
p.run_command("scan", &host.invocation(), None).unwrap();
let d = host.diagnostics();
assert_eq!(d["/project/src/a.rs"].len(), 1);
assert!(d.get("/project/src/b.py").is_none_or(Vec::is_empty));
assert!(!d.contains_key("/project/notes.md"));
assert_eq!(host.tasks().len(), 1);
assert!(host.notifications()[0].body.contains("1 of 2"), "{:?}", host.notifications());
}
}
+373
Переглянути файл
@@ -0,0 +1,373 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[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-regex-tester"
version = "0.1.0"
dependencies = [
"openidea-plugin",
"regex",
"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 = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[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"
+23
Переглянути файл
@@ -0,0 +1,23 @@
[package]
name = "openidea-plugin-openidea-regex-tester"
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" }
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa", "perf-onepass", "perf-backtrack", "perf-inline"] }
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
d2e35f3e4ee6b699899ea43fdf96d5ec99f0730c502f94fd6f3246e605be99a7
+32
Переглянути файл
@@ -0,0 +1,32 @@
id = "openidea.regex-tester"
name = "Regex Tester"
version = "0.1.0"
description = "Try a regular expression against sample text: matches highlighted as you type, capture groups, and a replacement preview. Takes the editor's selection as the text."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
change-notes = "0.1.0 — first release: live matching with flags, capture groups, replacement preview, the editor's selection as test text."
engines.openidea = "^1.0"
wasm = "dist/plugin.wasm"
activation = ["on-view:regex", "on-command:*"]
[capabilities]
required = ["editor.read"]
[[contributes.views]]
id = "regex"
title = "Regex Tester"
icon = "regex"
location = "bottom"
[[contributes.commands]]
id = "test-selection"
title = "Regex Tester: Test Selection"
category = "Regex Tester"
[[contributes.menus]]
menu = "editor.context"
command = "test-selection"
[contributes.settings]
title = "Regex Tester"
schema = "settings.schema.json"
+14
Переглянути файл
@@ -0,0 +1,14 @@
{
"properties": {
"ignoreCase": {
"type": "boolean",
"title": "Ignore case by default",
"default": false
},
"multiLine": {
"type": "boolean",
"title": "^ and $ match at line breaks by default",
"default": true
}
}
}
+280
Переглянути файл
@@ -0,0 +1,280 @@
//! Regex Tester for OpenIDEA: a tool window whose page (an `html` body) sends
//! the pattern, flags, text and replacement as you type; the plugin matches
//! them with Rust's `regex` (linear time, so no pattern can hang it) and posts
//! the matches back, in UTF-16 offsets the page can highlight. *Use Selection*
//! takes the focused editor's selection as the text.
use openidea_plugin::host;
use openidea_plugin::views::{Body, Button, View, ViewEvent, Viewport, Views};
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
use regex::RegexBuilder;
use serde_json::{Value, json};
pub const VIEW: &str = "regex";
/// Longest text matched (bytes).
const MAX_TEXT: usize = 1 << 20;
/// Most matches sent back (all are counted).
const MAX_MATCHES: usize = 1000;
/// Longest replacement preview sent back (bytes).
const MAX_REPLACED: usize = 256 << 10;
const PAGE: &str = include_str!("page.html");
#[derive(Default)]
pub struct RegexTester;
/// A request from the page.
#[derive(Debug, PartialEq)]
pub struct Run {
pub seq: u64,
pub pattern: String,
pub flags: String,
pub text: String,
pub replace: Option<String>,
}
impl Run {
pub fn parse(v: &Value) -> Option<Run> {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_owned);
Some(Run {
seq: v.get("seq").and_then(Value::as_u64).unwrap_or(0),
pattern: s("pattern")?,
flags: s("flags").unwrap_or_default(),
text: s("text").unwrap_or_default(),
replace: s("replace"),
})
}
}
/// UTF-16 offset of every byte offset that starts a char (and the end).
fn utf16_offsets(text: &str) -> Vec<u32> {
let mut map = vec![0u32; text.len() + 1];
let mut at = 0u32;
for (i, c) in text.char_indices() {
map[i] = at;
at += c.len_utf16() as u32;
}
map[text.len()] = at;
map
}
/// Matches `run` and returns the page's result message.
pub fn evaluate(run: &Run) -> Value {
let fail = |e: String| json!({ "op": "result", "seq": run.seq, "error": e, "matches": [], "count": 0 });
if run.text.len() > MAX_TEXT {
return fail(format!("the text is longer than {} KiB", MAX_TEXT >> 10));
}
let flag = |c: char| run.flags.contains(c);
let re = match RegexBuilder::new(&run.pattern)
.case_insensitive(flag('i'))
.multi_line(flag('m'))
.dot_matches_new_line(flag('s'))
.ignore_whitespace(flag('x'))
.size_limit(8 << 20)
.build()
{
Ok(re) => re,
// The parser's message names the position; keep it, minus the repeated pattern.
Err(e) => return fail(e.to_string()),
};
if run.pattern.is_empty() {
return json!({ "op": "result", "seq": run.seq, "error": null, "matches": [], "count": 0, "groups": [] });
}
let at = utf16_offsets(&run.text);
let names: Vec<Value> = re.capture_names().skip(1).map(|n| n.map_or(Value::Null, |n| json!(n))).collect();
let mut matches = Vec::new();
let mut count = 0usize;
for caps in re.captures_iter(&run.text) {
count += 1;
if matches.len() < MAX_MATCHES {
let groups: Vec<Value> = (1..caps.len())
.map(|i| caps.get(i).map_or(Value::Null, |g| json!([at[g.start()], at[g.end()]])))
.collect();
let m = caps.get(0).expect("group 0");
matches.push(json!({ "s": at[m.start()], "e": at[m.end()], "groups": groups }));
}
}
let replaced = run.replace.as_ref().map(|r| {
let mut out = re.replace_all(&run.text, r.as_str()).into_owned();
if out.len() > MAX_REPLACED {
let mut cut = MAX_REPLACED;
while !out.is_char_boundary(cut) {
cut -= 1;
}
out.truncate(cut);
out.push('…');
}
out
});
json!({
"op": "result",
"seq": run.seq,
"error": null,
"matches": matches,
"count": count,
"groups": names,
"replaced": replaced,
})
}
/// The flags the page starts with, from the settings.
fn default_flags() -> String {
let mut f = String::new();
if host::setting_bool("ignoreCase").unwrap_or(false) {
f.push('i');
}
if host::setting_bool("multiLine").unwrap_or(true) {
f.push('m');
}
f
}
/// The focused editor's first non-empty selection, else its whole text.
fn selection_text(ctx: &Invocation) -> Result<String, Error> {
let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?;
let doc = editor.document()?;
let selected = editor.selections()?.into_iter().filter_map(|r| doc.text_in(r)).find(|t| !t.is_empty());
let text = selected.map(str::to_owned).unwrap_or(doc.text);
Ok(if text.len() > MAX_TEXT {
let mut cut = MAX_TEXT;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text[..cut].to_owned()
} else {
text
})
}
fn post(message: &Value) -> Result<(), Error> {
host::post_view_message(VIEW, &message.to_string())
}
impl Plugin for RegexTester {
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
match id {
// To the open tool window (its page ignores it until it has loaded).
"test-selection" => post(&json!({ "op": "text", "text": selection_text(ctx)? })).map(|()| None),
_ => Err(Error::NotFound(format!("command {id}"))),
}
}
}
impl Views for RegexTester {
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
// The page keeps its own state: the plugin only answers its messages,
// and never re-renders it (that would reload the frame).
let start = json!({ "flags": default_flags() }).to_string().replace('<', "\\u003c");
let page = PAGE.replacen(r#""__START__""#, &start, 1);
Ok(View::new(Body::Html(page)).button(Button::new("selection", "Use Selection").icon("text")))
}
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
if view != VIEW {
return Err(Error::NotFound(view.into()));
}
match event {
ViewEvent::Button(b) if b == "selection" => post(&json!({ "op": "text", "text": selection_text(ctx)? })),
ViewEvent::Message(text) => {
let Ok(v) = serde_json::from_str::<Value>(&text) else { return Ok(()) };
match v.get("op").and_then(Value::as_str) {
Some("run") => match Run::parse(&v) {
Some(run) => post(&evaluate(&run)),
None => Ok(()),
},
Some("selection") => post(&json!({ "op": "text", "text": selection_text(ctx)? })),
_ => Ok(()),
}
}
_ => Ok(()),
}
}
}
register_plugin!(RegexTester, views);
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::mock::MockHost;
fn run(pattern: &str, flags: &str, text: &str, replace: Option<&str>) -> Value {
evaluate(&Run {
seq: 7,
pattern: pattern.into(),
flags: flags.into(),
text: text.into(),
replace: replace.map(str::to_owned),
})
}
#[test]
fn matches_with_groups_in_utf16_offsets() {
// "é" is one UTF-16 unit but two bytes; "😀" two units, four bytes.
let r = run(r"(?P<word>\w+)@(\d+)", "", "é😀 ab@12 cd@3", None);
assert_eq!(r["error"], Value::Null);
assert_eq!(r["count"], 2);
assert_eq!(r["seq"], 7);
assert_eq!(r["groups"], json!(["word", null]));
assert_eq!(r["matches"][0], json!({ "s": 4, "e": 9, "groups": [[4, 6], [7, 9]] }));
assert_eq!(r["matches"][1]["s"], 10);
}
#[test]
fn flags_change_the_match() {
let text = "One\none";
assert_eq!(run("^one$", "", text, None)["count"], 0);
assert_eq!(run("^one$", "m", text, None)["count"], 1);
assert_eq!(run("^one$", "im", text, None)["count"], 2);
assert_eq!(run("e.o", "", text, None)["count"], 0);
assert_eq!(run("e.o", "s", text, None)["count"], 1);
assert_eq!(run("o n e", "x", text, None)["count"], 1);
}
#[test]
fn replacement_preview_and_errors() {
let r = run(r"(\w+)@(\d+)", "", "ab@12, cd@3", Some("$2:$1"));
assert_eq!(r["replaced"], "12:ab, 3:cd");
let bad = run("(unclosed", "", "x", None);
assert!(bad["error"].as_str().unwrap().contains("unclosed"), "{bad}");
assert_eq!(bad["count"], 0);
assert_eq!(run("", "", "x", None)["count"], 0);
}
#[test]
fn counts_every_match_but_sends_a_bounded_number() {
let text = "a".repeat(5000);
let r = run("a", "", &text, None);
assert_eq!(r["count"], 5000);
assert_eq!(r["matches"].as_array().unwrap().len(), MAX_MATCHES);
}
#[test]
fn answers_the_page_and_reads_the_selection() {
let host = MockHost::install().with_capabilities(&["editor.read"]);
host.set_setting("ignoreCase", "true");
let mut p = RegexTester;
let View { body: Body::Html(page), toolbar, .. } = p.render(VIEW, None, &host.invocation()).unwrap() else {
panic!("an html body")
};
assert!(page.contains(r#"{"flags":"im"}"#), "the settings' flags start the page");
assert_eq!(toolbar[0].id, "selection");
let msg = json!({ "op": "run", "seq": 1, "pattern": "b+", "flags": "", "text": "abbc" }).to_string();
p.handle(VIEW, ViewEvent::Message(msg), &host.invocation()).unwrap();
let (view, reply) = host.view_messages().pop().unwrap();
assert_eq!(view, VIEW);
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!((reply["count"].clone(), reply["matches"][0]["s"].clone()), (json!(1), json!(1)));
host.open_editor("/p/a.txt", "plaintext", "first line\nsecond", &[(1, 0, 1, 6)]);
p.handle(VIEW, ViewEvent::Button("selection".into()), &host.invocation()).unwrap();
let reply: Value = serde_json::from_str(&host.view_messages().pop().unwrap().1).unwrap();
assert_eq!(reply, json!({ "op": "text", "text": "second" }));
// Garbage from the page is ignored.
p.handle(VIEW, ViewEvent::Message("not json".into()), &host.invocation()).unwrap();
}
}
+377
Переглянути файл
@@ -0,0 +1,377 @@
<style>
body {
margin: 0;
}
.rt {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 8px;
padding: 8px;
height: calc(100vh - 16px);
box-sizing: border-box;
}
.col {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
}
.row {
display: flex;
gap: 6px;
align-items: center;
}
label.caption {
color: var(--openidea-fg-muted);
font-size: 11px;
}
input[type="text"],
textarea {
font-family: var(--openidea-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--openidea-fg);
background: var(--openidea-bg-input, transparent);
border: 1px solid var(--openidea-border);
border-radius: 4px;
padding: 4px 6px;
outline: none;
box-sizing: border-box;
}
input[type="text"]:focus,
textarea:focus {
border-color: var(--openidea-focus-ring, var(--openidea-accent));
}
input.pattern {
flex: 1;
}
input.invalid {
border-color: var(--openidea-danger);
}
.flags {
display: flex;
gap: 2px;
}
.flags button {
font-family: var(--openidea-font-mono, monospace);
font-size: 12px;
width: 24px;
height: 24px;
padding: 0;
color: var(--openidea-fg-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.flags button:hover {
background: var(--openidea-bg-hover);
}
.flags button[aria-pressed="true"] {
color: var(--openidea-fg-on-accent);
background: var(--openidea-accent);
}
.status {
font-size: 11px;
color: var(--openidea-fg-muted);
min-height: 14px;
}
.status.error {
color: var(--openidea-danger);
}
/* The text: a transparent textarea over a backdrop that draws the matches. */
.editor {
position: relative;
flex: 1;
min-height: 60px;
}
.editor textarea,
.editor .backdrop {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--openidea-font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.5;
padding: 4px 6px;
box-sizing: border-box;
border: 1px solid transparent;
overflow: auto;
}
.editor textarea {
background: transparent;
resize: none;
border-color: var(--openidea-border);
caret-color: var(--openidea-fg);
}
.editor .backdrop {
color: transparent;
pointer-events: none;
}
mark {
color: transparent;
border-radius: 2px;
background: color-mix(in srgb, var(--openidea-accent) 35%, transparent);
}
mark.alt {
background: color-mix(in srgb, var(--openidea-info, var(--openidea-accent)) 30%, transparent);
}
mark.current {
outline: 1px solid var(--openidea-accent);
}
.replaced {
flex: 1;
min-height: 40px;
margin: 0;
white-space: pre-wrap;
overflow: auto;
font-family: var(--openidea-font-mono, monospace);
font-size: 12px;
border: 1px solid var(--openidea-border);
border-radius: 4px;
padding: 4px 6px;
color: var(--openidea-fg-strong);
}
.list {
flex: 2;
overflow: auto;
border: 1px solid var(--openidea-border);
border-radius: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th,
td {
text-align: left;
padding: 2px 6px;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: var(--openidea-bg-elevated, var(--openidea-bg));
color: var(--openidea-fg-muted);
font-weight: normal;
}
td.mono {
font-family: var(--openidea-font-mono, monospace);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
td.none {
color: var(--openidea-fg-muted);
}
tr.match {
cursor: pointer;
}
tr.match:hover {
background: var(--openidea-bg-hover);
}
tr.match.current {
background: var(--openidea-bg-selected);
}
.empty {
padding: 8px;
color: var(--openidea-fg-muted);
font-size: 12px;
}
</style>
<div class="rt">
<div class="col">
<label class="caption" for="pattern">Regular expression (Rust syntax)</label>
<div class="row">
<input
id="pattern"
class="pattern"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="e.g. (?P&lt;key&gt;\w+)=(\d+)"
/>
<div class="flags" role="group" aria-label="Flags">
<button data-flag="i" title="Ignore case (i)">i</button>
<button data-flag="m" title="^ and $ match at line breaks (m)">m</button>
<button data-flag="s" title=". matches line breaks (s)">s</button>
<button data-flag="x" title="Ignore whitespace and # comments in the pattern (x)">x</button>
</div>
</div>
<div id="status" class="status"></div>
<label class="caption" for="text">Text</label>
<div class="editor">
<div id="backdrop" class="backdrop" aria-hidden="true"></div>
<textarea
id="text"
spellcheck="false"
placeholder="Text to match. Use Selection takes the editor's selection."
></textarea>
</div>
<label class="caption" for="replace">Replace with ($1, ${name}, $$ for $)</label>
<input
id="replace"
type="text"
spellcheck="false"
autocomplete="off"
placeholder="Leave empty for no replacement"
/>
<pre id="replaced" class="replaced" hidden></pre>
</div>
<div class="col">
<label class="caption">Matches</label>
<div id="list" class="list"><div class="empty">No matches yet.</div></div>
</div>
</div>
<script>
(() => {
// Replaced by the plugin with its start state (a JSON object); a string the formatter leaves alone.
const START = "__START__";
const $ = (id) => document.getElementById(id);
const pattern = $("pattern"),
text = $("text"),
replace = $("replace");
const backdrop = $("backdrop"),
status = $("status"),
list = $("list"),
replaced = $("replaced");
const flags = new Set((typeof START === "object" && START.flags) || "m");
let seq = 0,
timer = 0,
current = -1,
last = null;
for (const b of document.querySelectorAll(".flags button")) {
b.setAttribute("aria-pressed", String(flags.has(b.dataset.flag)));
b.addEventListener("click", () => {
const f = b.dataset.flag;
flags.has(f) ? flags.delete(f) : flags.add(f);
b.setAttribute("aria-pressed", String(flags.has(f)));
schedule(0);
});
}
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
function schedule(delay = 120) {
clearTimeout(timer);
timer = setTimeout(run, delay);
}
function run() {
seq += 1;
openidea.postMessage(
JSON.stringify({
op: "run",
seq,
pattern: pattern.value,
flags: [...flags].sort().join(""),
text: text.value,
replace: replace.value === "" ? null : replace.value,
}),
);
}
function drawBackdrop() {
const src = text.value;
if (!last || last.error || !last.matches.length) {
backdrop.innerHTML = escape(src) + "\n";
return;
}
let out = "",
at = 0;
last.matches.forEach((m, i) => {
out += escape(src.slice(at, m.s));
const cls = (i % 2 ? "alt" : "") + (i === current ? " current" : "");
// An empty match still shows where it is.
out += `<mark class="${cls}">${m.e > m.s ? escape(src.slice(m.s, m.e)) : "​"}</mark>`;
at = m.e;
});
backdrop.innerHTML = out + escape(src.slice(at)) + "\n";
backdrop.scrollTop = text.scrollTop;
}
function drawList() {
if (!last || last.error || !last.matches.length) {
list.innerHTML = `<div class="empty">${last && !last.error && pattern.value ? "No matches." : "No matches yet."}</div>`;
return;
}
const src = text.value;
const names = last.groups || [];
const head = names.map((n, i) => `<th>${n === null ? "$" + (i + 1) : escape(n)}</th>`).join("");
const rows = last.matches
.map((m, i) => {
const groups = m.groups
.map((g) =>
g === null ? `<td class="none">–</td>` : `<td class="mono">${escape(src.slice(g[0], g[1]))}</td>`,
)
.join("");
return `<tr class="match${i === current ? " current" : ""}" data-i="${i}"><td>${i + 1}</td><td class="mono">${escape(src.slice(m.s, m.e))}</td>${groups}</tr>`;
})
.join("");
list.innerHTML = `<table><thead><tr><th>#</th><th>Match</th>${head}</tr></thead><tbody>${rows}</tbody></table>`;
}
function select(i) {
current = i;
const m = last.matches[i];
text.focus();
text.setSelectionRange(m.s, m.e);
drawBackdrop();
drawList();
}
list.addEventListener("click", (e) => {
const row = e.target.closest("tr.match");
if (row) select(Number(row.dataset.i));
});
openidea.onMessage((raw) => {
let m;
try {
m = JSON.parse(raw);
} catch {
return;
}
if (m.op === "text") {
text.value = m.text;
schedule(0);
} else if (m.op === "result" && m.seq === seq) {
last = m;
current = -1;
pattern.classList.toggle("invalid", !!m.error);
status.classList.toggle("error", !!m.error);
status.textContent = m.error
? m.error
.split("\n")
.filter((l) => l.trim())
.pop()
: pattern.value
? (m.count === 1 ? "1 match" : `${m.count} matches`) +
(m.count > m.matches.length ? ` (first ${m.matches.length} shown)` : "")
: "";
replaced.hidden = m.replaced == null;
if (m.replaced != null) replaced.textContent = m.replaced;
drawBackdrop();
drawList();
}
});
pattern.addEventListener("input", () => schedule());
replace.addEventListener("input", () => schedule());
// Offsets of the last result no longer fit the text: plain until the next one.
text.addEventListener("input", () => {
last = null;
drawBackdrop();
schedule();
});
text.addEventListener("scroll", () => {
backdrop.scrollTop = text.scrollTop;
});
pattern.focus();
drawBackdrop();
})();
</script>
+3 -2
Переглянути файл
@@ -14,7 +14,8 @@ const read = (p: string) => readFileSync(p, "utf8");
const css = read("src/styles/tokens.css");
const groups = themeTokenGroups(css);
const sets = tokenSets(groups);
const TEMPLATE = "templates/theme-plugin";
// The official Harbor Night theme, where new themes start (`openidea-plugin new --template theme`).
const TEMPLATE = "plugins/official/harbor-night";
const themeSrc = read(`${TEMPLATE}/themes/harbor-night.theme.json`);
const schemeSrc = read(`${TEMPLATE}/themes/harbor-night.scheme.json`);
const theme = JSON.parse(stripJsonComments(themeSrc)) as { id: string; kind: string; tokens: Record<string, string> };
@@ -47,7 +48,7 @@ describe("theme token reference", () => {
describe("theme plugin template", () => {
it("sets every UI token in the theme file and every editor token in the scheme file", () => {
// A failure here means tokens.css gained (or lost) a themeable token:
// add it to templates/theme-plugin/themes/*.json, then run pnpm gen:theme-docs.
// add it to plugins/official/harbor-night/themes/*.json, then run pnpm gen:theme-docs.
expect(sorted(Object.keys(theme.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(scheme.tokens))).toEqual(sorted(sets.scheme));
});
+7 -4
Переглянути файл
@@ -382,7 +382,8 @@ pub async fn plugin_line_markers(
ide_host::ops::lsp::plugin_line_markers(&state.host, window.label(), doc).await
}
/// Content of a plugin view (`doc`: the document of a custom editor).
/// Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
/// a tool window, the window's active editor, as commands get it).
#[tauri::command]
#[specta::specta]
pub async fn plugin_view_render(
@@ -392,12 +393,13 @@ pub async fn plugin_view_render(
view: String,
viewport: Option<ide_plugin::Viewport>,
doc: Option<ide_core::DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<ide_plugin::View> {
let plugins = state.plugins.clone();
ide_host::plugins::views::render(&state.host, plugins, window.label(), plugin_id, view, viewport, doc).await
ide_host::plugins::views::render(&state.host, plugins, window.label(), plugin_id, view, viewport, doc, editor).await
}
/// A user action in a plugin view or custom editor.
/// A user action in a plugin view or custom editor (`doc`, `editor`: as for rendering).
#[tauri::command]
#[specta::specta]
pub async fn plugin_view_event(
@@ -407,9 +409,10 @@ pub async fn plugin_view_event(
view: String,
event: ide_plugin::ViewEvent,
doc: Option<ide_core::DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<()> {
let plugins = state.plugins.clone();
ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc).await
ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc, editor).await
}
/// The page of a plugin's web view or custom editor (`web = …`).
+6 -3
Переглянути файл
@@ -26,6 +26,7 @@ import type {
} from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
import { editorContext } from "./editorContext";
export interface PluginsBackend {
list(): Promise<PluginsSnapshot>;
@@ -165,9 +166,11 @@ export const tauriPluginsBackend: PluginsBackend = {
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
renderView: (pluginId, view, viewport, doc) => call(commands.pluginViewRender(pluginId, view, viewport, doc)),
// A tool window's calls (no `doc`) carry the window's active editor.
renderView: (pluginId, view, viewport, doc) =>
call(commands.pluginViewRender(pluginId, view, viewport, doc, doc == null ? editorContext() : null)),
viewEvent: async (pluginId, view, event, doc) => {
await call(commands.pluginViewEvent(pluginId, view, event, doc));
await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null));
},
webPageUrl: async (pluginId, view) => {
const page = await call(commands.pluginWebPage(pluginId, view));
@@ -230,7 +233,7 @@ function ensureSubscribed() {
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
// templates/theme-plugin, abridged.
// plugins/official/harbor-night, abridged.
const SAMPLE_THEMES: ActiveTheme["theme"][] = [
{
id: "harbor-night",
+23
Переглянути файл
@@ -0,0 +1,23 @@
/**
* The editor a plugin call is about: the window's active text editor, as
* commands get it (path, language, selections in line / UTF-16 column). Tool
* windows' renders and events carry it too, so their buttons act on what the
* user is editing; a custom editor's calls carry its own document instead.
*/
import type { PluginEditor } from "@/bindings";
import { activeEditor } from "@/editor/api";
export function editorContext(): PluginEditor | null {
const editor = activeEditor();
if (!editor?.ctx.path) return null;
const { doc, selection } = editor.view.state;
const pos = (offset: number) => {
const line = doc.lineAt(offset);
return { line: line.number - 1, col: offset - line.from };
};
return {
path: editor.ctx.path,
language: editor.ctx.language,
selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })),
};
}
+2 -19
Переглянути файл
@@ -6,14 +6,13 @@ import type {
CatalogPlugin,
Consent,
MarketPlugin,
PluginEditor,
PluginInfo,
PluginStatus,
UiContribution,
} from "@/bindings";
import { activeEditor } from "@/editor/api";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend, type PluginsBackend } from "./backend";
import { editorContext } from "./editorContext";
export const SETTINGS_PAGE_ID = "plugins";
@@ -335,27 +334,11 @@ export function commandTitle(pluginId: string, commandId: string): string {
return typeof title === "string" ? title : commandId;
}
/** The focused text editor as a command's context: path, language, selections (line / UTF-16 column). */
function focusedEditor(): PluginEditor | null {
const editor = activeEditor();
if (!editor?.ctx.path) return null;
const { doc, selection } = editor.view.state;
const pos = (offset: number) => {
const line = doc.lineAt(offset);
return { line: line.number - 1, col: offset - line.from };
};
return {
path: editor.ctx.path,
language: editor.ctx.language,
selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })),
};
}
/** Runs a plugin command from an action; failures become notifications. */
export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) {
const title = commandTitle(pluginId, commandId);
try {
return await pluginsBackend().runCommand(pluginId, commandId, arg, focusedEditor());
return await pluginsBackend().runCommand(pluginId, commandId, arg, editorContext());
} catch (e) {
await refreshPlugins();
const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status;