From ed08b9638233eadf8df0c89454b7d3fff05f2bbc Mon Sep 17 00:00:00 2001 From: qiaojianlong <109515705+qjlqyf@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:07:53 +0800 Subject: [PATCH] feat(plugins): add background document-indexer API (#1021) Plugin API 1.2 gains an optional document-indexer export (plugin-indexing world). A plugin returns per document the keys it defines and refers to, aliases between key prefixes and gutter markers; the host indexes a trusted project in cancellable background batches, keeps the project-wide graph, and answers Go to Declaration / Ctrl-click and multi-target gutter markers from memory without calling the plugin again. Edits and file changes invalidate the affected entries; reload, disable or a failing runtime drops the plugin's graph. The interface is limited to cross-file associations: icons chosen by file content and embedded-language regions are separate concerns for later interfaces. The plugin navigation choosers' titles are translated. Co-authored-by: qjlqyf <109515705+qjlqyf@users.noreply.github.com> --- crates/ide-host/src/lib.rs | 4 + crates/ide-host/src/ops/lsp.rs | 11 + crates/ide-host/src/plugins/index_ops.rs | 177 +++++++ crates/ide-host/src/plugins/mod.rs | 10 +- crates/ide-host/src/plugins/navigation.rs | 462 ++++++++++++++++++ crates/ide-host/src/plugins/points.rs | 1 + crates/ide-host/src/plugins/providers.rs | 6 +- crates/ide-host/src/plugins/remote_host.rs | 38 ++ crates/ide-host/src/plugins/types.rs | 2 + crates/ide-host/tests/plugin_indexing.rs | 236 +++++++++ crates/ide-index/src/lib.rs | 37 ++ crates/ide-index/src/tests.rs | 20 + crates/ide-plugin/src/indexing.rs | 53 ++ crates/ide-plugin/src/lib.rs | 1 + crates/ide-plugin/src/runtime.rs | 52 +- crates/ide-plugin/src/wit_host.rs | 7 + crates/ide-plugin/tests/api_compat.rs | 7 +- .../tests/fixtures/hello/dist/plugin.wasm | Bin 133850 -> 133858 bytes .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/indexer/Cargo.lock | 333 +++++++++++++ .../tests/fixtures/indexer/Cargo.toml | 20 + .../tests/fixtures/indexer/README.md | 10 + .../tests/fixtures/indexer/dist/plugin.wasm | Bin 0 -> 82999 bytes .../fixtures/indexer/dist/plugin.wasm.sources | 1 + .../tests/fixtures/indexer/plugin.toml | 11 + .../tests/fixtures/indexer/src/lib.rs | 51 ++ .../tests/fixtures/jobs/dist/plugin.wasm | Bin 133015 -> 133023 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- crates/ide-workspace/src/documents.rs | 5 + docs/plugins/indexing.md | 54 ++ plugins/README.md | 7 + .../official/ai-completion/dist/plugin.wasm | Bin 173071 -> 173071 bytes .../ai-completion/dist/plugin.wasm.sources | 2 +- plugins/official/checkstyle/dist/plugin.wasm | Bin 169518 -> 169526 bytes .../checkstyle/dist/plugin.wasm.sources | 2 +- plugins/official/csv-editor/dist/plugin.wasm | Bin 133381 -> 133389 bytes .../csv-editor/dist/plugin.wasm.sources | 2 +- plugins/official/git-toolbox/dist/plugin.wasm | Bin 110055 -> 110063 bytes .../git-toolbox/dist/plugin.wasm.sources | 2 +- plugins/official/graphviz/dist/plugin.wasm | Bin 129089 -> 129097 bytes .../graphviz/dist/plugin.wasm.sources | 2 +- .../official/key-promoter/dist/plugin.wasm | Bin 100171 -> 100179 bytes .../key-promoter/dist/plugin.wasm.sources | 2 +- .../official/license-header/dist/plugin.wasm | Bin 152590 -> 152598 bytes .../license-header/dist/plugin.wasm.sources | 2 +- plugins/official/mybatis/dist/plugin.wasm | Bin 167183 -> 167191 bytes .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../official/regex-tester/dist/plugin.wasm | Bin 931714 -> 931722 bytes .../regex-tester/dist/plugin.wasm.sources | 2 +- plugins/official/solidity/dist/plugin.wasm | Bin 108016 -> 108016 bytes .../solidity/dist/plugin.wasm.sources | 2 +- .../official/string-tools/dist/plugin.wasm | Bin 165771 -> 165779 bytes .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/indexing.rs | 46 ++ sdk/rust/openidea-plugin/src/lib.rs | 80 +++ sdk/rust/openidea-plugin/wit/indexing.wit | 60 +++ sdk/rust/openidea-plugin/wit/plugin.wit | 11 + src-tauri/src/commands/mod.rs | 5 + src-tauri/src/commands/plugin.rs | 64 +++ src/bindings.ts | 28 ++ src/editor/lsp/navigation.ts | 63 ++- src/editor/lsp/navigationMouse.test.ts | 18 + src/editor/navigationProviders.test.ts | 97 ++++ src/editor/navigationProviders.ts | 85 ++++ src/features/plugins/index.ts | 4 + src/features/plugins/indexedMarkers.test.ts | 70 +++ src/features/plugins/indexedMarkers.ts | 129 +++++ src/features/plugins/lineMarkerGutter.test.ts | 56 +++ src/features/plugins/lineMarkerGutter.ts | 36 +- src/features/plugins/markerStore.test.ts | 53 ++ src/features/plugins/markerStore.ts | 83 ++++ src/features/plugins/navigation.test.ts | 130 +++++ src/features/plugins/navigation.ts | 207 ++++++++ src/features/plugins/navigationButton.ts | 34 ++ src/platform/locales/text/zh.json | 6 +- src/ui/icons.tsx | 5 + wit/indexing.wit | 60 +++ wit/plugin.wit | 11 + 79 files changed, 3039 insertions(+), 46 deletions(-) create mode 100644 crates/ide-host/src/plugins/index_ops.rs create mode 100644 crates/ide-host/src/plugins/navigation.rs create mode 100644 crates/ide-host/tests/plugin_indexing.rs create mode 100644 crates/ide-plugin/src/indexing.rs create mode 100644 crates/ide-plugin/tests/fixtures/indexer/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/indexer/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/indexer/README.md create mode 100644 crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/indexer/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/indexer/src/lib.rs create mode 100644 docs/plugins/indexing.md create mode 100644 sdk/rust/openidea-plugin/src/indexing.rs create mode 100644 sdk/rust/openidea-plugin/wit/indexing.wit create mode 100644 src/editor/navigationProviders.test.ts create mode 100644 src/editor/navigationProviders.ts create mode 100644 src/features/plugins/indexedMarkers.test.ts create mode 100644 src/features/plugins/indexedMarkers.ts create mode 100644 src/features/plugins/lineMarkerGutter.test.ts create mode 100644 src/features/plugins/markerStore.test.ts create mode 100644 src/features/plugins/markerStore.ts create mode 100644 src/features/plugins/navigation.test.ts create mode 100644 src/features/plugins/navigation.ts create mode 100644 src/features/plugins/navigationButton.ts create mode 100644 wit/indexing.wit diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index d1f395369..c1c76fdb8 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -223,6 +223,10 @@ impl Host { self.cancel_request(request); } let project = self.projects.write().remove(window)?; + if let Some(plugins) = plugins::editor_plugin_host() { + plugins.invalidate_client_navigation(&project.root, Some(window), None); + plugins.set_active_file(window, None); + } if !project.light { self.environment.project_closed(); } diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index a4eb79577..1f802df80 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -204,6 +204,17 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if kind == GotoKind::Definition + && let Some(plugins) = crate::plugins::editor_plugin_host() + { + let project = host.project(window)?; + let (path, version) = project.documents.identity(doc)?; + if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + }) { + return Ok(found.targets); + } + } let result = host.project(window)?.lsp.goto(doc, pos, kind).await; if kind != GotoKind::Definition { return result; diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs new file mode 100644 index 000000000..ebc1b5d9b --- /dev/null +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -0,0 +1,177 @@ +use super::PluginHost; +use ide_core::{IdeError, IdeResult}; +use ide_plugin::Invocation; +use std::{path::PathBuf, sync::Arc}; +#[derive(serde::Serialize, specta::Type)] +pub struct NavigationFiles { + pub paths: Vec, + pub indexing: bool, + pub extensions: Vec, +} + +/// File discovery is separate from clicking: the UI schedules small background +/// batches after initial index completion and updates only changed files later. +pub async fn plugin_navigation_files( + host: &crate::Host, + plugins: Arc, + window: &str, +) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + let extensions = plugins.navigation_extensions(&project.root); + if extensions.is_empty() { + return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] }); + } + let indexing = project.index.status().indexing; + let files = project.index.files_with_extensions(&extensions, 20_001); + if files.len() > 20_000 { + return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files")); + } + Ok(NavigationFiles { + paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(), + indexing, + extensions, + }) + }) + .await +} + +pub fn plugin_navigation_invalidate( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Option>, +) -> IdeResult<()> { + let project = host.project(window)?; + plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref()); + Ok(()) +} + +pub async fn plugin_navigation_index( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + if paths.len() > 16 { + return Err(IdeError::invalid("navigation batches are limited to 16 files")); + } + let project = host.project(window)?; + let cancel = host.begin_project_request(window, request); + let client = window.to_owned(); + let result = blocking(move || { + if !project.is_trusted() { + return Err(IdeError::unavailable("project is not trusted")); + } + for path in paths { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else { + continue; + }; + let extensions = plugins.navigation_extensions(&project.root); + if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) { + continue; + } + let document = project.documents.by_path(&path); + let (text, version, language) = if let Some(id) = document { + let snapshot = project.documents.text_snapshot(id)?; + if snapshot.large { + continue; + } + (snapshot.text, Some(snapshot.version), snapshot.language) + } else { + use std::io::Read; + if !std::fs::metadata(&path) + .is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64) + { + continue; + } + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + let mut text = String::new(); + if file + .take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64) + .read_to_string(&mut text) + .is_err() + { + continue; + } + let language = project.languages.detect(&path, text.lines().next()); + (text, None, language) + }; + if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES { + continue; + } + let symbols = project + .index + .symbols_for_text(&path, &text) + .unwrap_or_else(|| project.syntax.syntax().tags(&language, &text)); + let roots = project.roots(); + let scope = roots + .roots + .iter() + .filter(|r| path.starts_with(project.root.join(&r.path))) + .max_by_key(|r| r.path.len()) + .and_then(|r| r.detected_from.as_deref()) + .unwrap_or(""); + plugins.index_navigation( + &project.root, + crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope }, + Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None }, + )?; + // A document edit while WASM was executing must not publish stale ranges. + if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) { + plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path])); + } + } + Ok(()) + }) + .await; + host.end_request(request); + result +} + +pub async fn plugin_navigation_goto( + host: &crate::Host, + plugins: Arc, + window: &str, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + let (path, version) = project.documents.identity(doc)?; + Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +pub async fn plugin_navigation_markers( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > 64 { + return Err(IdeError::invalid("marker batches are limited to 64 files")); + } + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0b82a9350..36a48662d 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -20,9 +20,11 @@ mod icon_extensions; mod icon_patterns; mod icon_settings; mod icons; +pub mod index_ops; mod install; pub mod intellij; mod market; +pub mod navigation; mod points; mod providers; pub mod remote; @@ -41,8 +43,8 @@ pub use market::{ read_url as read_market_url, resolve as resolve_market_href, }; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; -pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; +pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for}; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; @@ -308,6 +310,7 @@ pub struct PluginHost { run_detectors: Arc, /// `[[contributes.providers]]` of enabled plugins. providers: Arc, + navigation: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -396,6 +399,7 @@ impl PluginHost { languages, run_detectors, providers, + navigation: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -1881,6 +1885,7 @@ impl PluginHost { } } let Some(error) = actor.take_crash() else { return }; + self.clear_plugin_navigation(plugin.id()); if error.starts_with(CANCELLED) { // Cancelled on request: restart on next use, no backoff. let mut meta = plugin.meta.lock(); @@ -1952,6 +1957,7 @@ impl PluginHost { } fn stop(&self, plugin: &LoadedPlugin) { + self.clear_plugin_navigation(plugin.id()); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(true); } @@ -1961,6 +1967,7 @@ impl PluginHost { } fn set_failed(&self, plugin: &LoadedPlugin, error: String) { + self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "{error}"); self.registry.dispose_plugin(plugin.id()); let mut meta = plugin.meta.lock(); @@ -1969,6 +1976,7 @@ impl PluginHost { } fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}"); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(false); diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs new file mode 100644 index 000000000..efa8ef5db --- /dev/null +++ b/crates/ide-host/src/plugins/navigation.rs @@ -0,0 +1,462 @@ +//! API 1.x document indexers: background snapshots become project-scoped +//! associations. Navigation and gutter markers only read validated memory indexes. +use super::PluginHost; +use ide_core::{IdeError, IdeResult, Location, Pos, Range}; +use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode}; +use ide_language::SymbolTag; +use ide_plugin::Invocation; +use serde::Deserialize; + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; + +pub const MAX_DOCUMENT_BYTES: usize = 2 << 20; +const MAX_FILES: usize = 20_000; +const MAX_EDGES: usize = 4096; + +pub struct Snapshot<'a> { + pub path: &'a Path, + pub version: Option, + pub text: &'a str, + pub symbols: &'a [SymbolTag], + pub scope: &'a str, +} + +pub use ide_plugin::indexing::*; + +struct File { + version: Option, + scope: String, + edges: Edges, +} +#[derive(Default)] +struct Graph { + instance: Weak, + files: BTreeMap, + definitions: HashMap>, + aliases: HashMap>, + edges: usize, +} +impl Graph { + fn targets(&self, key: &str) -> Vec<&Location> { + let mut keys = vec![key.to_owned()]; + let mut seen = std::collections::HashSet::new(); + let mut targets = vec![]; + while let Some(key) = keys.pop() { + if seen.len() >= 64 || !seen.insert(key.clone()) { + continue; + } + if let Some(locations) = self.definitions.get(&key) { + targets.extend(locations.iter().take(256usize.saturating_sub(targets.len()))); + continue; + } + if let Some((prefix, suffix)) = key.split_once('#') + && let Some(aliases) = self.aliases.get(&format!("{prefix}#")) + { + for (_, target) in aliases.iter().take(64) { + keys.push(format!("{target}{suffix}")); + } + } + } + targets + } + fn remove(&mut self, path: &Path) { + if let Some(file) = self.files.remove(path) { + self.edges -= file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in file.edges.aliases { + if let Some(entries) = self.aliases.get_mut(&alias.prefix) { + entries.retain(|(p, _)| p != path); + if entries.is_empty() { + self.aliases.remove(&alias.prefix); + } + } + } + for anchor in file.edges.definitions { + if let Some(locations) = self.definitions.get_mut(&anchor.key) { + locations.retain(|l| l.path.0.as_path() != path); + if locations.is_empty() { + self.definitions.remove(&anchor.key); + } + } + } + } + } + fn insert(&mut self, path: PathBuf, file: File) { + self.remove(&path); + self.edges += file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in &file.edges.aliases { + self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone())); + } + for anchor in &file.edges.definitions { + self.definitions.entry(anchor.key.clone()).or_default().push(Location { + path: path.clone().into(), + range: anchor.range, + uri: None, + }); + } + self.files.insert(path, file); + } +} + +#[derive(Default)] +pub(crate) struct NavigationIndex { + projects: HashMap<(PathBuf, Option), HashMap>, + generation: u64, + epochs: HashMap, +} + +fn contains(range: Range, pos: Pos) -> bool { + (range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col) +} +fn valid_range(lines: &[usize], range: Range) -> bool { + let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length); + pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col) +} + +impl PluginHost { + pub(crate) fn clear_plugin_navigation(&self, id: &str) { + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + let roots: Vec<_> = index.projects.keys().cloned().collect(); + for root in roots { + index.epochs.insert(root.0, epoch); + } + for graphs in index.projects.values_mut() { + graphs.remove(id); + } + drop(index); + self.emit(None, super::PluginEvent::DocumentIndexesChanged); + } + + /// Extensions with an active, permissioned navigation provider for this root. + pub fn navigation_extensions(&self, root: &Path) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + self.plugins + .read() + .values() + .filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root)) + .filter_map(|p| declaration(p)) + .flat_map(|d| d.extensions) + .collect() + } + + /// Remove stale contributions before scheduling replacement work. `None` + /// disposes the project, including reverse-index entries, on project close. + pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, None, paths); + } + + pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, Some(client), paths); + } + + fn invalidate_navigation_scope(&self, root: &Path, client: Option>, paths: Option<&[PathBuf]>) { + let matches = |key: &(PathBuf, Option)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c); + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + index.epochs.insert(root.to_owned(), epoch); + if index.epochs.len() > 64 + && let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone()) + { + index.epochs.remove(&oldest); + } + match paths { + None => { + index.projects.retain(|key, _| !matches(key)); + } + Some(paths) => { + for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) { + for graph in graphs.values_mut() { + for path in paths { + graph.remove(path); + } + } + } + } + } + } + + /// Index one bounded snapshot. No directory traversal or parsing happens in + /// navigation_at. Call off the UI thread, in small cancellable batches. + pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> { + let Snapshot { path, version, text, symbols, scope } = snapshot; + if !(self.project_policy.read())(root) { + return Err(IdeError::unavailable("project is not trusted")); + } + if text.len() > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("navigation document is too large")); + } + let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?; + if real != path { + return Err(IdeError::invalid("navigation requires a canonical project path")); + } + let project_key = (root.to_owned(), invocation.client.clone()); + let generation = self.navigation.lock().epochs.get(root).copied(); + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default(); + let plugins: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| { + p.status().is_live() + && p.dev_root.as_deref().is_none_or(|r| r == root) + && p.meta.lock().granted.contains(&Capability::ProjectRead) + && declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension)) + }) + .cloned() + .collect(); + if plugins.is_empty() { + return Ok(()); + } + let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect(); + for plugin in plugins { + let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?; + if activated { + self.changed(); + } + let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned()); + let symbols = symbols + .iter() + .map(|s| ide_plugin::indexing::IndexSymbol { + name: s.name.clone(), + kind: serde_json::to_value(s.kind) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(), + container: s.container.clone(), + range: s.range, + }) + .collect::>(); + let ctx = invocation.clone(); + let started = std::time::Instant::now(); + let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx)); + let edges = self + .finish_provider_call(&plugin, &actor, started, result) + .ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?; + let output_bytes = edges + .definitions + .iter() + .chain(&edges.references) + .chain(&edges.markers) + .map(|a| a.key.len().saturating_add(32)) + .sum::() + .saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::()); + if output_bytes > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("document index output is too large")); + } + if edges.aliases.len() > 256 + || edges.aliases.iter().any(|a| { + !a.prefix.ends_with('#') + || !a.target.ends_with('#') + || a.prefix.len() > 1024 + || a.target.len() > 1024 + }) + || edges.definitions.len() + edges.references.len() > MAX_EDGES + || edges + .definitions + .iter() + .chain(&edges.references) + .any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation anchors")); + } + if edges.markers.len() > MAX_EDGES + || edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation markers")); + } + let mut index = self.navigation.lock(); + if index.epochs.get(root).copied() != generation { + return Err(IdeError::cancelled()); + } + // Keep a bounded number of project graphs, including closed windows + // whose frontend did not get an opportunity to dispose its state. + if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) { + return Err(IdeError::unavailable("navigation project limit reached")); + } + let graph = + index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default(); + if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) { + *graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() }; + } + if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) { + return Err(IdeError::unavailable("navigation file limit reached")); + } + graph.remove(path); + if graph.edges + + edges.definitions.len() + + edges.references.len() + + edges.aliases.len() + + edges.markers.len() + > 200_000 + { + return Err(IdeError::unavailable("navigation association limit reached")); + } + graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges }); + } + Ok(()) + } + + /// Resolves only current live providers. Disable/reload/trap cannot retain + /// navigable stale data, even if a background snapshot finished concurrently. + pub fn navigation_at( + &self, + root: &Path, + path: &Path, + version: u32, + pos: Pos, + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Option { + if !(self.project_policy.read())(root) { + return None; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?; + let mut result: Option = None; + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if file.version != Some(version) { + continue; + } + for reference in &file.edges.references { + if !contains(reference.range, pos) { + continue; + } + let targets = graph.targets(&reference.key); + if targets.is_empty() { + continue; + } + let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] }); + let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + for target in targets.iter().take(256) { + let Some(target_file) = graph.files.get(&target.path.0) else { + continue; + }; + if local && target_file.scope != file.scope { + continue; + } + if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) { + continue; + } + if (target.path.0.as_path() != path || !contains(target.range, pos)) + && !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range) + { + answer.targets.push((*target).clone()); + } + } + } + } + result.filter(|r| !r.targets.is_empty()) + } + /// Gutter markers of visible files. Uses only existing graph entries; never executes WASM. + pub fn navigation_markers( + &self, + root: &Path, + paths: &[PathBuf], + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else { + return vec![]; + }; + let mut result = vec![]; + for path in paths { + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if version_of(path).is_some_and(|v| file.version != Some(v)) { + continue; + } + let mut markers = vec![]; + for anchor in &file.edges.markers { + let candidates = graph.targets(&anchor.key); + let local = + candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + let mut targets = vec![]; + for target in candidates { + let Some(other) = graph.files.get(&target.path.0) else { continue }; + if (local && other.scope != file.scope) + || version_of(&target.path).is_some_and(|v| other.version != Some(v)) + || (target.path.0 == *path && target.range == anchor.range) + { + continue; + } + if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) { + targets.push(target.clone()); + } + } + if !targets.is_empty() { + markers.push(NavigationResult { range: anchor.range, targets }); + } + } + if !markers.is_empty() { + result.push(FileMarkers { path: path.clone(), version: file.version, markers }); + } + } + } + result + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + extensions: Vec, +} +fn declaration(p: &super::LoadedPlugin) -> Option { + p.manifest() + .declarations("documentIndexers") + .into_iter() + .next() + .and_then(|d| decode("documentIndexers", d.clone()).ok()) +} +pub struct IndexersPoint; +impl ExtensionPoint for IndexersPoint { + fn id(&self) -> &'static str { + "documentIndexers" + } + fn placement(&self) -> Placement { + Placement::Workspace + } + fn prepare(&self, pkg: &Package, value: toml::Value) -> Result { + let d: Decl = decode(self.id(), value)?; + if pkg.manifest.wasm.is_none() + || d.extensions.is_empty() + || d.extensions.len() > 32 + || d.extensions + .iter() + .any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric())) + { + return Err("documentIndexers requires wasm and 1-32 file extensions".into()); + } + if pkg.manifest.declarations(self.id()).len() > 1 { + return Err("declare one document indexer per plugin".into()); + } + Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead)) + } +} diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index bdf23c660..940ac19c2 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -56,6 +56,7 @@ pub fn register_builtin_points( registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?; registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?; registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?; + registry.register(Arc::new(super::navigation::IndexersPoint))?; registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?; registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?; use super::ui_points as p; diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index b37c12e15..365c27bd2 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -180,10 +180,14 @@ pub fn set_editor_providers(host: Option<&Arc>) { /// The plugin host whose providers take part, when any declares `kind`. pub(crate) fn editor_providers(kind: ProviderKind) -> Option> { - let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?; + let host = editor_plugin_host()?; host.providers.any(kind).then_some(host) } +pub(crate) fn editor_plugin_host() -> Option> { + EDITOR_PROVIDERS.read().as_ref()?.upgrade() +} + impl PluginHost { /// Usable plugins declaring `kind` for `language`. fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 7b845a84d..d84667dca 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -448,6 +448,44 @@ impl RemotePlugins { .await } }); + let h = host.clone(); + d.add("plugin_navigation_files", move |c| { + let h = h.clone(); + async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await } + }); + let h = host.clone(); + d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move { + super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?) + } }); + let h = host.clone(); + d.add("plugin_navigation_index", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_index( + &c.host, + alive(&h)?, + &c.window, + c.arg("paths")?, + c.arg("request")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_goto", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_markers", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await + } + }); d.add("plugin_line_markers", |c| async move { crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await }); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 53c737e54..88e240c08 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -153,6 +153,8 @@ pub struct PluginUpdate { pub enum PluginEvent { /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. CommandsChanged, + /// Cached document associations were disposed (reload, disable or failure). + DocumentIndexesChanged, /// The marketplace offers newer versions of installed plugins (announced /// once per set of versions). UpdatesAvailable { updates: Vec }, diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs new file mode 100644 index 000000000..015136f91 --- /dev/null +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -0,0 +1,236 @@ +//! Generic document-indexer contract through a minimal real WASM fixture. +use ide_core::{Pos, Range, SymbolKind}; +use ide_host::plugins::navigation::Snapshot; +use ide_host::plugins::{Consent, PluginHost}; +use ide_language::SymbolTag; +use ide_plugin::{Invocation, Limits}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +struct Fixture { + _tmp: tempfile::TempDir, + root: PathBuf, + host: PluginHost, +} +impl Fixture { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + host.set_project_policy(Arc::new(|_| true)); + host.install_from_dir( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"), + &Consent::all(), + ) + .unwrap(); + Self { _tmp: tmp, root, host } + } + fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option, scope: &str) -> PathBuf { + let path = self.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + self.host + .index_navigation( + &self.root, + Snapshot { path: &path, version, text, symbols, scope }, + Invocation { workspace_root: Some(self.root.clone()), ..Default::default() }, + ) + .unwrap(); + path + } +} +fn method() -> SymbolTag { + SymbolTag { + name: "item".into(), + kind: SymbolKind::Method, + container: None, + range: Range::new(Pos::new(0, 4), Pos::new(0, 8)), + } +} +const SOURCE: &str = "def source:item\nref mapping:item\n"; +const MAPPING: &str = "def mapping:item\nref source:item\n"; +fn reference_pos() -> Pos { + Pos::new(1, 5) +} + +#[test] +fn bidirectional_navigation_versions_and_incremental_deletion() { + let f = Fixture::new(); + let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app"); + let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, source); + assert_eq!(result.range.end.col - result.range.start.col, 11); + let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(reverse.targets[0].path.0, mapping); + assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none()); + f.host.invalidate_navigation(&f.root, Some(&[source])); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn module_candidates_reload_disable_and_project_isolation() { + let f = Fixture::new(); + let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a"); + f.index("b/source.idx", SOURCE, &[method()], Some(1), "b"); + let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets.len(), 1); + assert_eq!(result.targets[0].path.0, first); + let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c"); + assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2); + assert!( + f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none() + ); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.set_enabled("test.indexer", true).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.reload("test.indexer").unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn warmed_clicks_never_read_or_parse_files() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + // Files become unreadable; the watcher invalidates separately. Resolution + // must remain purely in-memory, with no disk reads or component execution. + std::fs::remove_file(&source).unwrap(); + std::fs::remove_file(&mapping).unwrap(); + let calls = f.host.info_of("test.indexer").unwrap().stats.calls; + for _ in 0..10_000 { + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(), + 1 + ); + } + assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm"); + f.host.invalidate_navigation(&f.root, None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn permissions_and_paths_are_checked_before_indexing() { + let f = Fixture::new(); + let external = tempfile::NamedTempFile::new().unwrap(); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); + f.host.set_project_policy(Arc::new(|_| false)); + assert!(f.host.navigation_extensions(&f.root).is_empty()); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); +} + +#[test] +fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() { + let f = Fixture::new(); + let symbol = method(); + let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app"); + f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app"); + let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, definition); + assert_eq!(result.targets[0].range, method().range); + f.index("unicode.idx", "def 😀\n", &[], Some(1), "app"); + let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap(); + assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6))); +} + +#[test] +fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + for client in ["one", "two"] { + let mut symbol = method(); + if client == "two" { + symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9)); + } + for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] { + f.host + .index_navigation( + &f.root, + Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" }, + Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None }, + ) + .unwrap(); + } + } + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0] + .range + .start + .col, + 4 + ); + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0] + .range + .start + .col, + 5 + ); + f.host.invalidate_client_navigation(&f.root, Some("one"), None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some()); +} + +#[test] +fn markers_and_disable_follow_graph_state() { + let f = Fixture::new(); + let tags = vec![]; + let source = f.index("source.idx", SOURCE, &tags, Some(1), "app"); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app"); + let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None); + let source_markers = entries.iter().find(|e| e.path == source).unwrap(); + assert_eq!(source_markers.markers.len(), 1); + assert_eq!(source_markers.markers[0].targets[0].path.0, mapping); + let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap(); + assert_eq!(mapping_markers.markers.len(), 1); + assert_eq!(mapping_markers.markers[0].targets[0].path.0, source); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty()); + f.host.invalidate_navigation(&f.root, Some(&[mapping])); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty()); +} + +#[test] +fn invalid_ranges_and_oversized_documents_never_publish() { + let f = Fixture::new(); + let path = f.root.join("invalid.idx"); + std::fs::write(&path, "invalid key\n").unwrap(); + for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] { + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" }, + Invocation { workspace_root: Some(f.root.clone()), ..Default::default() }, + ) + .is_err() + ); + } + assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty()); +} diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index be02babc2..b52f666e1 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -189,6 +189,43 @@ impl ProjectIndex { out } + /// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or + /// not-yet-indexed contents fall back to the caller's parser. + pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option> { + let hash = blake3::hash(text.as_bytes()); + let data = self.shared.data.read(); + let file = data.get(path)?; + if &file.hash != hash.as_bytes() { + return None; + } + Some( + file.symbols + .iter() + .map(|s| ide_language::SymbolTag { + name: s.name.to_string(), + kind: s.kind, + container: s.container.as_ref().map(ToString::to_string), + range: s.range, + }) + .collect(), + ) + } + + /// Bounded file inventory for background plugin indexers; honors the same + /// project/exclude scope as search and never walks the filesystem. + pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec { + self.shared + .data + .read() + .files + .iter() + .flatten() + .filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e))) + .take(limit) + .map(|f| f.path.clone()) + .collect() + } + /// Fuzzy file search. IDEA-like ranking: file-name matches beat directory /// matches; `/` in the query matches path segments; recently opened files boost. pub fn find_files(&self, query: &str, limit: usize) -> Vec { diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index d8dadee2f..765f8cc17 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -632,3 +632,23 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() { assert!(idx.wait_idle(WAIT)); assert!(idx.find_files("", 100).is_empty()); } + +#[test] +fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() { + let java = "package demo; interface UserMapper { void find(); }"; + let f = Fixture::new(&[ + ("src/UserMapper.java", java), + ("src/mapper.xml", ""), + ("target/Generated.java", "class Generated {}"), + ("other.txt", "ignored"), + ]); + let index = f.open(); + let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100); + assert_eq!(paths.len(), 2); + assert!(paths.iter().all(|p| p.starts_with(f.root.join("src")))); + assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1); + let path = f.root.join("src/UserMapper.java"); + let symbols = index.symbols_for_text(&path, java).unwrap(); + assert!(symbols.iter().any(|s| s.name == "find")); + assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none()); +} diff --git a/crates/ide-plugin/src/indexing.rs b/crates/ide-plugin/src/indexing.rs new file mode 100644 index 000000000..fe0ef3e99 --- /dev/null +++ b/crates/ide-plugin/src/indexing.rs @@ -0,0 +1,53 @@ +//! Edges a `document-indexer` export returns, and what the host answers from them. +use ide_core::{Location, Range}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +#[derive(Clone, Debug, Deserialize, Serialize, Type)] +#[serde(deny_unknown_fields)] +pub struct Anchor { + pub key: String, + pub range: Range, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Alias { + pub prefix: String, + pub target: String, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Edges { + pub definitions: Vec, + pub references: Vec, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub markers: Vec, +} + +/// The resolved gutter markers of one file. +#[derive(Clone, Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileMarkers { + pub path: PathBuf, + pub version: Option, + pub markers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NavigationResult { + pub range: Range, + pub targets: Vec, +} + +pub struct IndexSymbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index fd27cb249..dae692bd1 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -9,6 +9,7 @@ //! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread. mod actor; +pub mod indexing; mod runtime; mod sandbox; mod types; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 6253c3710..ff442ab02 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -11,7 +11,7 @@ use crate::types::{ use crate::types::{ FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, }; -use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; +use crate::wit_host::{bindings, indexing_bindings, providers_bindings, syntax_bindings, views_bindings}; use ide_core::CancellationToken; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; @@ -27,6 +27,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use bindings::openidea::plugin::events as wit_events; use bindings::openidea::plugin::types as wit_types; +use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index; use providers_bindings::exports::openidea::plugin::providers as wit_prov; use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; use views_bindings::exports::openidea::plugin::view_provider as wit_view; @@ -484,6 +485,7 @@ struct Exports { plugin: bindings::Plugin, syntax: Option, providers: Option, + indexer: Option, views: Option, } @@ -560,6 +562,7 @@ impl Runtime { let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok(); + let index_indices = wit_index::GuestIndices::new(&pre).ok(); let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); let view_indices = wit_view::GuestIndices::new(&pre).ok(); let instance = pre.instantiate(&mut store).map_err(api_hint)?; @@ -572,13 +575,17 @@ impl Runtime { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let indexer = match index_indices { + Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; let views = match view_indices { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; Ok(Self { store, - exports: Exports { plugin, syntax, providers, views }, + exports: Exports { plugin, syntax, providers, views, indexer }, limits: setup.limits, version: setup.version, }) @@ -651,6 +658,47 @@ impl Runtime { self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) } + pub fn index_document( + &mut self, + path: &str, + text: &str, + symbols: &[crate::indexing::IndexSymbol], + invocation: Invocation, + ) -> Result { + if self.exports.indexer.is_none() { + return Err(CallError::missing_export("document-indexer")); + } + let doc = wit_index::Document { + path: path.into(), + text: text.into(), + symbols: symbols + .iter() + .map(|s| wit_index::Symbol { + name: s.name.clone(), + kind: s.kind.clone(), + container: s.container.clone(), + range: range_to_wit(s.range), + }) + .collect(), + }; + let edges = self.call(invocation, self.limits.timeout, |e, store| { + e.indexer.as_ref().expect("checked").call_index(store, &doc) + })?; + fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor { + crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) } + } + Ok(crate::indexing::Edges { + definitions: edges.definitions.into_iter().map(anchor).collect(), + references: edges.references.into_iter().map(anchor).collect(), + aliases: edges + .aliases + .into_iter() + .map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target }) + .collect(), + markers: edges.markers.into_iter().map(anchor).collect(), + }) + } + /// The component exports `providers`. pub fn has_providers(&self) -> bool { self.exports.providers.is_some() diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index a1f7b7533..c2154f365 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -696,3 +696,10 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {} /// Largest message between a plugin and its view's frame. const MAX_VIEW_MESSAGE: usize = 1 << 20; + +pub(crate) mod indexing_bindings { + wasmtime::component::bindgen!({ + world: "indexing", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 7ec2a34e2..f6f14abd9 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -23,9 +23,12 @@ fn repo() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } -/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture. +/// Official components plus fixtures for the base and optional indexing exports. fn components() -> Vec { - let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")]; + let mut out = vec![ + repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"), + ]; let official = repo().join("plugins/official"); let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect(); dirs.sort(); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 1efc352b6c893dabe6fc73f57966d9b28a04997f..b818841e36d2a74004a15e0dcf6747e777d512c1 100644 GIT binary patch delta 2686 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 A`v3p{ literal 0 HcmV?d00001 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources new file mode 100644 index 000000000..0c492615c --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -0,0 +1 @@ +87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/plugin.toml b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml new file mode 100644 index 000000000..a172e1422 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml @@ -0,0 +1,11 @@ +id = "test.indexer" +name = "Document indexer fixture" +version = "0.1.0" +engines.openidea = "^1.2" +wasm = "dist/plugin.wasm" + +[capabilities] +required = ["project.read"] + +[[contributes.documentIndexers]] +extensions = ["idx"] diff --git a/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs new file mode 100644 index 000000000..f5a1fdc9f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs @@ -0,0 +1,51 @@ +//! Deliberately tiny line-oriented fixture, not a language/framework plugin. +use openidea_plugin::indexing::{Alias, Anchor, Document, DocumentIndexer, Edges}; +use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin}; + +#[derive(Default)] +struct Indexer; +impl Plugin for Indexer { + fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result, Error> { + Err(Error::NotFound(id.into())) + } +} +impl DocumentIndexer for Indexer { + fn index(&mut self, doc: &Document) -> Edges { + let mut out = Edges::default(); + for (line, text) in doc.text.lines().enumerate() { + let Some((kind, value)) = text.split_once(' ') else { continue }; + let range = Range { + start: Position { line: line as u32, character: kind.len() as u32 + 1 }, + end: Position { line: line as u32, character: text.encode_utf16().count() as u32 }, + }; + let anchor = Anchor { key: value.into(), range }; + match kind { + "def" => out.definitions.push(anchor), + "ref" => { + out.references.push(anchor.clone()); + out.markers.push(anchor); + } + "alias" => { + if let Some((prefix, target)) = value.split_once(' ') { + out.aliases.push(Alias { prefix: prefix.into(), target: target.into() }); + } + } + "symbols" => out.definitions.extend( + doc.symbols + .iter() + .map(|symbol| Anchor { key: format!("{value}{}", symbol.name), range: symbol.range }), + ), + "invalid" => out.references.push(Anchor { + key: value.into(), + range: Range { + start: Position { line: 999, character: 0 }, + end: Position { line: 999, character: 1 }, + }, + }), + _ => {} + } + } + out + } +} +register_plugin!(Indexer, indexing); diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 58f3525f03ac3220971e9e5b4eeb85f708a9b116..8b2e04da24f592d60606e1bcfe1edc5c5048c13d 100644 GIT binary patch delta 2557 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 IdeResult<(PathBuf, u32)> { + self.with_doc(doc, |st| Ok((st.path.clone(), st.doc.version()))) + } + /// Current generation without copying the document text. pub fn version(&self, doc: DocId) -> IdeResult { self.with_doc(doc, |st| Ok(st.doc.version())) diff --git a/docs/plugins/indexing.md b/docs/plugins/indexing.md new file mode 100644 index 000000000..af146dd7c --- /dev/null +++ b/docs/plugins/indexing.md @@ -0,0 +1,54 @@ +# Background document indexers (API 1.2) + +Use `contributes.providers` for interactive definition, completion and line-marker +requests about one document. A document indexer is for cross-file associations +that need the whole project: it publishes keys once per document, and the host +resolves clicks and gutter markers from memory, without scanning files or +calling the plugin. + +```toml +[[contributes.documentIndexers]] +extensions = ["java", "xml"] +``` + +Implement `openidea_plugin::indexing::DocumentIndexer` and register with +`register_plugin!(Plugin, indexing)` (or `register_plugin!(Plugin, providers, indexing)` +when the plugin also has interactive providers). Components built with other +tools target the `plugin-indexing` world. Indexers require `project.read` and run +only in trusted projects. + +`index` gets the document's text (the editor's while it is open) and the symbols +the host's syntax support found in it, and returns: + +- `definitions`: where keys are declared; +- `references`: ranges that go to the definitions of their key on Go to + Declaration and Ctrl/⌘-click. Elsewhere the language server answers as usual; +- `aliases`: a `prefix#` whose keys also resolve under `target#`, for inherited + members. Chains are followed with bounded depth and candidates; +- `markers`: gutter icons that go to the definitions of their key. + +Keys are the plugin's own strings and ranges are UTF-16. When a key has +definitions in the same detected module, other modules' definitions are left +out. Several targets open a chooser. + +Initial discovery reuses the host's project index. The editor schedules +cancellable batches of at most 16 files; edits and file changes invalidate the +affected entries before they are indexed again. Reloading, disabling or a +failing runtime drops the plugin's graph, and nothing is answered from a +document version older than the editor's. + +Limits: 2 MiB per document, 20,000 files per graph, 4,096 definitions and +references and 4,096 markers per document, 256 aliases, 200,000 entries per +graph, 16 project/window graphs and 256 candidates per lookup. A document over +a limit is not indexed; editing is never blocked. + +## Minimal fixture + +`crates/ide-plugin/tests/fixtures/indexer` uses a tiny line-oriented `.idx` +language to exercise definitions, references, aliases, markers and host syntax +symbols without framework-specific parsing. Run +`cargo test -p ide-host --test plugin_indexing` for the real-component contract +checks. + +Framework plugins (MyBatis mappers, Rails routes) belong in their authors' +repositories and are listed through the registry; see `plugins/README.md`. diff --git a/plugins/README.md b/plugins/README.md index ed26a2a44..b4ebf4e76 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -159,6 +159,13 @@ key is true when any of its projects contains the path. It updates when files change. An IDE that doesn't know the key treats it as false: the plugin still installs and the key reads as false there. +### Background associations + +`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional +`document-indexer` export. It publishes cross-file associations (definitions, references, +aliases and gutter markers) in background batches, not on the editing path. See +[Background document indexers](../docs/plugins/indexing.md). + ### Activation Code is instantiated lazily: when one of the plugin's commands runs, or when diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index d6d6e810965b561b551981de7cafa1a1d21d30e6..a8f94345f2e894cf97d9b6914f970d56bf7de592 100644 GIT binary patch delta 191 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 E|0-m_^8f$< diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 2f0c2d75f..9b9e9e3da 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505 +34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 2f19e9b85fcd1cb993e2741ccc0b999ba036a95f..788f3fc2f1ba64025f919382b877852e3537af32 100644 GIT binary patch delta 2709 zcma)7eNa@_6@S0?7F<~tR)dt!x*x`X$qLFx00nu-Y7Z7A*CG+!4aA5Ju?Av5P_Da7Y=YE58j1 zO?^RQjIKNw5>C7xRN8c9Zz#MqWIPnY8M<*_kM1c6@<6~5|M`dT1;#&r0%#8Wq<@E$ z+TVNHD{OTOf>m(+%IozC4#C!}qg{~um79M%h$f|RAO}r>j|NVN7*g6UIg~?}lEr=J zm6MmgLwn%3c2d`G7~F_f5||uNyT>4()u-4 zF<4FZ2VyQqgJP1eRHB!?6^q2cGglS>rxo?@U!x^(czCUdapfQ1?80&7@vCL%P)=TT zMIGlUEwz9$(ix{*zxp&ZW!p%F=F zWg1toOR}K5i1OqBkpk+k-4wmuO8xa^!L|zDzHwNhabFu<4s-;x?|>Qp^LnPzFm*J{ z=vV?UqQrjRiIG6>_rIRPM|okq4g<=K@x>TZGAFjVhW_&ioREV$JMRs$7I=T+4G@`| zZe>Lc@%HVI#{&La4Zwh!4tS?1#NHLL6$9*U2@1N{I}&!FpSev)N*U&j+Yam{3IvNi z(mu^TEp#BmY?BF3qJ{OEP)8s~;8C3E$M}LUrdfj7drDpXnSC&u9Gl z4|zIq9j(iqs?UOZM0B&wF>tURD;5yb(_-*QQ1u6rX2s$`+JrsVOVF#*ZtSJKP#4a? zHX559oryIVQm@TK1?Z?)VI0}J)eqv(D2e-e*xCfFMw|MZ1k8dizmteXXkhzgjc^?(in9GeV1pD1sMbPI)V{|Z&Gs3RBJvpDMYpiQNatWji z|qAf~PZ4M({xbIGo3#~Dl zy^5fEAwDKZ&7xh@SVI<$CWHgc)QnUfZeP~el59FzjkRWDF2QHnm`89k8w>9>#`PHP zVU^ioiiRaveli8#9Bia;`gZ+*U@V9F(b&DYuoJAzMI}1ekzAA-W3)*QlR>Y>{*{Yo z$;Ep=en|ln_oJ5J(LCPL^La=%;u4&Zx0}+~yLrg}VR%BD(?mA9U8dF6aF4RaX5=G| zvdzs$I+du*Mo4F5Mhj3#?%9iQ z;C7xU%5!cJTFJ0$F{j|I#dwOOxrN9h*jR{j1oMj!Pv9xSa|CCLkU=m}gg%0cOL$yV zF?N&GPz*Q0AB*t?!6BJ@ekkKFB)z(nFUi@ZC?+Xp8S0FN6K-E4jlHl8S%xKUvgQ0T z{ER|NO7ICmtcPb`;z0*V;~w5eMk(*(L@BbNsb7~OmA37b<#3V9oaH>AYB@e4DXE-q za7{T^x>q8Vy;%;QQTdLZN-OwP*j)j;akO#H$jQwGD)3`UJ8K072_{yA)4oR18OlyD zxRUpnw2E&HTZIBrURZ@Q(0{ux??+q^&)@o>O&W zJ*VoYHT*b#TZ1hT7*?$t>8pcrR#%Jrq7MC)KOrXhnEHAxMkLZVK8Ee&Ht`tB;&Dp# z?1C0WIPY1!7eQ~`<^42x7gKyVfpJxS5nH6qzKePmrJZWF2q-z3>FNp4|1pW@G~vI{2G)PNMEU&Gf2e+Bl6Js6vCTvN+c^)tyN|D?w*TV_O8L>c)^?_ms%X=ScprAFaO7hmQgszMpAZq)dOH zuSMA876hx{`c+Fyi{KD!ojTeD*{@vx_kJ8u+6VKo-}lzwaZwx`P)>YeR}OrVE{62K zU!uqNO7Mh;L1pdGI&>+29&*zEe+{iii?Vn)pMHNaygj4if?g#{5*#L}%x+rTywWO$ z%gG(Sgp2V&P)Wa33qN}`5vjgCm*xSlDe9*m!|OZr*(wp&l>dId1ILtyE?1yeIdRz) zcZ{dBGy=-VsU+o#%a21-Hjh+`V*%y0kp+okyfGQYg5F9%5wB#4W9Jn275AOqcXc+l zi`1`Rmt;YA5#@;iA_X*Gxh{tOsWgAFBuuO1k5>;#)b8QYN}$&l{1TYjaZb-v9-SRE zGdh+63@eFWpTe*&@b$4te3Yj{O$aDgLklsYe`*zU?1dBb^ zKE*yIvXCHKZ^C2nvVaLq1ab@>KsWQo;PQ9-W!_k{O|C9cw@<;Cl-Bc}J|mj*X|MiA zzKv!bugiUEn+11?IK?(3z`@?PVjc$Almz@Ftoj2<(-ZMS@nmERDjkkpz)5xfRBWcc z+0kiOiJ*FA8md9NVkJprKc&8zgmy^``Pr%ztia3aZ&NUx?0)uoDi)xH{Vx?;Z|~;k zXTrgkyV=e(RAlyWgHxIitL`=WJ1MO3?_XN$zFW@1bHEbGfmRV6PiB1Q*L&qgW1rEL6&z>|Yqg2!`ELGWe{GLxgkOwKru6%k&VdMyWZqcujo z*Adju$KMGubLlW@tTh)$Q=)-pA|q8r>B|~hlt-J@Sa%*~5xkd&IRw}9F#m3&U60`& zRhb>8cv!;aCyt;cAL}TbzFt2d7|SO<8oRpyc7o*vs6{V3Qh;)!jULHiGRCX1uL|%a zxwPDiUsAyMy=WwOppaAgbs^G?xD;pXO;Q?rqYy=RL_4%OO=P3%Wm;j4a+EbTwFpU+ zZB`Mos6=fMQc2oW1aEXnCcA}}lw6E@GCW+&-B>Z^B^ilV7<15=R?HKO7NdmR^A=#= z%{+0G=j;M>lVQg~K7v;lVjD@bN>E6!t^{WZ=9VIvz*CB+2+ouun_#>Y7YNQT;&E|h z*hx}r8QcVaD#J$v2W0NqFT+pL!NvTLoLP)Ak`k7n$sn9^^BQUF=_SZDEJ+hB=a%5- z6uNXN{y~uF;n^2?aFV2uhx5oT=RA&=BM+MTaXB*Sv^`e|7rD%=|+ zs<_g<9GUE;Dzq7uZ{bv4&9B1FYS@j{COKm#lJiyLr<8X3G7J%nFN>x0VhI@pmFcw%x2*|oH2jWyJwz;L)_Ixp0sog8vk;>QI0 zSMnjPU8VC-_pYK>fVA;-oYv>{$Y5*g@CvEK)qD=}YHoRSH4Yi+ZyCXidOm{s`tKh> zTRk5EWgjw!&!Syqja5ixqxU0`YEkw~=30X%W<<;=5Y34ev5a1<8(22s)b_36qq@3= zkLr;IzRnjLurY={>ehAi)xkB^)QEfH+CJcKh)M2LpKrv7MEdpz@i4iKKZuHCbgP~n z2*wf4c@lq#p~vpxUTVCbDQ!58YpVPVHcA_IpVzY(k6ygp6!~QNd3sw%wEw6*EQEtXws zs0-U?p_OfW8zqjXAP6o&5Ek8L6Y2=JlI)luZ)5MjjlZ$ZckptINcx#-%^BPURH|Fg OVvC4=_2K|#7JdT(ZNqi| diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 6cae26b4b..e0795c324 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b +c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 5ec79db63d6149c59ef271082ffcc627fe02c831..0121a070d11eed6a1cd6ee6645775d91c47da961 100644 GIT binary patch delta 2575 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 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 8b8b88c5e..4ed042a00 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1 +2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 2ab1612dea15e8eec61357243263227aabf716c2..75255e055fb08cd67c8287d8ba3e75a6eab5ba8f 100644 GIT binary patch delta 2526 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 JPk`qU{{je0pTPhC delta 2348 zcmZvddr(wW7{Kp$@3Jf`D`3cgsN7vJ#X=F3F++#zE$~3P?({0@I*5nb~*t1s4DO&N<)ro$qzdxwj!h zUX>v?*0{lTsn%_htA3!qlu_y#A+^CacH7ta_O%;T7TOoJ_EANJ97UyFQgYa~-P0}?D!XcJmix_gl)mwvyAHC*FPj69Et{9Ke^-&n^4MrIFj`0XmD@(FyHR^v*>rv=Xu_1v-f5?idis(%nHg zPuYDC=OfeR!6g!x7Ug-#>R*7F48t=*4k<_rfp$`#HW|_hq>p+{&!T>O`af8L=c)HF z;)Xqq99AKG_db9q?acu2Hr%G^YE_IqM}s70`T-P>3;QdfK++vJHjE7kBMDgUArb{< za*rehwDC7iaaM zM2u5)h(IBUKm660tJZA2F7Lp0GD=>-b3A8H+(dd0kH+4*<$jHw(B&o|ALp*ZlhulS z|1XnhkL(*(I!ofef6k50sj;TW33o+05!`rI@JQr2p+E{hK7j=zQJz$jl>Di9ENAoM ztm|_)zjAUh0$d}ANBw?9k?&*bKlCn?Z!FQ3)A5+07tv~ZQhl_@v z7C^TYd~Oug%rr(}r;?4E-mv_)v4VxXGRBPUx5_o#L{_F@cpMZlj@AqPWNp#A&`*w9 z*;1s&`OBh&VSQ~mACB5H=YyeNsy%;54i=JFvJzQdvJ@=RxXU}`qYQUw2PU$98gRxi zNEb_wu%V(;#i|%K+I|!RjkXPphI4Gt$b(-X0uL7C6^w>JTZ^G7qrF9#S-GiuZQ}xx{H?xY5}zk!&@Eqvx|G9$9T#00SiO zw$|5SZ{*anA;u}UccTGY{Vf{nt5+l2t7&4&ngHh(KaG4aHIYQsyc6N@=4B1eyYjMF zl_OEXrsQ2CSuaQKazP{oH6a0x!mzo)f!R~v#E z`qxeMaR!ZbHcBL`>ip4YOWiEw#k!0^5ANt;L46^LU)Ixxgf%213mRr1I~uBy#yh^q ziaTk@*t=7Z`nyfY<@c!lzI)q{?v0_y8I5g7Uo*AQn(rV3n&^dzZHh+jZ%T0H<~R6q zMB>pL;Z*nxRV;4)68Dui-$EW}p0-8hJ|FG`wh^wT-10ZO8o}BdG=N%fn7)|k;l)7bcA3~BB;jkDC>XDjSii6Qa56H%1jOAFoRbpKkB z$1Jo+vt_Wz5l?858=uf3m-SNb>Aku37tRAdY)>T{jX#c8Fn`F6n$t&%-_%Eo|D%r{ z!i4_yF8HO*e7XYguiEqMZTEpX`ccAGyFkMqf@w>kly^(vwNAf@ufqT;_IU=Myjvs0HroH`8{ zCWv8}D;VxkqvM3YY*8R=i>*a4bX>uZn}GA^!r1 CaG1LQ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 13f5a9704..e0b0643a4 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286 +145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 6f41e7414ad446da0415c91bc1e290d16e3f6671..8659340b2187876cf5cdc826b1234c1f849805b9 100644 GIT binary patch delta 2821 zcmZuze^8Wl8h=0UF3Z9$u_QA5Jl|bN(L_T8%lNg^DRY$>!ifmN5-3P0!b$$b+es_D z;SBW6FEe=~nR9MkH*M22FI`N-T~bsd(Nw0F6((KGMCopFhYsF%gio>dezY z0y;4gBu9;AzjR>r&#rPV1f+@=DEFk`yDQ zKkoHF9}sh+uoq*ZCkn|l_LnHwL1P2aco-wXGY3hU%Z6;gFUsxs z3Kz5$F<2*4@ei@shl|4EKnbSBLk^@{FB(zXJi@aKbHxz{Evq6b4)^QYambJL8v<^u zl=MV;#K|};#GrUL4omKs_)JgPaf0beipeeMGO~N7hD?pr+wnhK6Df1>Ym8`&uik_n z?Q8-LfC=B12p{75|EXJA9V(r>UtCYZ(&zz$O}`gJZZa0(CE-bi>n3|dbSL8pOl!BN z0JB)pGE$KU7O)@3xDXkyWw3j5&~H40kN`+nI@7u*4!V&n<8s(q*6 zd0o?8LaRWHQ)s(VB@GW;U9MtFx4EbRoKDXBTL`MVL0RBhM{C zqU`-#dt(Xa%S_`~Hs{ck&C%%*x!KqhV7*N0zGWz5VFE4GN31|g%E4-mnC4lI7Un9> z<)W0MHZPB(L)*kfEzbv$SLL4jP zgieaj3XwwisSx)Pa*7OSErOF&ZxNbi)wLI68PAJ~k%39Ap&0+N@LD`jf-1t95>#*q zZtP!liPQEDu^TD^i{uub34}o@}+>W(!6Zir4IlUKN87Q>*x`{y3i3eY^*9`D>0vtSIO(Ain>ZHyE9ycRblZE%jz`S z5sYbOWv*1BHYwz4z~nMhHC$CyRBdF>RB>b@6vE3JIV@AhtFSsDq~bR@%`@bytMDYI z#FZ-Sr@W^c9>U+NjS#1*krLKRbXjf&sfx5sxF;uM(_uux8)jY`6N=d#I8-9@WJf3s zi(3_kH(_B)C`mRT!|grbw3tG3p^9^x&>w2HS+TL%dup(rQH|B`y_^z1*C0DS?0Idt zaH=S(MH1yIYf(TDwK#IqPBiUgd+-tuzVH|&imSseQitnsH{l<37$x*Rf&_x^5j;h> zs~$@Uo_bs)tZXp!wl$!W)VB@DA|!3b=Y*d&8Z!^N`Y%?b5=QgY)Gq)Y@LOpY%ol9lv_)Z*{-RIGrY^^E=cOow= za7#Tb9eA1o$2(9>_)mvXmNy^M!_dBejJtu{*4;)_rTdU7-q?*7$lAOIh1TF8N}_l_ zGRfS!pZWiL4?4rHZpr`gy+;18@12?d)xAdkw5{iV&wj%b4Lv(AXdQe*Vr_CUx7H0j zFr&xmokm&KcN%5+sMF|{^#B?pxUzQ~aZRIyj|63g8NzrkZ6VxZ}9p)PGiG#yf zOi%t{zGU*Duf&D7(WhhkJNP8cciC|Ak?w@wFh72vlkP}GCq@KyGPJG{T#lfV=25Hw zcgm>`ut;Vd&V7ix&AOq;^H@9QTA&b6WXG^>%w~H_+j<@=?_{p0A!j)UAW7+xB=uSy uQaSO@q@6QppZEdt^P*RlyQ9dKWd8azmq~5~?AlSYyh}!x79A;@*Zd!Q%&aQ_ delta 2817 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 delta 2903 zcmZ`*dr*|u75{#Bmxb&uLJCzBl6)+Q(u0&8MQIMMWell2#bu?kD_y~os z5nBWd-~j|hg#cy+6!em*Ql?`g8k-?AwCa>rGo!|7O{E42W=!qbZy#bp{@LH0bIU(dtzC zdw<%?*9C=5&L4JpTyPt8x-rmcceBBD=qPjqNkY9VCI7ct1AQ?AlTM8mr zs!zdQXcUu*6x35eDy9e2-*ZN?BUVvV#hy=pNyW2p(ATMW;hFwHJxgx^XHgAy#c3n# zqdf$Br2Gv3ZD)Wae3V5@ zI-WxBPXIr?^>jyc&=uH-KA6YF2SzJ zxFt9Yw9}QPh%%S`*Vzdb_Ieq%3`IbZY+R1~vC>7a#5zuQa3#vc$Zd*Vg-tO9`=*Z)9RJm%27VL%WQB8=H+x#uJ;F|9#T*Yy222 zhHc?Nw9~>Z$PMcM?jY>_^!XO%{3Uue51|aR@({=H8yB3;LpV!U^00PH-$h&T5})th zil=c&9^Z-^CUjDD0TwgdEx?=Tlxc-1H1S9E*)9YzyRPp-oru)FaSr$=BE%;A6*0p- zfq`#bTE%AYGR0^gptfR;uTg(7vKglDMjFHKcB9nlievLpoo3IJa6*l?mmr#c8vzhd3qWxt-xdkt%5&B zjoK>k((~SX&+?jkRDR(`E8JDsM)hK9B|nQA?Wx2BhL%d6gGK|DP#M0hWC9G(8+(y7 z-cwDxA>8Qgvqneu;ypH*O4!B@t%MZ}0abcQu~i85`i0m`4~x?1msOY+<%t<+Gx8aK zLB_fMo*M10L)AB01bWIg*)_UY#Si5Gp&Ao8Q)o5nJ+m{a7M|VNYAoP`Cu?-G*)^CN z>`gP*+ZBy=*XRM7Y7o!n*K1JnFpinyyt@w$KA2LgH)MG&HnViG7Oye{*5OlzD|Hyp zFkFY73_JGYMTXY>xXe&=K==E@0c>Y!T0J5evg`2=hFJ%7%iM$LXDQ{7&Yc~HFoUH( z9>Q90!6C_`3XVIBDDO$|=#zrO;18GX9>#fwo(4VoU;|FEbhJ^g<7%T`$FdK2TWYfW z13cm6-mxZLPa6HTN%!z+#-CaGq*-USxkXq0^FusAubqGoEos3{Z~jLr`?^K{PF^?y z)jRHBn`v~3YmQ(g$Nu;TzF=tm&=Y$fOL-2iMx!c+Ug;+eok_EgB9@hfM{y2XQ(7x; z5+-6Bc8p!Z)ixftM&@=zdkr4xQD!?z+2C3`mN3|l>8-LJch(`NALot0+S46+SqDxc zj2`Pm3#&Rhk>KZMqC$_iU?Me11n`NJuEuQl=6j?keJAvu_@4aXp2VNjd&23y@T3sm zO|j4YoT%VeB)}qO@G5(N)KRq9*rm6vp-XRD_(%GH7kq?lA5_ThZhoG42Tbh26!WPe zeHR+-7v$U?JZ5D5!ZTQpYx3+FB#*~{4EQ^)nHh(!quU2V)bVd_!VoRJg`>PQEMFqq zFsbvdGm85!kVUnsM(@j3Jji>!g51#Ulr(v95Lp6eWc*j?bKB#TO(K++0bBT(I9#b!!~p$t z)?b|F3;%YUc-6?p-9HlZf(Bd{hAIN@I5)V((k8=$#LOt%qBTjPE_^2xB|=e@ICG$q t$(YM>_$Ya^v?YuAg0{>NmwW^tSmmM=u>sg7&!>vF1%_n88{%@p{{c=7_rd@G diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index cb12a22ee..e59d759a4 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63 +c29594e55d321ee5240f8f61223d72c9095695a5b19ff1f8a25c5a6402a4a736 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index b49a3b60595df28d95cb5fb239d9228ec166cdfb..e99b1f6a2cc803c6d36df9451213ecb6e18dcf55 100644 GIT binary patch delta 2930 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 QfMu&lV)@@6TWaS12e@ZVssI20 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 914149fed..46f075233 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7 +01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 06d84ab2537feb164063ac7bff4dbb1bf00a798b..54d8b003a6861d8c9aa8f834ad8b483b3232fd75 100644 GIT binary patch delta 159 zcmexxn(f1BwhdP8j88UOw|8?gZkc?1LIC5E$@w!SHycj;!OnPQverx&#wDBcXWkKF z{Il6-%?VD%9*7uFk?mv;AD+oOcM41v*s*(Z-u8UPp3V2R?_*@#vU%=KFGj{Qn_uk8 z-lDjJk%2*(fq{XSU4S73NG}8O6_NNWwwL5HwzE$EUc%VJ$^?}7vc0F2F~tA?5+pk= delta 163 zcmexxn(f1BwhdP8j1M+jw|8?gZkT+0LIC4}$@w!SHycj;!OnPMverx&#s!=6XWkKF z{IS_*%?VD%4u}{~k?mv;AD+oOcM41v*s*(Z-u8UPj?MSB?_*@#uzBuIFGj`_n_uk8 z-lEvf$iSe?z`(%EF2E21q)UN(MI?Ut_L6+YcGl_tOBg#if#M8IK>T5QPbp)H0RY9Q BI&%O3 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 65e44bd13..6c4e41b3c 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7 +745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 46412ea4cc265737d60a235bbdaf34d5f6d4c44b..8ad1974a609df31bb99e0779653496644dd02bc0 100644 GIT binary patch delta 2871 zcmaJ@e^8Wl8h=0EeS>vjPdc(dPz=*pIO+%;t|c{_y(MeST~@t-IUV z*y7=s5lZoe{ALlda8+6zm_t&A11B*iJq`-#lJk<0KE2CV%84fxKFz2+ zkPJJTWJfX<#`m4%HX>@P_^1Y_!al+|IYLmx?PT1eh+8RGjZsPML~CnP@ri+&30sOa zz*Q~h2>=iGyus-*10*_owEQO#1>&c283xg!tx3mQ4{(!rmSYdmw)-ihqfVZA3I%h< z|9d1hICY)H*Cw1}+T$zm7ob&syAmlTukS1tIP0_+C+dK4t?OyHex}?kxp6htQOz@} z(F~7F&A=;5#(e!poUbN2lrW3YVz3zeQ@N{~di|ylOQtL1M%Chd&&-bNG<6LMw_Ho&VD)@U-GM`q!@ez=bVHoO(!B#&vqpRdn;}7{Hrk;C zVay87e?*S&z%q=;#9SZj$%U1qW4YM#z=RLwA(`w;^Dvi=pPPqMA?TJrJ&)}Kn_j?H zy0CwK0o5ThMtxNiM3fD|YNOh!0-P2}HJ!YLY!hZhtn^RG^OmkvY=%IDO?7&txe#+j zO{YvLL_TACA=WbfqYw?@dMevP7WS;(kF9L+?8jop$ReaNK2wDCjO|63&G=CfmNVWd z!fzQjA3!qW?++l8@#_PKeJs$VYBOb+;qS4gQ&V3-7})FKgEU&g{DZj6m~;qx8Cwsb zGdd7$q}B}21kU3mbM|4p#BLW4BcAcbVb~deaRiP9K@Kv4Apw=qs)i#>?_&xtO-GQ& z3C54$QAT4i9E{6~5y!Zr7}<1xx{Hw+JXDXtss@QCpJ}h6mV6ptMHz>fOR$r1UkU6f z`cW()Y2tz*Osv%Zf_wxf8FIg43jDGJOXC6@MA%d^(tT93&4Cjz6V_5ha+S1FBvA34 zrHE#+q!e!k=Bir4Xs+>P*us|bGN0qyWpK<0CeIEsMIK{ih+@x>a-=h^F30ivxlEMn zS~o;U?y{=#0fLQrKJ>0=YruMkSt>3ugZo~zb>=9eYlx{zDV;3inKaTGi%?-iiZ7hF7 zMHqi-(7T@Bh*FmS)QBe-ziY%GV|NoKXkWVjfQp}KXT=F5u;<>G_cF6 zW^89X)2vhYQj4!Ct-S@6K8j*b>RsMzMT~s$B;H}~UrL>y4N^BAl{ghFG?kjiTXkyk zTmKI=9j!VwROEZ3|H27bSdVDAUt&6!q!O_*xeXN-za449IGrve{h&_ZH1`8_;~yBdA}QN@pPz#t|VRFOu3UR8V;N*U#aa8XIGGO1<}kwrh> z89Acd+6P6MqbSO|(;}1{!naAbPLj`RH*&-k$al90&ot62g8r9k^;^X}V3*eWoY*bs Mwdk?!;$Y%`0If69j{pDw delta 2867 zcmaJ@drVYU8o$3YSC|e=rKPqYC^Mt1jDU3j(N?TZ6|h|`KJc+BXktrsOGT^?%5&sV zSs8&9>OmeYCZ=n5=@uXDZK`b~i(R(Tgl74}CfKmmv~JQUZQPU?Bl~@GXRgrNW|I4x z?|jem`@VDUX=j< zWusr{_G2P1&M_XR{L5dwebJXoOGQ!>Ew{o$z{kX0jIZ@RYuJwhZNW~4! z9f73+qq1fy=A%~oWGZ$6Em}e}3V~DFThnmXG;OH8$D{l)QBkbQjDsGJN3knm?Tj|X zJtTM8(TJ0B%#L~J)aE(xo)9C=(jE(&RAUhZue8Nt6c@F~-{G{t4S6{ZTX9atJK?~H ze8GwB=#*!i>@ROPQ5^kI2Pbrz6uW9#VpE^3SZx)0X7@*0`D}y&w`E;CR^hrf6p!bD zJ~=B9-%t+A9IO$jmN|1#f*$$RTx6nG+Fh7IQo0Mra9s|$$faG*Nq7_ z!*Wj&oT!!UNthGceT>TptEuFzn%oN82={moK_0h~aF6zQGa1V`c03}r6)E`CM8$+G zz;d8h%YF*LBd6Zva2Wv--KVs?rx5}2tyqXYc(vteIPm~8d1n!J5N+F^K^i==^BLsN zxbgFrIN;XTY`!w#zOFsF7=H&E<=0D)Y#H(G#Rj)Wi*lnD)ccdq!t*QXrpYzSu#$3i zE<+OrWlB0;n}6L`f7qESqC*L>nQbPU$={XRy6UW76=Vyx1qW2}>vZfzw_KY+yXck| zGLVZ4GAa|de#wZ8S`P1nmltXOTaHm;Ro+}dtPN+CsYT` zP@FJl8vP%TBU`W#12Q4U2Rm|LC+Tnwc04fPq+BGCenBo~()M$5aXg65-A^xKGr`)I zumLBvzrBR2pee(?tZ^d30VCV6wlp6dBJr-F6IMkm@pozP1kYxiP#mU!heLG_N^1dT zin|>$xd3^LZ3S4t_}>E5hw7mmlWc5RwHq5)GqfA?7{d#Z!uVVvRx!2}VmjmbLM&pu zQ;0t>uG@no#y{;r2IH4|5dB1;NY!CUx58iJ-40EC4IyBwNA^-{3G?>i5@X^%>||`& zhqodF-exMz^ju&+Zc=CM$17}hVLxISukDAE@!% z$@*4oL8;=LDMT#Me+D0V&mtT__bVpH&x^4jCcr_MLnS4hM>WeD*a0(PFF`nGNi9Jf zCEr?tNEVAr@OGfDsx5^28e59>tSKw?8NO2r*AydomcbNRd|!$PwhSsm8soAu9JwFM zLb0xvp^G(jtM!q)T8+glPp`qt#-1YXA5c=Y)*#7HJvKr0 zpBfahclI0j7h}vJJxkUhoM$<-R zo?WM(kezk<`l5P7%Ma?{H42$n<9e@t;*NL`V+`42j-UzE{=Hxf;x=E)YF zn!J|Zpr*Y=r-qVzYxW;FAq(mdDR)au=9H8oS|+uk+~(IKRhTE!fus-W7=Ax~NC#SV z0!?i?fu1(K?H{z^{l}*_xB9LWm;E0~h5YGG;XlyTTICs>w$f`n6xeyo6WDZ02Xkq}NWX_UaI>S@_SI?_do^v$pLf1_Yt&2aKA@VfGyi zlEc^?%r{Z{`tKn_mfgb(^wp#Pw^e*EdrV@CK^3XwII4<|gDIjs7b+^~DwARrVVU&# zPR|x)_CrvV8H%F(^`S5&n{Xe=_Hpt7?OL|D40&d~xH3tQB1LQ3AZ7s@weuUrc0pI7 Kn9X8u!v6tWP6)UF diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ff540a74c..5e33d0308 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea +4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517 diff --git a/sdk/rust/openidea-plugin/src/indexing.rs b/sdk/rust/openidea-plugin/src/indexing.rs new file mode 100644 index 000000000..bb55b9272 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/indexing.rs @@ -0,0 +1,46 @@ +//! Background document indexing (`[[contributes.documentIndexers]]`): return +//! the keys a document defines and refers to; the host keeps the project-wide +//! graph and resolves Go to Declaration and gutter markers from it. Register +//! with `register_plugin!(Plugin, indexing)`. +use crate::Range; + +/// A symbol the host's syntax support found in the document. +#[derive(Clone, Debug)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} + +pub struct Document { + pub path: String, + pub text: String, + pub symbols: Vec, +} + +/// A range tied to a key the plugin chooses. +#[derive(Clone, Debug)] +pub struct Anchor { + pub key: String, + pub range: Range, +} + +/// Keys starting with `prefix` also resolve under `target` (both end with `#`). +pub struct Alias { + pub prefix: String, + pub target: String, +} + +#[derive(Default)] +pub struct Edges { + pub definitions: Vec, + pub references: Vec, + pub aliases: Vec, + /// Gutter icons that go to the definitions of their key. + pub markers: Vec, +} + +pub trait DocumentIndexer { + fn index(&mut self, doc: &Document) -> Edges; +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index ee9686957..ad2cce7bb 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -40,6 +40,7 @@ //! wasm32-wasip2`); see `plugins/README.md`. pub mod host; +pub mod indexing; #[cfg(not(target_arch = "wasm32"))] pub mod mock; pub mod providers; @@ -114,6 +115,17 @@ pub mod views_bindings { }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod indexing_bindings { + wit_bindgen::generate!({ + world: "indexing", path: "wit", pub_export_macro: true, + export_macro_name: "__export_indexing_world", + default_bindings_module: "openidea_plugin::indexing_bindings", + with: { "openidea:plugin/types@1.2.0": crate::bindings::openidea::plugin::types }, + }); +} + /// The plugin API version this SDK targets. pub const API_VERSION: &str = "1.2"; @@ -164,6 +176,29 @@ macro_rules! register_plugin { $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); }; }; + ($plugin:ty, indexing) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__indexing_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers, indexing) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::__indexing_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; ($plugin:ty, views) => { #[cfg(not(target_arch = "wasm32"))] const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; @@ -313,6 +348,20 @@ macro_rules! __views_exports { }; } +#[doc(hidden)] +#[macro_export] +macro_rules! __indexing_exports { + ($plugin:ty, $component:ident) => { + impl $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Guest for $component { + fn index(doc: $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document) + -> $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + $crate::__rt::index_document::<$plugin>(doc) + } + } + $crate::indexing_bindings::__export_indexing_world!($component with_types_in $crate::indexing_bindings); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! __plugin_exports { @@ -522,6 +571,37 @@ pub mod __rt { .collect() } + pub fn index_document( + d: crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document, + ) -> crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + use crate::indexing as i; + use crate::indexing_bindings::exports::openidea::plugin::document_indexer as w; + let doc = i::Document { + path: d.path, + text: d.text, + symbols: d + .symbols + .into_iter() + .map(|s| i::Symbol { + name: s.name, + kind: s.kind, + container: s.container, + range: crate::host::__range_from(s.range), + }) + .collect(), + }; + let e = with::(|p| p.index(&doc)); + fn anchor(a: i::Anchor) -> w::Anchor { + w::Anchor { key: a.key, range: crate::host::__range_to(a.range) } + } + w::Edges { + definitions: e.definitions.into_iter().map(anchor).collect(), + references: e.references.into_iter().map(anchor).collect(), + aliases: e.aliases.into_iter().map(|a| w::Alias { prefix: a.prefix, target: a.target }).collect(), + markers: e.markers.into_iter().map(anchor).collect(), + } + } + // ---- views ---- use crate::views as v; diff --git a/sdk/rust/openidea-plugin/wit/indexing.wit b/sdk/rust/openidea-plugin/wit/indexing.wit new file mode 100644 index 000000000..e9edc27d2 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.2.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 35e154de1..12d2dc71b 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -50,6 +50,12 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +71,8 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 398f73d2f..3e4b0d47c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -481,6 +481,11 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_web_save_file, plugin::plugin_web_read_file, plugin::plugin_line_markers, + plugin::plugin_navigation_files, + plugin::plugin_navigation_invalidate, + plugin::plugin_navigation_index, + plugin::plugin_navigation_goto, + plugin::plugin_navigation_markers, plugin::plugin_market, plugin::plugin_market_url, plugin::plugin_set_market_url, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index bb565af4f..989c906e1 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -633,3 +633,67 @@ pub fn plugin_set_theme( pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option) -> IdeResult<()> { state.plugins.set_theme_frame(color.as_deref()) } + +use ide_host::plugins::index_ops::NavigationFiles; + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_files(window: Window, state: State<'_, AppState>) -> IdeResult { + ide_host::plugins::index_ops::plugin_navigation_files(&state.host, state.plugins.clone(), window.label()).await +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_navigation_invalidate( + window: Window, + state: State<'_, AppState>, + paths: Option>, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_invalidate( + &state.host, + state.plugins.clone(), + window.label(), + paths, + ) +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_index( + window: Window, + state: State<'_, AppState>, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_index( + &state.host, + state.plugins.clone(), + window.label(), + paths, + request, + ) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_goto( + window: Window, + state: State<'_, AppState>, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_goto(&state.host, state.plugins.clone(), window.label(), doc, pos) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_markers( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_markers(&state.host, state.plugins.clone(), window.label(), paths) + .await +} diff --git a/src/bindings.ts b/src/bindings.ts index dd55fd6b0..9ede3d710 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1043,6 +1043,14 @@ export const commands = { pluginWebReadFile: (path: string) => typedError(__TAURI_INVOKE("plugin_web_read_file", { path })), /** Gutter markers of plugin `line-markers` providers for an open document. */ pluginLineMarkers: (doc: DocId) => typedError(__TAURI_INVOKE("plugin_line_markers", { doc })), + pluginNavigationFiles: () => typedError(__TAURI_INVOKE("plugin_navigation_files")), + pluginNavigationInvalidate: (paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_navigation_invalidate", { paths })), + pluginNavigationIndex: (paths: string[], request: RequestId) => typedError(__TAURI_INVOKE("plugin_navigation_index", { paths, request })), + pluginNavigationGoto: (doc: DocId, pos: Pos) => typedError<{ + range: Range, + targets: Location[], +} | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), + pluginNavigationMarkers: (paths: string[]) => typedError(__TAURI_INVOKE("plugin_navigation_markers", { paths })), /** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */ pluginMarket: (refresh: boolean) => typedError(__TAURI_INVOKE("plugin_market", { refresh })), pluginMarketUrl: () => __TAURI_INVOKE("plugin_market_url"), @@ -3632,6 +3640,13 @@ export type FileLineStats = { removed: number, }; +/** The resolved gutter markers of one file. */ +export type FileMarkers = { + path: string, + version: number | null, + markers: NavigationResult[], +}; + export type FileMatch = { path: IpcPath, /** Path relative to the project root, as displayed. */ @@ -5346,6 +5361,17 @@ export type MethodStat = { total: number | null, }; +export type NavigationFiles = { + paths: IpcPath[], + indexing: boolean, + extensions: string[], +}; + +export type NavigationResult = { + range: Range, + targets: Location[], +}; + export type NewAgentTask = { /** Validated by the host; defaults to the window project root. */ projectRoot?: IpcPath | null, @@ -5592,6 +5618,8 @@ export type PluginEditor = { export type PluginEvent = /** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ { type: "commandsChanged" } | +/** Cached document associations were disposed (reload, disable or failure). */ +{ type: "documentIndexesChanged" } | /** * The marketplace offers newer versions of installed plugins (announced * once per set of versions). diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 187b79c7a..ddf811ebf 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -1,3 +1,9 @@ +import { + contributedNavigation, + navigationEpoch, + onNavigationProvidersChanged, + type NavigationMatch, +} from "../navigationProviders"; /** * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B, * ⌘-click with a link underline while ⌘ is held, and a plain middle-button @@ -69,7 +75,7 @@ import { declarationSummary, renderCode } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs"; import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap"; -import { isNavigationGesture, onNavigationModifier } from "@/platform/navigationModifier"; +import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier"; import { create } from "zustand"; import { tx } from "@/platform/i18n"; @@ -251,6 +257,21 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) const own = contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); if (own?.(view, offset)) return; if (showPhpConstructDocumentation(view, offset)) return; + const before = view.state.doc; + const plugin = await contributedNavigation(view, offset).catch(() => null); + if (view.state.doc !== before) return; + if (plugin) { + if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!); + else + openPopup({ + kind: "list", + anchor: caretAnchor(view, offset), + title: tx`Choose Declaration`, + items: await locationItems(plugin.targets), + pin: pinToFind(view.state.sliceDoc(plugin.from, plugin.to), plugin.targets), + }); + return; + } } const doc = docOf(view); if (!doc) return; @@ -468,7 +489,12 @@ function linkAt(view: EditorView, x: number, y: number): { from: number; to: num * Whether ⌘-click at `offset` would navigate somewhere, with the declaration * info to show; null when there is no target (no underline then, as in IDEA). */ -async function probeNavigation(view: EditorView, offset: number): Promise<{ info: NavInfo | null } | null> { +async function probeNavigation( + view: EditorView, + offset: number, +): Promise<{ info: NavInfo | null; plugin?: NavigationMatch } | null> { + const plugin = await contributedNavigation(view, offset).catch(() => null); + if (plugin) return { info: null, plugin }; const doc = docOf(view); let navigable = !!contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); if (!navigable && doc) { @@ -490,10 +516,12 @@ const linkPlugin = ViewPlugin.fromClass( class { last: { x: number; y: number } | null = null; /** Probe results per word (`from:to`) for the current document. */ - private probed = new Map(); + private probed = new Map(); private probedDoc: Text | null = null; + private providerEpoch = -1; private probing: string | null = null; private off: () => void; + private offProviders: () => void; private disposed = false; private hovered: string | null = null; private probeVersion = 0; @@ -503,6 +531,15 @@ const linkPlugin = ViewPlugin.fromClass( const offModifier = onNavigationModifier((held) => { if (!held || this.last) this.show(held); }); + this.offProviders = onNavigationProvidersChanged(() => { + if (this.disposed) return; + this.probeVersion++; + clearTimeout(this.probeTimer); + this.probing = null; + this.probed.clear(); + this.set(null); + if (this.last) this.show(navigationModifierHeld()); + }); // IDEA's macOS "Force touch" shortcut: a force click navigates like ⌘-click // (the keyboard dispatcher keeps the system Look Up popover away). const onForceDown = (e: Event) => { @@ -518,6 +555,7 @@ const linkPlugin = ViewPlugin.fromClass( } destroy() { this.off(); + this.offProviders(); this.disposed = true; this.probeVersion++; clearTimeout(this.probeTimer); @@ -560,14 +598,17 @@ const linkPlugin = ViewPlugin.fromClass( this.set({ from: target.from, to: target.to, info: null }); return; } - if (this.probedDoc !== view.state.doc) { + if (this.probedDoc !== view.state.doc || this.providerEpoch !== navigationEpoch()) { + this.providerEpoch = navigationEpoch(); this.probed.clear(); this.probedDoc = view.state.doc; } const key = `${target.from}:${target.to}`; if (this.probed.has(key)) { const hit = this.probed.get(key); - this.set(hit ? { from: target.from, to: target.to, info: hit.info } : null); + this.set( + hit ? { from: hit.plugin?.from ?? target.from, to: hit.plugin?.to ?? target.to, info: hit.info } : null, + ); return; } const current = view.state.field(linkField); @@ -584,15 +625,23 @@ const linkPlugin = ViewPlugin.fromClass( } }, 3000); const docAt = view.state.doc; + const epochAt = navigationEpoch(); void probeNavigation(view, target.from) .then((result) => { - if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version) return; + if ( + this.disposed || + view.state.doc !== docAt || + this.probeVersion !== version || + epochAt !== navigationEpoch() + ) + return; clearTimeout(this.probeTimer); this.probed.set(key, result); if (this.probing !== key) return; this.probing = null; if (result) { - this.set({ from: target.from, to: target.to, info: null }); + this.set({ from: result.plugin?.from ?? target.from, to: result.plugin?.to ?? target.to, info: null }); + if (result.plugin) return; // Documentation must never delay the link cursor or underline. void requestHover(view, target.from) .then((hover) => { diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts index ef49c770f..a02a935b7 100644 --- a/src/editor/lsp/navigationMouse.test.ts +++ b/src/editor/lsp/navigationMouse.test.ts @@ -1,3 +1,4 @@ +import { registerNavigationProvider } from "../navigationProviders"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -79,6 +80,23 @@ function click(init: MouseEventInit) { } describe("mouse navigation", () => { + it("uses plugin navigation for clicks without asking the language server", async () => { + const off = registerNavigationProvider(async () => ({ + from: 0, + to: 13, + targets: [{ path: "/p/Mapper.xml", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 8 } } }], + })); + try { + click({ metaKey: true }); + await vi.waitFor(() => + expect(openFile).toHaveBeenCalledWith("/p/Mapper.xml", { selection: { line: 2, col: 4 }, focus: true }), + ); + expect(lspGoto).not.toHaveBeenCalled(); + } finally { + off(); + } + }); + it("opens the implementation of the symbol clicked with Option+Command", async () => { const event = click({ metaKey: true, altKey: true }); await vi.waitFor(() => diff --git a/src/editor/navigationProviders.test.ts b/src/editor/navigationProviders.test.ts new file mode 100644 index 000000000..e8669edc0 --- /dev/null +++ b/src/editor/navigationProviders.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + contributedNavigation, + invalidateNavigationProviders, + registerNavigationProvider, + type NavigationMatch, +} from "./navigationProviders"; + +const disposers: (() => void)[] = []; +const views: EditorView[] = []; +afterEach(() => { + disposers.splice(0).forEach((f) => f()); + views.splice(0).forEach((v) => v.destroy()); +}); +function view() { + const v = new EditorView({ state: EditorState.create({ doc: "demo.UserMapper" }) }); + views.push(v); + return v; +} +const hit: NavigationMatch = { + from: 0, + to: 15, + targets: [{ path: "/src/UserMapper.java", range: { start: { line: 0, col: 0 }, end: { line: 0, col: 10 } } }], +}; + +describe("navigation providers", () => { + it("shares pending hover/click requests and whole-name results", async () => { + let calls = 0; + let finish!: (value: NavigationMatch) => void; + disposers.push( + registerNavigationProvider(() => { + calls++; + return new Promise((r) => { + finish = r; + }); + }), + ); + const v = view(); + const hover = contributedNavigation(v, 0); + const click = contributedNavigation(v, 0); + finish(hit); + expect(await hover).toEqual(hit); + expect(await click).toEqual(hit); + expect(await contributedNavigation(v, 8)).toEqual(hit); + expect(calls).toBe(1); + }); + it("drops results after edits and re-queries the new document", async () => { + let finish!: (value: NavigationMatch) => void; + const remove = registerNavigationProvider( + () => + new Promise((r) => { + finish = r; + }), + ); + disposers.push(remove); + const v = view(); + const request = contributedNavigation(v, 0); + v.dispatch({ changes: { from: 0, insert: "x" } }); + finish(hit); + expect(await request).toBeNull(); + }); + it("invalidates misses after background indexing and removes disabled providers", async () => { + let ready = false; + let calls = 0; + const off = registerNavigationProvider(async () => { + calls++; + return ready ? hit : null; + }); + disposers.push(off); + const v = view(); + expect(await contributedNavigation(v, 0)).toBeNull(); + expect(await contributedNavigation(v, 0)).toBeNull(); + expect(calls).toBe(1); + ready = true; + invalidateNavigationProviders(); + expect(await contributedNavigation(v, 0)).toEqual(hit); + off(); + expect(await contributedNavigation(v, 0)).toBeNull(); + }); + it("drops old results after project/provider invalidation", async () => { + let finish!: (value: NavigationMatch) => void; + disposers.push( + registerNavigationProvider( + () => + new Promise((r) => { + finish = r; + }), + ), + ); + const request = contributedNavigation(view(), 0); + invalidateNavigationProviders(); + finish(hit); + expect(await request).toBeNull(); + }); +}); diff --git a/src/editor/navigationProviders.ts b/src/editor/navigationProviders.ts new file mode 100644 index 000000000..0cb197a16 --- /dev/null +++ b/src/editor/navigationProviders.ts @@ -0,0 +1,85 @@ +/** Optional, asynchronous navigation contributions. Results are versioned by + * the editor text and provider epoch; hover and click share bounded requests. */ +import type { EditorView } from "@codemirror/view"; +import type { Location } from "@/platform/ipc"; + +export interface NavigationMatch { + from: number; + to: number; + targets: Location[]; +} +type Provider = (view: EditorView, offset: number) => Promise; +const providers = new Set(); +let epoch = 0; +export function navigationEpoch() { + return epoch; +} +const listeners = new Set<() => void>(); +let notifyPending = false; +export function onNavigationProvidersChanged(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +export function invalidateNavigationProviders() { + epoch++; + if (!notifyPending) { + notifyPending = true; + queueMicrotask(() => { + notifyPending = false; + for (const listener of listeners) listener(); + }); + } +} +export function registerNavigationProvider(provider: Provider) { + providers.add(provider); + invalidateNavigationProviders(); + return () => { + providers.delete(provider); + invalidateNavigationProviders(); + }; +} +const caches = new WeakMap< + EditorView, + { + text: EditorView["state"]["doc"]; + epoch: number; + pending: Map>; + hits: NavigationMatch[]; + } +>(); +export async function contributedNavigation(view: EditorView, offset: number): Promise { + if (!providers.size) return null; + const text = view.state.doc; + let cache = caches.get(view); + if (!cache || cache.text !== text || cache.epoch !== epoch) { + cache = { text, epoch, pending: new Map(), hits: [] }; + caches.set(view, cache); + } + const hit = cache.hits.find((h) => h.from <= offset && offset < h.to); + if (hit) return hit; + const pending = cache.pending.get(offset); + if (pending) return pending; + const current = cache; + const query = (async () => { + for (const provider of providers) { + const answer = await provider(view, offset); + if (current.epoch !== epoch || view.state.doc !== text) return null; + if (answer && answer.targets.length && answer.from >= 0 && answer.to <= text.length && answer.from < answer.to) { + current.hits.push(answer); + if (current.hits.length > 128) current.hits.shift(); + return answer; + } + } + return null; + })(); + if (cache.pending.size >= 128) cache.pending.delete(cache.pending.keys().next().value!); + cache.pending.set(offset, query); + try { + return await query; + } catch (error) { + cache.pending.delete(offset); + throw error; + } +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index bcf4c39eb..651ffa04e 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -34,6 +34,8 @@ import { startViewUi } from "./uiPoints"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { IntellijThemesDialogHost } from "./IntellijThemesDialog"; import { startWorkspaceContains } from "./workspaceContains"; +import { startIndexedMarkers } from "./indexedMarkers"; +import { startPluginNavigation } from "./navigation"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store"; function registerPluginsPage(): () => void { @@ -143,6 +145,8 @@ export function activate(): () => void { }, ]), startPluginBridge(), + startPluginNavigation(), + startIndexedMarkers(), // Plugin custom editors (tab kind) and context menu entries. startViewUi((input) => void editorService().openInput(input)), startWorkspaceContains(), diff --git a/src/features/plugins/indexedMarkers.test.ts b/src/features/plugins/indexedMarkers.test.ts new file mode 100644 index 000000000..724e9f2e4 --- /dev/null +++ b/src/features/plugins/indexedMarkers.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import type { FileMarkers } from "@/platform/ipc"; +import { indexedMarkers } from "./indexedMarkers"; +import { usePluginMarkers } from "./markerStore"; + +const calls = vi.hoisted(() => ({ navigate: vi.fn(), popup: vi.fn() })); +vi.mock("@/editor/lsp/navigation", () => ({ navigateTo: calls.navigate, locationItems: async () => [] })); +vi.mock("@/editor/lsp/popupStore", () => ({ openPopup: calls.popup })); + +let view: EditorView | undefined; +afterEach(() => { + view?.destroy(); + view = undefined; + usePluginMarkers.setState({ entries: new Map() }); + vi.clearAllMocks(); +}); + +const source = '\n\n'; +function setup() { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: source, extensions: [indexedMarkers("/p/Query.xml")] }), + }); + const range = (from: number, to: number) => offsetsToRange(view!.state.doc, from, to); + const target = { path: "/p/Query.java", range: range(0, 4) }; + const at = source.indexOf("find"); + const entry: FileMarkers = { + path: "/p/Query.xml", + version: 1, + markers: [{ range: range(at, at + 4), targets: [target] }], + }; + usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) }); + return { entry, target }; +} +const button = () => view!.dom.querySelector(".cm-plugin-mapping-button"); + +it("puts a button on the marker's line that goes to its target", async () => { + const { target } = setup(); + await vi.waitFor(() => expect(button()).not.toBeNull()); + expect(button()!.title).toContain("Query.java:1"); + expect(button()!.getAttribute("aria-label")).toContain("Query.java:1"); + button()!.click(); + expect(calls.navigate).toHaveBeenCalledWith(target); +}); + +it("hides markers on edit and when the entry goes away", async () => { + setup(); + await vi.waitFor(() => expect(button()).not.toBeNull()); + view!.dispatch({ changes: { from: 0, insert: "\n" } }); + expect(button()).toBeNull(); + usePluginMarkers.setState({ entries: new Map() }); + await vi.waitFor(() => expect(button()).toBeNull()); +}); + +it("merges a line's targets and offers a chooser for several", async () => { + const { entry, target } = setup(); + const other = { ...target, path: "/p/Other.java" }; + usePluginMarkers.setState({ + entries: new Map([ + [entry.path, { ...entry, markers: [...entry.markers, { ...entry.markers[0]!, targets: [target, other] }] }], + ]), + }); + await vi.waitFor(() => expect(button()!.title).toContain("Other.java")); + expect(view!.dom.querySelectorAll(".cm-plugin-mapping-button")).toHaveLength(1); + button()!.click(); + await vi.waitFor(() => expect(calls.popup).toHaveBeenCalled()); +}); diff --git a/src/features/plugins/indexedMarkers.ts b/src/features/plugins/indexedMarkers.ts new file mode 100644 index 000000000..559db2796 --- /dev/null +++ b/src/features/plugins/indexedMarkers.ts @@ -0,0 +1,129 @@ +/** + * Gutter markers from plugins' document indexers, resolved by the host from + * its cached graph. A click goes to the marker's targets (a chooser when there + * are several); an edit hides the markers until the edited text is indexed. + */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import { registerEditorContribution } from "@/editor/api"; +import { rangeToOffsets } from "@/editor/lsp/positions"; +import { editorPrefs } from "@/platform/editorSettings"; +import { useProject } from "@/platform/project"; +import type { FileMarkers, Location } from "@/platform/ipc"; +import { markerIcon } from "./lineMarkerGutter"; +import { usePluginMarkers, watchPluginMarkers } from "./markerStore"; +import { navigationButton } from "./navigationButton"; + +class MappingMarker extends GutterMarker { + constructor(readonly targets: Location[]) { + super(); + } + override eq(other: GutterMarker) { + return other instanceof MappingMarker && JSON.stringify(other.targets) === JSON.stringify(this.targets); + } + override toDOM() { + return navigationButton(this.targets, markerIcon(null)); + } +} + +const setMarkers = StateEffect.define>(); +const markersField = StateField.define>({ + create: () => RangeSet.empty, + update(value, tr) { + if (tr.docChanged) value = RangeSet.empty; + for (const effect of tr.effects) if (effect.is(setMarkers)) value = effect.value; + return value; + }, +}); + +/** One marker per line, with the targets of every marker on it. */ +export function markersOf(view: EditorView, entry: FileMarkers | undefined): RangeSet { + if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty; + const doc = view.state.doc; + const rows = new Map(); + for (const marker of entry.markers) { + const line = doc.lineAt(rangeToOffsets(doc, marker.range).from).from; + const targets = rows.get(line) ?? []; + for (const target of marker.targets) + if (!targets.some((t) => t.path === target.path && JSON.stringify(t.range) === JSON.stringify(target.range))) + targets.push(target); + rows.set(line, targets); + } + return RangeSet.of( + [...rows].map(([pos, targets]) => new MappingMarker(targets).range(pos)), + true, + ); +} + +export function indexedMarkers(path: string): Extension { + return [ + markersField, + gutter({ class: "cm-plugin-mapping-gutter", markers: (v) => v.state.field(markersField) }), + EditorView.baseTheme({ + ".cm-plugin-mapping-button": { + border: "none", + padding: "0 2px", + background: "transparent", + color: "inherit", + cursor: "pointer", + height: "100%", + display: "flex", + alignItems: "center", + }, + ".cm-plugin-mapping-button svg": { width: "16px", height: "16px" }, + ".cm-plugin-mapping-button:focus-visible": { outline: "1px solid var(--accent)" }, + }), + ViewPlugin.fromClass( + class { + last: FileMarkers | undefined; + queued = false; + disposed = false; + readonly off: () => void; + readonly unwatch: () => void; + readonly prefs: () => void; + constructor(readonly view: EditorView) { + this.off = usePluginMarkers.subscribe(() => this.schedule()); + this.unwatch = watchPluginMarkers(path); + this.prefs = useProject.subscribe((s, prev) => { + if (s.settings !== prev.settings) { + this.last = undefined; + this.schedule(); + } + }); + this.schedule(); + } + update(update: ViewUpdate) { + if (update.docChanged) this.last = undefined; + } + schedule() { + if (this.queued || this.disposed) return; + this.queued = true; + queueMicrotask(() => { + this.queued = false; + if (!this.disposed) this.refresh(); + }); + } + refresh() { + const entry = usePluginMarkers.getState().entries.get(path); + if (entry && entry === this.last) return; + this.last = entry; + this.view.dispatch({ effects: setMarkers.of(markersOf(this.view, entry)) }); + } + destroy() { + this.disposed = true; + this.off(); + this.unwatch(); + this.prefs(); + } + }, + ), + ]; +} + +export function startIndexedMarkers() { + return registerEditorContribution({ + id: "plugins.indexedMarkers", + order: 65, + extensions: (ctx) => (ctx.virtual || ctx.doc < 0 ? [] : indexedMarkers(ctx.path)), + }); +} diff --git a/src/features/plugins/lineMarkerGutter.test.ts b/src/features/plugins/lineMarkerGutter.test.ts new file mode 100644 index 000000000..61180e6d5 --- /dev/null +++ b/src/features/plugins/lineMarkerGutter.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import type { PluginEvent } from "@/bindings"; +import { pluginLineMarkerGutter } from "./lineMarkerGutter"; +const mock = vi.hoisted(() => ({ + markers: vi.fn(), + event: undefined as ((event: PluginEvent) => void) | undefined, + off: vi.fn(), +})); +vi.mock(import("@/platform/ipc"), async (original) => { + const actual = await original(); + return { ...actual, commands: { ...actual.commands, pluginLineMarkers: mock.markers } }; +}); +vi.mock("./backend", () => ({ + pluginsBackend: () => ({ + onEvent: (fn: (event: PluginEvent) => void) => { + mock.event = fn; + return mock.off; + }, + }), +})); +vi.mock("./navigationButton", () => ({ navigationButton: () => document.createElement("button") })); +let view: EditorView | undefined; +afterEach(() => { + view?.destroy(); + view = undefined; + vi.useRealTimers(); + vi.clearAllMocks(); +}); +it("withdraws old interactive markers on reload and does not refresh on idle status changes", async () => { + vi.useFakeTimers(); + mock.markers.mockResolvedValue({ + status: "ok", + data: [{ pluginId: "test", line: 0, icon: "navigate", tooltip: "old", target: null }], + }); + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "select", extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext) }), + }); + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).not.toBeNull(); + const calls = mock.markers.mock.calls.length; + mock.event!({ type: "commandsChanged" }); + await vi.advanceTimersByTimeAsync(450); + expect(mock.markers).toHaveBeenCalledTimes(calls); + mock.markers.mockResolvedValue({ status: "ok", data: [] }); + mock.event!({ type: "documentIndexesChanged" }); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull(); + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull(); + view.destroy(); + view = undefined; + expect(mock.off).toHaveBeenCalledOnce(); +}); diff --git a/src/features/plugins/lineMarkerGutter.ts b/src/features/plugins/lineMarkerGutter.ts index 23e3149fc..4fa34cb2c 100644 --- a/src/features/plugins/lineMarkerGutter.ts +++ b/src/features/plugins/lineMarkerGutter.ts @@ -1,6 +1,5 @@ /** - * Gutter icons from plugins' `line-markers` providers (MyBatis' arrows - * between a mapper interface and its XML). Asked once per document version + * Gutter icons from plugins' `line-markers` providers. Asked once per document version * (debounced); markers follow their lines in between; a click opens the * marker's target. The gutter column exists only while there are markers, * so editors without plugin providers keep their usual width. @@ -8,7 +7,8 @@ import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; -import { navigateTo } from "@/editor/lsp/navigation"; +import { navigationButton } from "./navigationButton"; +import { pluginsBackend } from "./backend"; import { commands, type LineMarkerInfo } from "@/platform/ipc"; import { gutterIconMarkup } from "@/ui"; import styles from "@/editor/navigation/LineMarkers.module.css"; @@ -58,14 +58,16 @@ class PluginMarker extends GutterMarker { return ( other instanceof PluginMarker && other.marker.tooltip === this.marker.tooltip && - other.marker.icon === this.marker.icon + other.marker.icon === this.marker.icon && + JSON.stringify(other.marker.target) === JSON.stringify(this.marker.target) ); } override toDOM(): Node { - const el = document.createElement("div"); - el.className = styles.marker!; + const el = this.marker.target + ? navigationButton([this.marker.target], markerIcon(this.marker.icon)) + : document.createElement("span"); + el.className = `${styles.marker} cm-plugin-mapping-button`; el.title = this.marker.tooltip; - el.setAttribute("role", "button"); el.setAttribute("aria-label", this.marker.tooltip); el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? ""; return el; @@ -76,12 +78,20 @@ class MarkersPlugin { private timer: ReturnType | null = null; private generation = 0; private shown = false; + private off: () => void; constructor( private view: EditorView, private doc: number, private column: Compartment, ) { + this.off = pluginsBackend().onEvent((event) => { + if (event.type !== "documentIndexesChanged") return; + this.generation++; + this.shown = false; + this.view.dispatch({ effects: [setMarkers.of([]), this.column.reconfigure([])] }); + this.schedule(); + }); this.schedule(); } @@ -116,6 +126,7 @@ class MarkersPlugin { } destroy() { + this.off(); if (this.timer) clearTimeout(this.timer); this.generation++; } @@ -128,17 +139,6 @@ const markerColumn: Extension = gutter({ view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)), true, ), - domEventHandlers: { - mousedown: (view, block, event) => { - const e = event as MouseEvent; - if (e.button !== 0) return false; - const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target; - if (!target) return false; - e.preventDefault(); - void navigateTo(target); - return true; - }, - }, }); export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension { diff --git a/src/features/plugins/markerStore.test.ts b/src/features/plugins/markerStore.test.ts new file mode 100644 index 000000000..5c953c86c --- /dev/null +++ b/src/features/plugins/markerStore.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { watchPluginMarkers, usePluginMarkers, invalidatePluginMarkers } from "./markerStore"; +import { invalidateNavigationProviders } from "@/editor/navigationProviders"; +const mock = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { pluginNavigationMarkers: mock }, +})); +const off: (() => void)[] = []; +afterEach(() => { + off.splice(0).forEach((f) => f()); + vi.useRealTimers(); + mock.mockReset(); +}); +it("batches visible paths, shares watchers and does no polling while idle", async () => { + vi.useFakeTimers(); + mock.mockResolvedValue({ status: "ok", data: [] }); + for (let i = 0; i < 130; i++) off.push(watchPluginMarkers(`/p/${i}.java`)); + off.push(watchPluginMarkers("/p/0.java")); + await vi.advanceTimersByTimeAsync(250); + expect(mock.mock.calls.map((a) => a[0].length)).toEqual([64, 64, 2]); + await vi.advanceTimersByTimeAsync(5000); + expect(mock).toHaveBeenCalledTimes(3); +}); +it("discards in-flight results after the project/provider epoch changes", async () => { + vi.useFakeTimers(); + let finish!: (r: unknown) => void; + mock.mockImplementationOnce(() => new Promise((r) => (finish = r))).mockResolvedValue({ status: "ok", data: [] }); + off.push(watchPluginMarkers("/old/A.xml")); + await vi.advanceTimersByTimeAsync(250); + invalidateNavigationProviders(); + finish({ status: "ok", data: [{ path: "/old/A.xml", version: 1, markers: [] }] }); + await vi.advanceTimersByTimeAsync(250); + expect(usePluginMarkers.getState().entries.size).toBe(0); +}); +it("invalidates both changed files and markers targeting them", () => { + const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }; + usePluginMarkers.setState({ + entries: new Map([ + [ + "/p/A.xml", + { + path: "/p/A.xml", + version: 1, + markers: [{ range, targets: [{ path: "/p/A.java", range }] }], + }, + ], + ]), + }); + invalidatePluginMarkers(["/p/A.java"]); + expect(usePluginMarkers.getState().entries.size).toBe(0); +}); diff --git a/src/features/plugins/markerStore.ts b/src/features/plugins/markerStore.ts new file mode 100644 index 000000000..680344e5b --- /dev/null +++ b/src/features/plugins/markerStore.ts @@ -0,0 +1,83 @@ +/** Gutter markers of document indexers for the files open in editors: bounded IPC, no file reads. */ +import { create } from "zustand"; +import { call, commands, isTauri, type FileMarkers } from "@/platform/ipc"; +import { navigationEpoch, onNavigationProvidersChanged } from "@/editor/navigationProviders"; + +export const usePluginMarkers = create<{ entries: ReadonlyMap }>(() => ({ + entries: new Map(), +})); +const visible = new Map(); +let timer: ReturnType | undefined; +let running = false; +let session = 0; +let again = false; +let off: (() => void) | undefined; +function schedule() { + again = true; + if (!timer && !running) + timer = setTimeout(() => { + timer = undefined; + void refresh(); + }, 200); +} +async function refresh() { + if (!isTauri() || running) return; + running = true; + again = false; + const epoch = navigationEpoch(); + const current = session; + const paths = [...visible.keys()]; + const entries = new Map(); + try { + for (let i = 0; i < paths.length; i += 64) { + const results = await call(commands.pluginNavigationMarkers(paths.slice(i, i + 64))); + if (epoch !== navigationEpoch() || current !== session) return; + for (const result of results) { + if (!visible.has(result.path)) continue; + const previous = entries.get(result.path); + entries.set( + result.path, + previous ? { ...previous, markers: [...previous.markers, ...result.markers] } : result, + ); + } + } + if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries }); + } catch { + // Plugin navigation may be unavailable during project close or reload. + if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries: new Map() }); + } finally { + running = false; + if (again) schedule(); + } +} +export function watchPluginMarkers(path: string): () => void { + if (!path || !isTauri()) return () => {}; + if (!visible.size) + off = onNavigationProvidersChanged(() => { + schedule(); + }); + visible.set(path, (visible.get(path) ?? 0) + 1); + schedule(); + return () => { + const n = (visible.get(path) ?? 1) - 1; + if (n) visible.set(path, n); + else visible.delete(path); + if (!visible.size) { + session++; + off?.(); + off = undefined; + clearTimeout(timer); + timer = undefined; + again = false; + usePluginMarkers.setState({ entries: new Map() }); + } + }; +} +export function invalidatePluginMarkers(paths?: readonly string[]) { + const entries = new Map(usePluginMarkers.getState().entries); + for (const [path, entry] of entries) { + if (!paths || paths.includes(path) || entry.markers.some((m) => m.targets.some((t) => paths.includes(t.path)))) + entries.delete(path); + } + usePluginMarkers.setState({ entries }); +} diff --git a/src/features/plugins/navigation.test.ts b/src/features/plugins/navigation.test.ts new file mode 100644 index 000000000..df603bd8d --- /dev/null +++ b/src/features/plugins/navigation.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useProject } from "@/platform/project"; +import { usePlugins } from "./store"; +import { useEditorRegistry } from "@/editor/api"; +import { lspDoc } from "@/editor/lsp/lsp"; + +const mocks = vi.hoisted(() => ({ + files: vi.fn(), + index: vi.fn(), + invalidate: vi.fn(), + cancel: vi.fn(), + events: new Map void>(), + pluginEvent: null as null | ((e: any) => void), +})); +vi.mock("./backend", () => ({ + pluginsBackend: () => ({ + onEvent: (callback: (e: any) => void) => { + mocks.pluginEvent = callback; + return () => { + mocks.pluginEvent = null; + }; + }, + }), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + onIdeEvent: (type: string, callback: (e: any) => void) => { + mocks.events.set(type, callback); + return () => mocks.events.delete(type); + }, + commands: { + ...real.commands, + pluginNavigationFiles: mocks.files, + pluginNavigationIndex: mocks.index, + pluginNavigationInvalidate: mocks.invalidate, + cancelRequest: mocks.cancel, + }, + }; +}); +const { startPluginNavigation } = await import("./navigation"); +let off: () => void; +let view: EditorView | null = null; +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.events.clear(); + mocks.files.mockResolvedValue({ + status: "ok", + data: { paths: ["/project/A.java", "/project/A.xml"], indexing: false, extensions: ["xml", "java"] }, + }); + mocks.index.mockResolvedValue({ status: "ok", data: null }); + mocks.invalidate.mockResolvedValue({ status: "ok", data: null }); + mocks.cancel.mockResolvedValue(undefined); + useProject.setState({ project: { root: "/project", trusted: true } as never }); + usePlugins.setState({ + plugins: [ + { + id: "test", + version: "1", + status: "active", + contributions: [{ point: "documentIndexers", label: "xml, java" }], + } as never, + ], + }); +}); +afterEach(() => { + off?.(); + view?.destroy(); + view = null; + vi.useRealTimers(); + usePlugins.setState({ plugins: [] }); + useProject.setState({ project: null }); +}); + +it("prewarms once and indexes only changed paths after file events", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.files).toHaveBeenCalledTimes(1); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.java", "/project/A.xml"]); + mocks.index.mockClear(); + mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]); + mocks.events.get("indexState")?.({ indexing: false, extensions: ["xml", "java"] }); + await vi.advanceTimersByTimeAsync(100); + expect(mocks.files).toHaveBeenCalledTimes(1); +}); + +it("indexes unsaved editor changes and refreshes after same-version plugin reload", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + const ctx = { doc: 1, path: "/project/A.xml", language: "xml", uiMode: "XML", readOnly: false }; + const extension = useEditorRegistry.getState().contributions.get("plugins.navigation")!; + view = new EditorView({ + state: EditorState.create({ doc: "", extensions: [lspDoc.of(ctx), extension.extensions(ctx)] }), + }); + await vi.advanceTimersByTimeAsync(250); + mocks.index.mockClear(); + view.dispatch({ changes: { from: 7, insert: " " } }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]); + mocks.pluginEvent?.({ type: "documentIndexesChanged" }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.invalidate).toHaveBeenCalledWith(null); + expect(mocks.files).toHaveBeenCalledTimes(2); +}); + +it("cancels outstanding work when project context changes", async () => { + mocks.index.mockImplementation(() => new Promise(() => {})); + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + useProject.setState({ project: null }); + expect(mocks.cancel).toHaveBeenCalledTimes(1); +}); + +it("keeps warm indexes when API 1.0 reclaims an idle Wasm instance", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + mocks.index.mockClear(); + mocks.pluginEvent?.({ type: "commandsChanged" }); + usePlugins.setState({ plugins: usePlugins.getState().plugins.map((p) => ({ ...p, status: "inactive" })) }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.files).toHaveBeenCalledTimes(1); + expect(mocks.index).not.toHaveBeenCalled(); +}); diff --git a/src/features/plugins/navigation.ts b/src/features/plugins/navigation.ts new file mode 100644 index 000000000..ebf44bc96 --- /dev/null +++ b/src/features/plugins/navigation.ts @@ -0,0 +1,207 @@ +/** Background navigation indexing. Opening a project discovers files once; + * edits and filesystem events then enqueue only affected paths. */ +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { registerEditorContribution, whenSynced } from "@/editor/api"; +import { invalidateNavigationProviders, registerNavigationProvider } from "@/editor/navigationProviders"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetToPos, rangeToOffsets } from "@/editor/lsp/positions"; +import { call, commands, isCancelled, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; +import { pluginsBackend } from "./backend"; +import { invalidatePluginMarkers } from "./markerStore"; +import { usePlugins } from "./store"; + +export function startPluginNavigation(): () => void { + if (!isTauri()) return () => {}; + let generation = 0; + let disposed = false; + let running = false; + let request: number | null = null; + let timer: ReturnType | undefined; + let pending = new Set(); + const dirty = new Set(); + const views = new Map(); + let discovered = false; + let discovering = false; + const known = new Set(); + let signature = ""; + let reportedError = false; + function report(error: unknown) { + console.warn("Plugin navigation", error); + if (reportedError) return; + reportedError = true; + notifications().notify({ + group: "plugins", + level: "warning", + title: "Plugin navigation indexing incomplete", + body: String(error), + }); + } + let fileExtensions = new Set(); + const extensions = () => fileExtensions; + const eligible = (path: string) => extensions().has(path.split(".").pop() ?? ""); + const ready = () => + !disposed && + !!useProject.getState().project?.trusted && + usePlugins + .getState() + .plugins.some( + (p) => + (p.status === "active" || p.status === "inactive") && + p.contributions.some((c) => c.point === "documentIndexers"), + ); + const notify = () => invalidateNavigationProviders(); + + async function flush() { + timer = undefined; + if (running || !ready() || !pending.size) return; + running = true; + const at = generation; + const paths = [...pending].slice(0, 8); + paths.forEach((p) => pending.delete(p)); + const id = nextRequestId(); + request = id; + try { + await Promise.all([...views].filter(([, p]) => paths.includes(p)).map(([v]) => whenSynced(v))); + if (at !== generation || !ready()) return; + await call(commands.pluginNavigationInvalidate(paths)); + await call(commands.pluginNavigationIndex(paths, id)); + if (at === generation) { + paths.forEach((p) => { + if (!pending.has(p)) dirty.delete(p); + }); + notify(); + } + } catch (error) { + if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p)); + else if (at === generation) report(error); + } finally { + request = null; + running = false; + if (pending.size && ready()) timer = setTimeout(() => void flush(), 20); + } + } + function queue(paths: string[], priority = false) { + const matching = paths.filter(eligible); + if (!matching.length || !ready()) return; + invalidatePluginMarkers(matching); + matching.forEach((p) => dirty.add(p)); + pending = priority ? new Set([...matching, ...pending]) : new Set([...pending, ...matching]); + notify(); + clearTimeout(timer); + timer = setTimeout(() => void flush(), priority ? 150 : 30); + } + async function discover() { + if (!ready() || discovered || discovering) return; + discovering = true; + const at = generation; + try { + const inventory = await call(commands.pluginNavigationFiles()); + if (at !== generation) return; + fileExtensions = new Set(inventory.extensions); + discovered = !inventory.indexing; + const fresh = inventory.paths.filter((p) => !known.has(p)); + inventory.paths.forEach((p) => known.add(p)); + queue(fresh); + queue([...views.values()], true); + } catch (error) { + discovered = false; + report(error); + } finally { + if (at === generation) discovering = false; + if (!discovered && ready() && at === generation) setTimeout(() => void discover(), 1000); + } + } + function reset() { + invalidatePluginMarkers(); + generation++; + if (request !== null) void commands.cancelRequest(request); + clearTimeout(timer); + pending.clear(); + dirty.clear(); + known.clear(); + discovered = false; + discovering = false; + reportedError = false; + notify(); + if (ready()) + void call(commands.pluginNavigationInvalidate(null)) + .then(discover) + .catch(() => {}); + } + const offs = [ + pluginsBackend().onEvent((event) => { + if (event.type === "documentIndexesChanged") reset(); + }), + registerNavigationProvider(async (view, offset) => { + const doc = docOf(view); + if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null; + const before = view.state.doc; + await whenSynced(view); + const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset))); + if (!result || view.state.doc !== before) return null; + return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) }; + }), + registerEditorContribution({ + id: "plugins.navigation", + extensions: (ctx) => + ctx.virtual || ctx.doc < 0 + ? [] + : ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + views.set(view, ctx.path); + queue([ctx.path], true); + } + update(update: ViewUpdate) { + if (update.docChanged) queue([ctx.path], true); + } + destroy() { + views.delete(this.view); + queue([ctx.path]); + } + }, + ), + }), + onIdeEvent("fsChanged", (event) => { + const paths = [...event.created, ...event.modified, ...event.removed]; + queue(paths); + }), + onIdeEvent("indexState", (event) => { + if (!event.indexing) void discover(); + }), + onIdeEvent("projectRootsChanged", () => reset()), + useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) reset(); + }), + usePlugins.subscribe((state) => { + const next = state.plugins + .map( + (p) => + `${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`, + ) + .join("|"); + if (next !== signature) { + signature = next; + reset(); + } + }), + ]; + // Index completion events may predate feature activation; querying the current + // inventory now is safe, and an incomplete inventory is retried on IndexState. + signature = usePlugins + .getState() + .plugins.map( + (p) => + `${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`, + ) + .join("|"); + void discover(); + return () => { + disposed = true; + reset(); + offs.reverse().forEach((off) => off()); + views.clear(); + }; +} diff --git a/src/features/plugins/navigationButton.ts b/src/features/plugins/navigationButton.ts new file mode 100644 index 000000000..b7e1ebf2c --- /dev/null +++ b/src/features/plugins/navigationButton.ts @@ -0,0 +1,34 @@ +import { tx } from "@/platform/i18n"; +import { navigateTo, locationItems } from "@/editor/lsp/navigation"; +import { openPopup } from "@/editor/lsp/popupStore"; +import { gutterIconMarkup, isIconName } from "@/ui/icons"; +import type { Location } from "@/platform/ipc"; + +async function openTargets(targets: Location[], element: HTMLElement) { + if (targets.length === 1) { + await navigateTo(targets[0]!); + return; + } + const rect = element.getBoundingClientRect(); + openPopup({ + kind: "list", + title: tx`Choose Mapping Target`, + anchor: { x: rect.left, y: rect.bottom + 2 }, + items: await locationItems(targets), + }); +} +/** Shared ordinary-click and keyboard navigation for interactive and indexed markers. */ +export function navigationButton(targets: Location[], icon: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cm-plugin-mapping-button"; + button.title = targets.map((t) => `${t.path.split("/").pop()}:${t.range.start.line + 1}`).join("\n"); + button.setAttribute("aria-label", tx`Go to mapping: ${button.title}`); + button.innerHTML = gutterIconMarkup(isIconName(icon) ? icon : "arrow-right") ?? ""; + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + void openTargets(targets, button); + }; + return button; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4a5f567a4..aa9ed6c2e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6329,5 +6329,9 @@ "HR (ALTER SESSION SET CURRENT_SCHEMA)": "HR(ALTER SESSION SET CURRENT_SCHEMA)", "Instant Client:": "Instant Client:", "Directory of Oracle Instant Client 19 or later. Empty: ORACLE_HOME or the system library path.": "Oracle Instant Client 19 或更高版本的目录。留空则使用 ORACLE_HOME 或系统库路径。", - "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.": "连接后设置的会话参数(ALTER SESSION SET name = value)。connect_timeout 以秒为单位。" + "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.": "连接后设置的会话参数(ALTER SESSION SET name = value)。connect_timeout 以秒为单位。", + "Choose Mapping Target": "选择映射目标", + "Plugin navigation indexing incomplete": "插件导航索引未完成", + "Choose Declaration": "选择声明", + "Go to mapping: {target}": "跳转到映射:{target}" } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index c7339fbde..4faeac875 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -1124,3 +1124,8 @@ export function folderIconName(kind: FolderKind = "plain"): IconName { return "folder"; } } + +/** Validated built-in and registered, sanitized plugin icons. */ +export function isIconName(name: string): name is IconName { + return iconPath(name) !== undefined; +} diff --git a/wit/indexing.wit b/wit/indexing.wit new file mode 100644 index 000000000..e9edc27d2 --- /dev/null +++ b/wit/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.2.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 35e154de1..12d2dc71b 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -50,6 +50,12 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +71,8 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +}