дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
3b78d15d6d
коміт
ac71b7008e
54 files changed
+3966
-99
No files matched your search
@@ -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__"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
web-src/node_modules/
|
||||
# Built by plugins/build.sh, not checked in.
|
||||
web/
|
||||
web.sources
|
||||
Створено
+334
@@ -0,0 +1,334 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-openidea-graphviz"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "openidea-plugin-openidea-graphviz"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,28 @@
|
||||
# Third-party software
|
||||
|
||||
The page in `web/` is built from `web-src/` and bundles:
|
||||
|
||||
- **Viz.js** (`@viz-js/viz` 3.31.0), MIT License, Copyright (c) Michael Daines —
|
||||
https://github.com/mdaines/viz-js
|
||||
- **Graphviz** 16.1.0, compiled to WebAssembly inside Viz.js, Eclipse Public License 1.0 —
|
||||
https://graphviz.org, https://gitlab.com/graphviz/graphviz
|
||||
|
||||
The MIT License:
|
||||
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
The Eclipse Public License 1.0 is at https://www.eclipse.org/legal/epl-v10.html. Graphviz's
|
||||
source code is available from the links above.
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
de353be96a6b66629901f55a8027a0f27107a7ac2fb9d891089db1b7fd716299
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M5.2 5.6 7 10M10.8 5.6 9 10M6 4h4" stroke="#3574F0" stroke-width="1.1" stroke-linecap="round"/>
|
||||
<circle cx="4" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
|
||||
<circle cx="12" cy="4" r="2.5" fill="#E7EFFD" stroke="#3574F0"/>
|
||||
<circle cx="8" cy="12" r="2.5" fill="#3574F0" stroke="#3574F0"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 410 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M5.2 5.6 7 10M10.8 5.6 9 10M6 4h4" stroke="#548AF7" stroke-width="1.1" stroke-linecap="round"/>
|
||||
<circle cx="4" cy="4" r="2.5" fill="#25324D" stroke="#548AF7"/>
|
||||
<circle cx="12" cy="4" r="2.5" fill="#25324D" stroke="#548AF7"/>
|
||||
<circle cx="8" cy="12" r="2.5" fill="#548AF7" stroke="#548AF7"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 410 B |
@@ -0,0 +1,8 @@
|
||||
id = "dot"
|
||||
name = "DOT"
|
||||
extensions = ["dot", "gv"]
|
||||
line-comment = "//"
|
||||
block-comment = ["/*", "*/"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
# No DOT mode in CodeMirror: C's highlights the strings, comments, numbers and braces.
|
||||
ui-mode = "C"
|
||||
@@ -0,0 +1,38 @@
|
||||
id = "openidea.graphviz"
|
||||
name = "Graphviz"
|
||||
version = "0.1.0"
|
||||
description = "Graphviz DOT files (.dot, .gv) with a live preview beside the text: rendered as you type with any layout engine, syntax errors in the editor and the Problems view, export to SVG and PNG."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
license = "Apache-2.0"
|
||||
change-notes = "0.1.0 — first release: live preview (Graphviz compiled to WebAssembly), layout engines, zoom, errors as problems, SVG and PNG export."
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-view:preview"]
|
||||
|
||||
[capabilities]
|
||||
# Problems: published for the project's file (project.read), on the lines
|
||||
# Graphviz names (editor.read: the previewed text).
|
||||
required = ["editor.read", "project.read"]
|
||||
|
||||
[[contributes.customEditors]]
|
||||
id = "preview"
|
||||
title = "Preview"
|
||||
patterns = ["*.dot", "*.gv"]
|
||||
priority = "default"
|
||||
layout = "split"
|
||||
web = "web/index.html"
|
||||
|
||||
[[contributes.languages]]
|
||||
path = "languages/dot.toml"
|
||||
|
||||
[[contributes.fileIcons]]
|
||||
id = "graphviz"
|
||||
patterns = ["*.dot", "*.gv"]
|
||||
icon = "icons/graphviz.svg"
|
||||
iconDark = "icons/graphviz_dark.svg"
|
||||
|
||||
[[contributes.fileTemplates]]
|
||||
id = "graph"
|
||||
name = "Graphviz Graph"
|
||||
extension = "dot"
|
||||
path = "templates/graph.dot"
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Graphviz for OpenIDEA: `.dot` / `.gv` files open in the text editor with a
|
||||
//! live preview beside them. The preview is a web page (`web-src/`: viz.js,
|
||||
//! Graphviz compiled to WebAssembly) that renders the document as it changes;
|
||||
//! what Graphviz reports it sends here, and the plugin publishes it as the
|
||||
//! file's problems, on the line Graphviz names.
|
||||
|
||||
use openidea_plugin::views::{View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Diagnostic, Error, Invocation, Plugin, Position, Range, Severity, host, register_plugin};
|
||||
use serde_json::Value;
|
||||
|
||||
pub const VIEW: &str = "preview";
|
||||
/// Problems published for one file at most.
|
||||
const MAX_PROBLEMS: usize = 100;
|
||||
|
||||
/// One message from Graphviz, as the page reports it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// 1-based, when Graphviz names one.
|
||||
pub line: Option<u32>,
|
||||
pub message: String,
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
/// The page's `{"op": "problems", "items": [{"line", "message", "level"}]}`.
|
||||
pub fn parse_reports(message: &str) -> Option<Vec<Report>> {
|
||||
let v: Value = serde_json::from_str(message).ok()?;
|
||||
if v.get("op").and_then(Value::as_str) != Some("problems") {
|
||||
return None;
|
||||
}
|
||||
let items = v.get("items")?.as_array()?;
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.take(MAX_PROBLEMS)
|
||||
.filter_map(|i| {
|
||||
Some(Report {
|
||||
line: i.get("line").and_then(Value::as_u64).and_then(|l| u32::try_from(l).ok()),
|
||||
message: i.get("message")?.as_str()?.trim().to_owned(),
|
||||
error: i.get("level").and_then(Value::as_str) != Some("warning"),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `reports` as diagnostics of `text`: on the named line (clamped to the
|
||||
/// document), the whole of it; without a line, on the first.
|
||||
pub fn diagnostics(text: &str, reports: &[Report]) -> Vec<Diagnostic> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
reports
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let line = r.line.map_or(0, |l| l.saturating_sub(1)).min(lines.len().saturating_sub(1) as u32);
|
||||
let len = lines.get(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: if r.error { Severity::Error } else { Severity::Warning },
|
||||
message: r.message.clone(),
|
||||
code: Some("graphviz".into()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Graphviz;
|
||||
|
||||
impl Plugin for Graphviz {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(format!("command {id}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Views for Graphviz {
|
||||
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
// The preview is the plugin's web page; nothing is rendered here.
|
||||
Ok(View::message("The preview is shown by the plugin's page."))
|
||||
}
|
||||
|
||||
fn handle(&mut self, view: &str, event: ViewEvent, ctx: &Invocation) -> Result<(), Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
let ViewEvent::Message(message) = event else { return Ok(()) };
|
||||
let Some(reports) = parse_reports(&message) else { return Ok(()) };
|
||||
let editor = ctx.editor().ok_or_else(|| Error::Invalid("the preview needs its document".into()))?;
|
||||
let text = editor.document()?.text;
|
||||
// A file outside the open projects has no problems to publish to; its
|
||||
// preview shows what went wrong anyway.
|
||||
match host::publish_diagnostics(&editor.path(), &diagnostics(&text, &reports)) {
|
||||
Err(Error::Invalid(_)) => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Graphviz, views);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::mock::MockHost;
|
||||
|
||||
#[test]
|
||||
fn reports_become_problems_on_their_line() {
|
||||
let msg = r#"{"op":"problems","items":[
|
||||
{"line":2,"message":"syntax error in line 2 near '}'","level":"error"},
|
||||
{"line":null,"message":"Warning: no such engine","level":"warning"},
|
||||
{"line":99,"message":"past the end"},
|
||||
{"nope":1}
|
||||
]}"#;
|
||||
let reports = parse_reports(msg).unwrap();
|
||||
assert_eq!(reports.len(), 3);
|
||||
let d = diagnostics("digraph {\n a -> }\n", &reports);
|
||||
assert_eq!(
|
||||
(d[0].range, d[0].severity),
|
||||
(Range::new(Position::new(1, 0), Position::new(1, 8)), Severity::Error)
|
||||
);
|
||||
assert_eq!((d[1].range.start.line, d[1].severity), (0, Severity::Warning));
|
||||
assert_eq!(d[2].range.start.line, 1, "clamped to the last line");
|
||||
assert!(parse_reports(r#"{"op":"other"}"#).is_none());
|
||||
assert!(parse_reports("garbage").is_none());
|
||||
assert!(diagnostics("", &reports).iter().all(|d| d.range.start.line == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_for_the_previewed_file_and_clears() {
|
||||
let host = MockHost::install().with_capabilities(&["editor.read", "project.read"]);
|
||||
host.open_editor("/project/a.dot", "dot", "digraph {\n a -> \n}\n", &[(0, 0, 0, 0)]);
|
||||
let mut p = Graphviz;
|
||||
let msg =
|
||||
r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#;
|
||||
p.handle(VIEW, ViewEvent::Message(msg.into()), &host.invocation()).unwrap();
|
||||
assert_eq!(host.diagnostics()["/project/a.dot"][0].range.start.line, 2);
|
||||
p.handle(VIEW, ViewEvent::Message(r#"{"op":"problems","items":[]}"#.into()), &host.invocation()).unwrap();
|
||||
assert!(host.diagnostics().get("/project/a.dot").is_none_or(Vec::is_empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
digraph G {
|
||||
rankdir=LR;
|
||||
node [shape=box, style=rounded];
|
||||
|
||||
start [label="Start"];
|
||||
work [label="Do the work"];
|
||||
done [label="Done", shape=doublecircle];
|
||||
|
||||
start -> work -> done;
|
||||
work -> work [label="again"];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Graphviz</title>
|
||||
<link rel="stylesheet" href="./src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar" role="toolbar" aria-label="Preview">
|
||||
<select id="engine" title="Layout engine" aria-label="Layout engine"></select>
|
||||
<span class="sep"></span>
|
||||
<button id="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
|
||||
<button id="zoom-reset" title="Actual size" aria-label="Actual size"><span id="zoom-label">100%</span></button>
|
||||
<button id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
|
||||
<button id="zoom-fit" title="Fit to window" aria-label="Fit to window">Fit</button>
|
||||
<span class="sep"></span>
|
||||
<button id="export-svg" title="Export as SVG…">SVG</button>
|
||||
<button id="export-png" title="Export as PNG…">PNG</button>
|
||||
</div>
|
||||
<div id="stage" class="stage"><div id="canvas" class="canvas"></div></div>
|
||||
<div id="banner" class="banner" role="status" hidden></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -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]: {}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* The Graphviz plugin's preview: renders the `.dot` file it is opened beside
|
||||
* (read only: the text editor next to it is where it changes) with viz.js,
|
||||
* follows the IDE theme, zooms and pans, exports SVG and PNG through the
|
||||
* IDE's save dialog, and sends what Graphviz reports to the plugin's code,
|
||||
* which turns it into the file's problems.
|
||||
*/
|
||||
import { instance, type Viz } from "@viz-js/viz";
|
||||
import { ENGINES, type Engine, exportName, problemsOf, renderOptions } from "./render";
|
||||
|
||||
const RENDER_DELAY_MS = 150;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** The most Fit enlarges a small graph to (opening a file never enlarges it). */
|
||||
const MAX_FIT = 4;
|
||||
|
||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
function start(bridge: OpenideaBridge) {
|
||||
const stage = $<HTMLDivElement>("stage");
|
||||
const canvas = $<HTMLDivElement>("canvas");
|
||||
const banner = $<HTMLDivElement>("banner");
|
||||
const engineSelect = $<HTMLSelectElement>("engine");
|
||||
const zoomLabel = $<HTMLSpanElement>("zoom-label");
|
||||
|
||||
for (const e of ENGINES) engineSelect.add(new Option(e, e));
|
||||
|
||||
let viz: Viz | null = null;
|
||||
let text = "";
|
||||
let name = "graph.dot";
|
||||
let engine: Engine = "dot";
|
||||
let zoom = 1;
|
||||
let fitted = true;
|
||||
// How far fitting may enlarge: 1 until the user asks for Fit.
|
||||
let fitCap = 1;
|
||||
let timer = 0;
|
||||
let lastProblems = "";
|
||||
|
||||
const foreground = () => bridge.theme?.vars["--openidea-fg"] ?? null;
|
||||
|
||||
function setZoom(z: number, keepFit = false) {
|
||||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||||
fitted = keepFit;
|
||||
// `zoom`, not a transform: the scroll area follows the scaled size.
|
||||
canvas.style.zoom = String(zoom);
|
||||
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const svg = canvas.querySelector("svg");
|
||||
if (!svg) return;
|
||||
// Graphviz sizes its SVG in points; these are CSS pixels at 100%.
|
||||
const w = svg.width.baseVal.value;
|
||||
const h = svg.height.baseVal.value;
|
||||
if (!w || !h) return;
|
||||
// The canvas's padding (16px a side) scales with it; a pixel spare for rounding.
|
||||
const pad = 32;
|
||||
const z = Math.min((stage.clientWidth - 1) / (w + pad), (stage.clientHeight - 1) / (h + pad), fitCap);
|
||||
setZoom(z > 0 && Number.isFinite(z) ? z : 1, true);
|
||||
}
|
||||
|
||||
/** What Graphviz said, to the plugin (only when it changed: each one republishes the problems). */
|
||||
function report(problems: ReturnType<typeof problemsOf>) {
|
||||
const key = JSON.stringify(problems);
|
||||
if (key === lastProblems) return;
|
||||
lastProblems = key;
|
||||
bridge.postMessage(JSON.stringify({ op: "problems", items: problems }));
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!viz) return;
|
||||
const result = viz.render(text, renderOptions(engine, foreground()));
|
||||
const problems = problemsOf(result);
|
||||
report(problems);
|
||||
if (result.status === "success") {
|
||||
canvas.innerHTML = result.output;
|
||||
canvas.classList.remove("stale");
|
||||
banner.hidden = true;
|
||||
if (fitted) fit();
|
||||
} else {
|
||||
// The last good graph stays, dimmed, under what went wrong.
|
||||
canvas.classList.add("stale");
|
||||
banner.hidden = false;
|
||||
banner.textContent = problems.map((p) => (p.line ? `Line ${p.line}: ${p.message}` : p.message)).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (delay = RENDER_DELAY_MS) => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(render, delay);
|
||||
};
|
||||
|
||||
/** The graph as Graphviz draws it, without the theme's defaults. */
|
||||
function exportSvg(): string | null {
|
||||
if (!viz) return null;
|
||||
const result = viz.render(text, renderOptions(engine, null));
|
||||
return result.status === "success" ? result.output : null;
|
||||
}
|
||||
|
||||
/** Runs an export; a failure (no dialog, the image not drawn) shows in the banner. */
|
||||
async function exporting(run: () => Promise<void>) {
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
banner.hidden = false;
|
||||
banner.textContent = `Export failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSvg() {
|
||||
const svg = exportSvg();
|
||||
if (!svg) return;
|
||||
await bridge.saveFile({
|
||||
name: exportName(name, "svg"),
|
||||
data: svg,
|
||||
filters: [{ name: "SVG image", extensions: ["svg"] }],
|
||||
});
|
||||
}
|
||||
|
||||
async function savePng() {
|
||||
const svg = exportSvg();
|
||||
if (!svg) return;
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error("the graph could not be drawn as an image"));
|
||||
img.src = url;
|
||||
});
|
||||
const scale = 2;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.ceil(img.naturalWidth * scale);
|
||||
c.height = Math.ceil(img.naturalHeight * scale);
|
||||
const ctx = c.getContext("2d")!;
|
||||
ctx.scale(scale, scale);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const png = await new Promise<Blob | null>((resolve) => c.toBlob(resolve, "image/png"));
|
||||
if (!png) throw new Error("the image could not be encoded");
|
||||
await bridge.saveFile({
|
||||
name: exportName(name, "png"),
|
||||
data: png,
|
||||
filters: [{ name: "PNG image", extensions: ["png"] }],
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
engineSelect.addEventListener("change", () => {
|
||||
engine = engineSelect.value as Engine;
|
||||
fitted = true;
|
||||
schedule(0);
|
||||
});
|
||||
$("zoom-in").addEventListener("click", () => setZoom(zoom * 1.25));
|
||||
$("zoom-out").addEventListener("click", () => setZoom(zoom / 1.25));
|
||||
$("zoom-fit").addEventListener("click", () => {
|
||||
fitCap = MAX_FIT;
|
||||
fit();
|
||||
});
|
||||
$("zoom-reset").addEventListener("click", () => setZoom(1));
|
||||
$("export-svg").addEventListener("click", () => void exporting(saveSvg));
|
||||
$("export-png").addEventListener("click", () => void exporting(savePng));
|
||||
// ⌘/Ctrl + wheel (and pinch) zooms; the wheel alone scrolls.
|
||||
stage.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
e.preventDefault();
|
||||
setZoom(zoom * Math.exp(-e.deltaY / 300));
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
// Dragging pans.
|
||||
stage.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const [x, y, left, top] = [e.clientX, e.clientY, stage.scrollLeft, stage.scrollTop];
|
||||
stage.setPointerCapture(e.pointerId);
|
||||
stage.classList.add("panning");
|
||||
const move = (m: PointerEvent) => {
|
||||
stage.scrollLeft = left - (m.clientX - x);
|
||||
stage.scrollTop = top - (m.clientY - y);
|
||||
};
|
||||
const up = () => {
|
||||
stage.classList.remove("panning");
|
||||
stage.removeEventListener("pointermove", move);
|
||||
stage.removeEventListener("pointerup", up);
|
||||
};
|
||||
stage.addEventListener("pointermove", move);
|
||||
stage.addEventListener("pointerup", up);
|
||||
});
|
||||
new ResizeObserver(() => fitted && fit()).observe(stage);
|
||||
bridge.onThemeChange(() => schedule(0));
|
||||
bridge.document.onChange((s) => {
|
||||
text = s.text;
|
||||
schedule();
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const [doc, v] = await Promise.all([bridge.document.get(), instance()]);
|
||||
viz = v;
|
||||
text = doc.text;
|
||||
name = doc.name || name;
|
||||
render();
|
||||
})().catch((e: unknown) => {
|
||||
banner.hidden = false;
|
||||
banner.textContent = `The preview could not start: ${e instanceof Error ? e.message : String(e)}`;
|
||||
});
|
||||
}
|
||||
|
||||
const bridge = window.openidea;
|
||||
if (bridge) start(bridge);
|
||||
else document.body.textContent = "This page runs inside OpenIDEA.";
|
||||
@@ -0,0 +1,40 @@
|
||||
/** The IDE's frame bridge (`window.openidea`), injected before the page's scripts. */
|
||||
interface OpenideaDocumentState {
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OpenideaTextEdit {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
interface OpenideaFileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
interface OpenideaBridge {
|
||||
postMessage(text: string): void;
|
||||
onMessage(fn: (text: string) => void): void;
|
||||
readonly theme: { dark: boolean; vars: Readonly<Record<string, string>> } | null;
|
||||
onThemeChange(fn: (theme: { dark: boolean; vars: Readonly<Record<string, string>> }) => void): void;
|
||||
document: {
|
||||
/** `name`: the file's name, without its directory. */
|
||||
get(): Promise<OpenideaDocumentState & { readOnly: boolean; name: string }>;
|
||||
onChange(fn: (state: OpenideaDocumentState) => void): void;
|
||||
edit(version: number, edits: OpenideaTextEdit[]): Promise<{ version: number }>;
|
||||
replace(version: number, text: string): Promise<{ version: number }>;
|
||||
};
|
||||
saveFile(options: {
|
||||
name: string;
|
||||
data: string | Uint8Array | ArrayBuffer | Blob;
|
||||
filters?: OpenideaFileFilter[];
|
||||
}): Promise<string | null>;
|
||||
openFile(options?: { filters?: OpenideaFileFilter[] }): Promise<{ name: string; data: Uint8Array } | null>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
openidea?: OpenideaBridge;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { instance } from "@viz-js/viz";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ENGINES, exportName, graphvizColor, problemsOf, renderOptions } from "./render";
|
||||
|
||||
describe("the preview's rendering", () => {
|
||||
it("renders with the theme's colour as a default the graph can override", async () => {
|
||||
const viz = await instance();
|
||||
const themed = viz.render('digraph { a -> b; c [color="red"] }', renderOptions("dot", "rgb(200, 200, 200)"));
|
||||
expect(themed.status).toBe("success");
|
||||
const svg = themed.status === "success" ? themed.output : "";
|
||||
expect(svg).toContain('stroke="#c8c8c8"');
|
||||
expect(svg).toContain('stroke="red"');
|
||||
const plain = viz.render("digraph { a -> b }", renderOptions("dot", null));
|
||||
expect(plain.status === "success" && plain.output).not.toContain("#c8c8c8");
|
||||
});
|
||||
|
||||
it("names the line of a syntax error", async () => {
|
||||
const viz = await instance();
|
||||
const result = viz.render("digraph {\n a -> b\n c -> }\n", renderOptions("dot", null));
|
||||
expect(result.status).toBe("failure");
|
||||
const [p] = problemsOf(result);
|
||||
expect(p).toMatchObject({ line: 3, level: "error" });
|
||||
expect(p!.message).toMatch(/syntax error/);
|
||||
});
|
||||
|
||||
it("offers only engines this Graphviz has", async () => {
|
||||
const viz = await instance();
|
||||
for (const e of ENGINES) expect(viz.engines).toContain(e);
|
||||
});
|
||||
|
||||
it("turns the theme's colours into Graphviz's", () => {
|
||||
expect(graphvizColor("rgb(200, 200, 200)")).toBe("#c8c8c8");
|
||||
expect(graphvizColor("rgba(0, 128, 255, 0.5)")).toBe("#0080ff80");
|
||||
expect(graphvizColor("rgb(1 2 3 / 50%)")).toBe("#01020380");
|
||||
expect(graphvizColor("#ABCDEF")).toBe("#abcdef");
|
||||
expect(graphvizColor("var(--x)")).toBeNull();
|
||||
expect(renderOptions("dot", "nonsense").nodeAttributes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names exports after the file", () => {
|
||||
expect(exportName("flow.dot", "svg")).toBe("flow.svg");
|
||||
expect(exportName("Flow.GV", "png")).toBe("Flow.png");
|
||||
expect(exportName("", "svg")).toBe("graph.svg");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The preview's logic apart from the page: rendering options, Graphviz's
|
||||
* messages as problems, export names. Kept free of the DOM so it is tested
|
||||
* against the real Graphviz in Node.
|
||||
*/
|
||||
import type { RenderOptions, RenderResult } from "@viz-js/viz";
|
||||
|
||||
/** Layout engines offered, the default first. */
|
||||
export const ENGINES = ["dot", "neato", "fdp", "sfdp", "circo", "twopi", "osage", "patchwork"] as const;
|
||||
export type Engine = (typeof ENGINES)[number];
|
||||
|
||||
export interface Problem {
|
||||
/** 1-based, when Graphviz names one. */
|
||||
line: number | null;
|
||||
message: string;
|
||||
level: "error" | "warning";
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults a graph can still override: transparent background and the IDE's
|
||||
* text colour, so an unstyled graph reads in a dark theme too. Exports use
|
||||
* none of them (`null`): they look as Graphviz draws them.
|
||||
*/
|
||||
export function renderOptions(engine: Engine, foreground: string | null): RenderOptions {
|
||||
const options: RenderOptions = { format: "svg", engine };
|
||||
const color = foreground && graphvizColor(foreground);
|
||||
if (color) {
|
||||
options.graphAttributes = { bgcolor: "transparent", color, fontcolor: color };
|
||||
options.nodeAttributes = { color, fontcolor: color };
|
||||
options.edgeAttributes = { color, fontcolor: color };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* A CSS colour as Graphviz reads it (`#rrggbb[aa]`): the theme's variables
|
||||
* are `rgb()` / `rgba()`, which it doesn't know. `null` when not one.
|
||||
*/
|
||||
export function graphvizColor(css: string): string | null {
|
||||
const hex = (n: number) =>
|
||||
Math.round(Math.min(255, Math.max(0, n)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
const value = css.trim();
|
||||
if (/^#([0-9a-f]{6}|[0-9a-f]{8})$/i.test(value)) return value.toLowerCase();
|
||||
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+)(%?))?\s*\)$/i.exec(value);
|
||||
if (!m) return null;
|
||||
const alpha = m[4] === undefined ? 1 : Number(m[4]) / (m[5] ? 100 : 1);
|
||||
return `#${hex(+m[1]!)}${hex(+m[2]!)}${hex(+m[3]!)}${alpha < 1 ? hex(alpha * 255) : ""}`;
|
||||
}
|
||||
|
||||
/** What Graphviz reported, as problems (on the line it names, if any). */
|
||||
export function problemsOf(result: RenderResult): Problem[] {
|
||||
return result.errors.map((e) => {
|
||||
const line = /\bline (\d+)\b/.exec(e.message);
|
||||
return {
|
||||
line: line ? Number(line[1]) : null,
|
||||
message: e.message.replace(/^(Error|Warning):\s*/i, "").trim(),
|
||||
level: e.level === "warning" ? "warning" : "error",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The name an export starts with: the file's, without `.dot` / `.gv`. */
|
||||
export function exportName(file: string, extension: string): string {
|
||||
const base = file.replace(/\.(dot|gv)$/i, "") || "graph";
|
||||
return `${base}.${extension}`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font: var(--openidea-font-size, 13px) var(--openidea-font-ui, system-ui, sans-serif);
|
||||
color: var(--openidea-fg);
|
||||
background: var(--openidea-bg, transparent);
|
||||
}
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--openidea-border);
|
||||
border-radius: 6px;
|
||||
background: var(--openidea-bg-elevated, var(--openidea-bg));
|
||||
}
|
||||
.toolbar button,
|
||||
.toolbar select {
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--openidea-fg);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar select {
|
||||
border-color: var(--openidea-border);
|
||||
background: var(--openidea-bg-input, transparent);
|
||||
}
|
||||
.toolbar button:hover {
|
||||
background: var(--openidea-bg-hover);
|
||||
}
|
||||
.toolbar .sep {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 2px;
|
||||
background: var(--openidea-border);
|
||||
}
|
||||
#zoom-label {
|
||||
display: inline-block;
|
||||
min-width: 36px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
cursor: grab;
|
||||
}
|
||||
.stage.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.canvas {
|
||||
display: inline-block;
|
||||
padding: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
.canvas svg {
|
||||
display: block;
|
||||
}
|
||||
.canvas.stale {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.banner {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 1;
|
||||
padding: 6px 8px;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--openidea-font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
color: var(--openidea-danger);
|
||||
border: 1px solid color-mix(in srgb, var(--openidea-danger) 40%, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--openidea-bg-elevated, var(--openidea-bg));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Builds the page into ../web (not checked in: plugins/build.sh builds it).
|
||||
* Relative URLs throughout: the IDE serves the page from the plugin package
|
||||
* under its own URL scheme.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: path.resolve(import.meta.dirname, "../web"),
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 4096,
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "openidea-plugin-openidea-license-header"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# A plugin builds on its own (target wasm32-wasip2), outside any IDE workspace.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
6a4210a3978f058b3526427eb41ef398924e8c011cc4dc53c19f5419d31501f3
|
||||
@@ -0,0 +1,35 @@
|
||||
id = "openidea.license-header"
|
||||
name = "License Header"
|
||||
version = "0.1.0"
|
||||
description = "Keeps a copyright and SPDX license header at the top of source files: files without one are reported when they open, a quick fix adds it, and a project scan lists every file that still needs one."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
license = "Apache-2.0"
|
||||
change-notes = "0.1.0 — first release: missing headers reported on open and save, Add License Header quick fix and command, project scan in the background."
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-event:file-opened", "on-event:file-saved", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["events", "project.read", "editor.read", "editor.write"]
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["code-actions"]
|
||||
languages = ["*"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "add"
|
||||
title = "License Header: Add to Current File"
|
||||
category = "License Header"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "scan"
|
||||
title = "License Header: Find Files Without a Header"
|
||||
category = "License Header"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "tools"
|
||||
command = "scan"
|
||||
|
||||
[contributes.settings]
|
||||
title = "License Header"
|
||||
schema = "settings.schema.json"
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"title": "Copyright holder",
|
||||
"description": "Written after the year, e.g. \"ACME Inc.\"; empty for none.",
|
||||
"default": ""
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"title": "License (SPDX identifier)",
|
||||
"description": "e.g. Apache-2.0, MIT, GPL-3.0-or-later; empty for no SPDX line.",
|
||||
"default": "Apache-2.0"
|
||||
},
|
||||
"reportOnOpen": {
|
||||
"type": "boolean",
|
||||
"title": "Report files without a header when they open",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! License Header for OpenIDEA: a copyright and SPDX license header at the top
|
||||
//! of source files, in the file's own comment syntax.
|
||||
//!
|
||||
//! - A file without one is reported (an info diagnostic on its first line)
|
||||
//! when it opens, and again when it is saved, so adding the header clears it.
|
||||
//! - The quick fix and *License Header: Add to Current File* insert it (after a
|
||||
//! `#!` line or a Python encoding line, which must stay first).
|
||||
//! - *Find Files Without a Header* scans the project in a background task and
|
||||
//! reports every source file that needs one in the Problems view.
|
||||
|
||||
use openidea_plugin::host;
|
||||
use openidea_plugin::providers::{CodeAction, Document, Providers};
|
||||
use openidea_plugin::{
|
||||
Diagnostic, Error, Event, Invocation, NotificationLevel, Plugin, Position, Project, Range, Severity, TextEdit,
|
||||
register_plugin,
|
||||
};
|
||||
|
||||
/// Lines looked at for an existing header.
|
||||
const HEADER_LINES: usize = 15;
|
||||
/// Files a scan reads at most.
|
||||
const MAX_SCAN: u32 = 5000;
|
||||
/// Files larger than this are skipped by a scan.
|
||||
const MAX_FILE: usize = 1 << 20;
|
||||
const CODE: &str = "missing-license-header";
|
||||
|
||||
/// How a file writes a header: line comments, or one block comment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Comment {
|
||||
Line(&'static str),
|
||||
Block(&'static str, &'static str),
|
||||
}
|
||||
|
||||
/// The comment syntax for `path`, `None` for files that don't get a header.
|
||||
pub fn comment_for(path: &str) -> Option<Comment> {
|
||||
let name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if matches!(lower.as_str(), "dockerfile" | "makefile" | "justfile") {
|
||||
return Some(Comment::Line("#"));
|
||||
}
|
||||
let ext = lower.rsplit_once('.').map(|(_, e)| e)?;
|
||||
Some(match ext {
|
||||
"rs" | "java" | "kt" | "kts" | "scala" | "groovy" | "gradle" | "go" | "js" | "jsx" | "mjs" | "cjs" | "ts"
|
||||
| "tsx" | "mts" | "cts" | "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "m" | "mm" | "cs" | "swift"
|
||||
| "dart" | "proto" | "sol" | "zig" | "v" => Comment::Line("//"),
|
||||
"css" | "scss" | "less" => Comment::Block("/*", " */"),
|
||||
"py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "r" | "ps1" | "yaml" | "yml" | "toml" | "cmake"
|
||||
| "tf" | "nix" | "ex" | "exs" | "jl" => Comment::Line("#"),
|
||||
"sql" | "lua" | "hs" | "elm" => Comment::Line("--"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `text` already starts with a header (copyright or license notice).
|
||||
pub fn has_header(text: &str) -> bool {
|
||||
text.lines().take(HEADER_LINES).any(|l| {
|
||||
let l = l.to_ascii_lowercase();
|
||||
l.contains("copyright") || l.contains("spdx-license-identifier") || l.contains("licensed under")
|
||||
})
|
||||
}
|
||||
|
||||
/// The header's lines, without comment markers.
|
||||
pub fn header_lines(year: i64, owner: &str, license: &str) -> Vec<String> {
|
||||
let owner = owner.trim();
|
||||
let mut lines =
|
||||
vec![if owner.is_empty() { format!("Copyright (c) {year}") } else { format!("Copyright (c) {year} {owner}") }];
|
||||
if !license.trim().is_empty() {
|
||||
lines.push(format!("SPDX-License-Identifier: {}", license.trim()));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// The header as `comment` writes it, with a blank line after it.
|
||||
pub fn render(comment: Comment, lines: &[String]) -> String {
|
||||
let mut out = String::new();
|
||||
match comment {
|
||||
Comment::Line(prefix) => {
|
||||
for l in lines {
|
||||
out.push_str(&format!("{prefix} {l}\n"));
|
||||
}
|
||||
}
|
||||
Comment::Block(open, close) => {
|
||||
out.push_str(open);
|
||||
out.push('\n');
|
||||
for l in lines {
|
||||
out.push_str(&format!(" * {l}\n"));
|
||||
}
|
||||
out.push_str(close);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// The line the header goes on: after a `#!` line and a Python encoding line.
|
||||
pub fn insert_line(text: &str) -> u32 {
|
||||
let mut line = 0;
|
||||
let mut lines = text.lines();
|
||||
if lines.next().is_some_and(|l| l.starts_with("#!")) {
|
||||
line = 1;
|
||||
if lines.next().is_some_and(is_encoding_line) {
|
||||
line = 2;
|
||||
}
|
||||
} else if text.lines().next().is_some_and(is_encoding_line) {
|
||||
line = 1;
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn is_encoding_line(l: &str) -> bool {
|
||||
l.starts_with('#') && (l.contains("coding:") || l.contains("coding="))
|
||||
}
|
||||
|
||||
/// The edit that adds the header to `path`'s `text`, `None` when it has one or takes none.
|
||||
pub fn header_edit(path: &str, text: &str, year: i64, owner: &str, license: &str) -> Option<TextEdit> {
|
||||
let comment = comment_for(path)?;
|
||||
if has_header(text) {
|
||||
return None;
|
||||
}
|
||||
let at = Position::new(insert_line(text), 0);
|
||||
let mut insert = render(comment, &header_lines(year, owner, license));
|
||||
// After a first line without a newline (a lone shebang).
|
||||
if at.line > 0 && text.lines().count() == at.line as usize && !text.ends_with('\n') {
|
||||
insert.insert(0, '\n');
|
||||
}
|
||||
Some(TextEdit { range: Range::new(at, at), text: insert })
|
||||
}
|
||||
|
||||
fn missing(text: &str) -> Diagnostic {
|
||||
let line = insert_line(text);
|
||||
let len = text.lines().nth(line as usize).map_or(0, |l| l.encode_utf16().count() as u32);
|
||||
Diagnostic {
|
||||
range: Range::new(Position::new(line, 0), Position::new(line, len)),
|
||||
severity: Severity::Info,
|
||||
message: "No license header".into(),
|
||||
code: Some(CODE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports `path` when it takes a header and has none, clears it otherwise.
|
||||
pub fn report(path: &str, text: &str) -> Result<bool, Error> {
|
||||
let lacking = comment_for(path).is_some() && !has_header(text);
|
||||
host::publish_diagnostics(path, &if lacking { vec![missing(text)] } else { Vec::new() })?;
|
||||
Ok(lacking)
|
||||
}
|
||||
|
||||
/// The current year (WASI wall clock), from days since the epoch.
|
||||
fn this_year() -> i64 {
|
||||
let secs =
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||||
year_of_day(secs.div_euclid(86_400))
|
||||
}
|
||||
|
||||
/// The proleptic Gregorian year of `day` days since 1970-01-01.
|
||||
pub fn year_of_day(day: i64) -> i64 {
|
||||
let z = day + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
yoe + era * 400 + i64::from(month <= 2)
|
||||
}
|
||||
|
||||
fn settings() -> (String, String) {
|
||||
(
|
||||
host::setting_string("owner").unwrap_or_default(),
|
||||
host::setting_string("license").unwrap_or_else(|| "Apache-2.0".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_of(path: &str) -> Option<Project> {
|
||||
host::projects()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.filter(|p| p.root().is_ok_and(|r| path.starts_with(&r)))
|
||||
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LicenseHeader;
|
||||
|
||||
impl LicenseHeader {
|
||||
fn add_to_editor(&self, ctx: &Invocation) -> Result<Option<String>, Error> {
|
||||
let editor = ctx.editor().ok_or_else(|| Error::Invalid("focus an editor first".into()))?;
|
||||
let (path, doc) = (editor.path(), editor.document()?);
|
||||
if comment_for(&path).is_none() {
|
||||
return Err(Error::Invalid("this kind of file takes no license header".into()));
|
||||
}
|
||||
let (owner, license) = settings();
|
||||
let Some(edit) = header_edit(&path, &doc.text, this_year(), &owner, &license) else {
|
||||
return Ok(Some("The file already has a license header.".into()));
|
||||
};
|
||||
editor.apply_edits(doc.version, &[edit])?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reports every source file of the project without a header; returns (checked, lacking).
|
||||
fn scan(&self, project: &Project) -> Result<(u32, u32), Error> {
|
||||
let files: Vec<String> = project
|
||||
.find_files("**/*", MAX_SCAN * 4)?
|
||||
.into_iter()
|
||||
.filter(|f| comment_for(f).is_some())
|
||||
.take(MAX_SCAN as usize)
|
||||
.collect();
|
||||
let total = files.len() as u32;
|
||||
let task = host::begin_task("License Header: checking files", true);
|
||||
let (mut checked, mut lacking) = (0, 0);
|
||||
for (i, path) in files.iter().enumerate() {
|
||||
if task.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if i % 20 == 0 {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
task.report(i as u32, Some(total), Some(name));
|
||||
}
|
||||
let Ok(text) = project.read_file(path) else { continue };
|
||||
if text.len() > MAX_FILE {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
if report(path, &text)? {
|
||||
lacking += 1;
|
||||
}
|
||||
}
|
||||
Ok((checked, lacking))
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for LicenseHeader {
|
||||
fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"add" => self.add_to_editor(ctx),
|
||||
"scan" => {
|
||||
let project = ctx.project().ok_or_else(|| Error::Invalid("open a project first".into()))?;
|
||||
let (checked, lacking) = self.scan(&project)?;
|
||||
let body = match lacking {
|
||||
0 => format!("All {checked} source files have one."),
|
||||
1 => format!("1 of {checked} source files has none; see the Problems view."),
|
||||
n => format!("{n} of {checked} source files have none; see the Problems view."),
|
||||
};
|
||||
host::notify(NotificationLevel::Info, "License headers", &body);
|
||||
Ok(None)
|
||||
}
|
||||
_ => Err(Error::NotFound(format!("command {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
|
||||
for e in events {
|
||||
let (path, opened) = match e {
|
||||
Event::FileOpened(p) => (p, true),
|
||||
Event::FileSaved(p) => (p, false),
|
||||
_ => continue,
|
||||
};
|
||||
if comment_for(path).is_none() || (opened && !host::setting_bool("reportOnOpen").unwrap_or(true)) {
|
||||
continue;
|
||||
}
|
||||
let Some(project) = project_of(path) else { continue };
|
||||
if let Ok(text) = project.read_file(path) {
|
||||
let _ = report(path, &text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Providers for LicenseHeader {
|
||||
fn code_actions(&mut self, doc: &Document, _selection: Range) -> Vec<CodeAction> {
|
||||
let (owner, license) = settings();
|
||||
let Some(edit) = header_edit(&doc.path, &doc.text, this_year(), &owner, &license) else { return Vec::new() };
|
||||
vec![CodeAction {
|
||||
title: "Add license header".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: true,
|
||||
edits: vec![(doc.path.clone(), vec![edit])],
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(LicenseHeader, providers);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::mock::MockHost;
|
||||
|
||||
fn doc(path: &str, text: &str) -> Document {
|
||||
Document { path: path.into(), language: String::new(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_the_header_in_each_files_comment_syntax() {
|
||||
let lines = header_lines(2026, "ACME Inc.", "MIT");
|
||||
assert_eq!(
|
||||
render(comment_for("a/b.rs").unwrap(), &lines),
|
||||
"// Copyright (c) 2026 ACME Inc.\n// SPDX-License-Identifier: MIT\n\n"
|
||||
);
|
||||
assert_eq!(render(comment_for("x.py").unwrap(), &lines[..1]), "# Copyright (c) 2026 ACME Inc.\n\n");
|
||||
assert_eq!(
|
||||
render(comment_for("s.css").unwrap(), &lines),
|
||||
"/*\n * Copyright (c) 2026 ACME Inc.\n * SPDX-License-Identifier: MIT\n */\n\n"
|
||||
);
|
||||
assert_eq!(comment_for("q.SQL"), Some(Comment::Line("--")));
|
||||
assert_eq!(comment_for("Dockerfile"), Some(Comment::Line("#")));
|
||||
assert_eq!(comment_for("README.md"), None);
|
||||
assert_eq!(comment_for("noext"), None);
|
||||
assert_eq!(header_lines(2026, " ", ""), ["Copyright (c) 2026"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_shebang_and_encoding_lines_first() {
|
||||
assert_eq!(insert_line("fn main() {}\n"), 0);
|
||||
assert_eq!(insert_line("#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint()\n"), 2);
|
||||
assert_eq!(insert_line("#!/bin/sh\necho\n"), 1);
|
||||
assert_eq!(insert_line("# coding=latin-1\nx = 1\n"), 1);
|
||||
let e = header_edit("run.sh", "#!/bin/sh", 2026, "", "").unwrap();
|
||||
assert_eq!((e.range.start, e.text.as_str()), (Position::new(1, 0), "\n# Copyright (c) 2026\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_header_is_left_alone() {
|
||||
assert!(has_header("// SPDX-License-Identifier: Apache-2.0\nfn x() {}"));
|
||||
assert!(has_header("/*\n * Licensed under the MIT license\n */"));
|
||||
assert!(has_header(&format!("{}// Copyright 2020\n", "\n".repeat(10))));
|
||||
assert!(!has_header(&format!("{}// Copyright 2020\n", "\n".repeat(20))), "only the top of the file counts");
|
||||
assert!(header_edit("a.rs", "// Copyright (c) 2020 X\n", 2026, "", "").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn years() {
|
||||
assert_eq!(year_of_day(0), 1970);
|
||||
assert_eq!(year_of_day(19_722), 2023); // 2023-12-31
|
||||
assert_eq!(year_of_day(19_723), 2024); // 2024-01-01
|
||||
assert_eq!(year_of_day(20_759), 2026);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_on_open_and_save_and_fixes() {
|
||||
let host = MockHost::install().with_capabilities(&["events", "project.read", "editor.read", "editor.write"]);
|
||||
host.add_file("src/a.rs", "fn a() {}\n");
|
||||
host.add_file("src/b.rs", "// Copyright (c) 2020 X\nfn b() {}\n");
|
||||
let mut p = LicenseHeader;
|
||||
p.handle_events(
|
||||
&[Event::FileOpened("/project/src/a.rs".into()), Event::FileOpened("/project/src/b.rs".into())],
|
||||
&host.invocation(),
|
||||
);
|
||||
let d = host.diagnostics();
|
||||
assert_eq!(d["/project/src/a.rs"][0].code.as_deref(), Some(CODE));
|
||||
assert!(d.get("/project/src/b.rs").is_none_or(Vec::is_empty));
|
||||
|
||||
// Off when opening, still on saving.
|
||||
host.set_setting("reportOnOpen", "false");
|
||||
host.add_file("src/c.rs", "fn c() {}\n");
|
||||
p.handle_events(&[Event::FileOpened("/project/src/c.rs".into())], &host.invocation());
|
||||
assert!(host.diagnostics().get("/project/src/c.rs").is_none_or(Vec::is_empty));
|
||||
p.handle_events(&[Event::FileSaved("/project/src/c.rs".into())], &host.invocation());
|
||||
assert_eq!(host.diagnostics()["/project/src/c.rs"].len(), 1);
|
||||
|
||||
host.set_setting("owner", "\"ACME\"");
|
||||
let actions = p.code_actions(&doc("/project/src/a.rs", "fn a() {}\n"), Range::default());
|
||||
assert_eq!(actions[0].title, "Add license header");
|
||||
let (path, edits) = &actions[0].edits[0];
|
||||
assert_eq!(path, "/project/src/a.rs");
|
||||
assert!(
|
||||
edits[0].text.starts_with(&format!(
|
||||
"// Copyright (c) {} ACME\n// SPDX-License-Identifier: Apache-2.0\n",
|
||||
this_year()
|
||||
))
|
||||
);
|
||||
assert!(p.code_actions(&doc("/project/README.md", "x"), Range::default()).is_empty());
|
||||
|
||||
// The command edits the focused editor once.
|
||||
host.open_editor("/project/src/a.rs", "rust", "fn a() {}\n", &[(0, 0, 0, 0)]);
|
||||
assert_eq!(p.run_command("add", &host.invocation(), None).unwrap(), None);
|
||||
assert!(has_header(&host.editor_text()));
|
||||
assert!(p.run_command("add", &host.invocation(), None).unwrap().unwrap().contains("already"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_the_project_in_a_task() {
|
||||
let host = MockHost::install().with_capabilities(&["events", "project.read"]);
|
||||
host.add_file("src/a.rs", "fn a() {}\n");
|
||||
host.add_file("src/b.py", "# Copyright 2024 X\n");
|
||||
host.add_file("notes.md", "no header needed\n");
|
||||
let mut p = LicenseHeader;
|
||||
p.run_command("scan", &host.invocation(), None).unwrap();
|
||||
let d = host.diagnostics();
|
||||
assert_eq!(d["/project/src/a.rs"].len(), 1);
|
||||
assert!(d.get("/project/src/b.py").is_none_or(Vec::is_empty));
|
||||
assert!(!d.contains_key("/project/notes.md"));
|
||||
assert_eq!(host.tasks().len(), 1);
|
||||
assert!(host.notifications()[0].body.contains("1 of 2"), "{:?}", host.notifications());
|
||||
}
|
||||
}
|
||||
+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"
|
||||
@@ -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"
|
||||
Бінарний файл не відображається.
@@ -0,0 +1 @@
|
||||
d2e35f3e4ee6b699899ea43fdf96d5ec99f0730c502f94fd6f3246e605be99a7
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<key>\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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 = …`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) })),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача