feat(plugins): share project HTTP endpoints with the HTTP client (#1991)

Adds the API 1.3 `endpoints-provider` export and `[[contributes.endpoints]]` (file globs, ^1.3, `project.read`). A plugin's routes join the Spring ones in Search Everywhere, `.http` URL completion and the declaration's Open in HTTP Client gutter action. The UI asks `plugin_endpoints` on demand; the host runs the plugin on a blocking thread, validates the whole list (bounds, project-relative locations, UTF-16 ranges) and caches it per project until a file matching the globs changes, then emits `EndpointsChanged`. A failed or invalid run keeps the last list; disabling, crashing or closing the project withdraws it, and late results can't restore it. Closes #1960.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 22:59:21 +08:00
1 джерело 4ef4adf9b3
коміт 668206ddfd
86 files changed
+1709 -97

No files matched your search

+318
Переглянути файл
@@ -0,0 +1,318 @@
//! `[[contributes.endpoints]]`: HTTP routes a plugin finds in a project. The UI
//! asks for them (`plugin_endpoints`); the host caches each plugin's list per
//! project until a file matching its globs changes, then emits `EndpointsChanged`.
use super::{PluginEvent, PluginHost};
use ide_core::{IdeResult, ipc_path::to_ipc};
use ide_extension::{Capability, Package, UiDecl, decode};
use ide_plugin::{Endpoint, HostError, Invocation};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub(super) const POINT: &str = "endpoints";
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Decl {
files: Vec<String>,
}
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
let d: Decl = decode(POINT, value)?;
pkg.manifest.needs_api("contributes.endpoints", 3)?;
if pkg.manifest.wasm.is_none() || d.files.is_empty() || d.files.len() > 64 {
return Err("contributes.endpoints needs code and 1–64 file globs".into());
}
for file in &d.files {
if file.is_empty()
|| file.len() > 1024
|| file.starts_with('/')
|| file.contains('\\')
|| file.contains(':')
|| file.split('/').any(|p| p == "..")
{
return Err("endpoint file globs must be project-relative".into());
}
matcher(file).map_err(|e| e.to_string())?;
}
let key = serde_json::to_string(&d.files).map_err(|e| e.to_string())?;
Ok(UiDecl::new(key, "HTTP endpoints", serde_json::to_value(d).map_err(|e| e.to_string())?))
}
fn matcher(glob: &str) -> Result<globset::GlobMatcher, globset::Error> {
Ok(globset::GlobBuilder::new(glob).literal_separator(true).build()?.compile_matcher())
}
/// A route as the UI gets it.
#[derive(Clone, Debug, PartialEq, Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginEndpoint {
/// `None` accepts any method.
pub method: Option<String>,
/// Path parameters in braces: `/photos/{id}`.
pub path: String,
pub framework: String,
/// Display name, e.g. `PhotosController#show`.
pub handler: Option<String>,
/// Where the route is declared.
pub location: ide_core::Location,
}
#[derive(Default)]
pub(super) struct Endpoints {
/// Bumped by every invalidation: a list computed across one is kept but not trusted.
epoch: u64,
cache: BTreeMap<(String, PathBuf), Cached>,
}
struct Cached {
list: Arc<Vec<PluginEndpoint>>,
fresh: bool,
}
impl PluginHost {
/// `plugin`'s routes in the project `root`: the cached list while no file
/// matching its globs changed, else computed again. Runs plugin code, so
/// call it on a blocking thread. A failed or invalid run keeps the last list.
pub fn endpoints(&self, plugin: &str, root: &Path) -> IdeResult<Arc<Vec<PluginEndpoint>>> {
let Some(workspace) = self.shared.workspace() else { return Ok(Arc::default()) };
let open = |root: &Path| workspace.projects().iter().any(|r| r == root) && workspace.trusted(root);
let loaded = self.get(plugin)?;
let key = (plugin.to_owned(), root.to_path_buf());
let epoch = {
let mut state = self.endpoints.lock();
state.cache.retain(|(_, r), _| open(r));
if !open(root) || !self.endpoint_eligible(&loaded, root) {
return Ok(Arc::default());
}
if let Some(cached) = state.cache.get(&key).filter(|c| c.fresh) {
return Ok(cached.list.clone());
}
state.epoch
};
let invocation = Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() };
let result = self
.code_call(plugin, invocation, "endpoints", |rt, ctx| rt.endpoints(ctx, Duration::from_secs(2)))
.and_then(|items| {
validate(root, items, &*workspace).map_err(|e| ide_core::IdeError::invalid(e.to_string()))
});
let mut state = self.endpoints.lock();
// Disabled, replaced or closed meanwhile: its routes are gone.
let current = self.plugins.read().get(plugin).is_some_and(|p| Arc::ptr_eq(p, &loaded));
if !current || !open(root) || !self.endpoint_eligible(&loaded, root) {
state.cache.remove(&key);
return Ok(Arc::default());
}
let fresh = state.epoch == epoch;
let list = match result {
Ok(list) => Arc::new(list),
Err(e) => {
tracing::warn!(plugin, "endpoints failed: {e}");
state.cache.get(&key).map(|c| c.list.clone()).unwrap_or_default()
}
};
state.cache.insert(key, Cached { list: list.clone(), fresh });
Ok(list)
}
pub(super) fn clear_plugin_endpoints(&self, plugin: &str) {
let mut state = self.endpoints.lock();
state.epoch += 1;
let roots: Vec<PathBuf> = state.cache.keys().filter(|(id, _)| id == plugin).map(|(_, r)| r.clone()).collect();
state.cache.retain(|(id, _), _| id != plugin);
drop(state);
for root in roots {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id: plugin.to_owned(), root: to_ipc(&root).into() });
}
}
/// Called by the project's file watcher; doesn't run plugin code.
pub fn invalidate_endpoints(&self, root: &Path, paths: &[PathBuf]) {
let rel: Vec<&Path> = paths.iter().filter_map(|p| p.strip_prefix(root).ok()).collect();
if rel.is_empty() {
return;
}
let declarations = self.ui.items(POINT, None);
let mut state = self.endpoints.lock();
state.epoch += 1;
let mut changed = Vec::new();
for ((plugin, project), cached) in &mut state.cache {
let matches = project == root
&& declarations.iter().filter(|d| &d.plugin_id == plugin).any(|d| {
d.decl["files"].as_array().is_some_and(|globs| {
globs
.iter()
.filter_map(|g| matcher(g.as_str()?).ok())
.any(|m| rel.iter().any(|p| m.is_match(p)))
})
});
if matches && cached.fresh {
cached.fresh = false;
changed.push(plugin.clone());
}
}
drop(state);
for plugin_id in changed {
self.emit(None, PluginEvent::EndpointsChanged { plugin_id, root: to_ipc(root).into() });
}
}
fn endpoint_eligible(&self, plugin: &super::LoadedPlugin, root: &Path) -> bool {
plugin.status().is_live()
&& plugin.dev_root.as_deref().is_none_or(|r| r == root)
&& !self.ui.items(POINT, Some(plugin.id())).is_empty()
&& {
let meta = plugin.meta.lock();
meta.pending.is_empty() && meta.granted.contains(&Capability::ProjectRead)
}
}
}
fn validate(
root: &Path,
items: Vec<Endpoint>,
workspace: &dyn super::PluginWorkspace,
) -> Result<Vec<PluginEndpoint>, HostError> {
if items.len() > 10_000 {
return Err(HostError::Invalid("at most 10,000 endpoints".into()));
}
let mut bytes = 0usize;
let mut document_bytes = 0usize;
let mut texts: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
let mut validated = Vec::with_capacity(items.len());
for e in items {
bytes += e.path.len()
+ e.method.as_ref().map_or(0, String::len)
+ e.framework.len()
+ e.handler.as_ref().map_or(0, String::len)
+ e.location.path.as_os_str().len();
if bytes > 4 << 20
|| e.path.len() > 8192
|| !e.path.starts_with('/')
|| e.path.chars().any(char::is_whitespace)
|| e.path.chars().any(char::is_control)
|| e.method.as_ref().is_some_and(|m| {
m.is_empty() || m.len() > 32 || !m.bytes().all(|b| b.is_ascii_uppercase() || b == b'-')
})
|| e.framework.is_empty()
|| e.framework.len() > 256
|| e.framework.chars().any(char::is_control)
|| e.handler.as_ref().is_some_and(|h| h.len() > 1024 || h.chars().any(char::is_control))
{
return Err(HostError::Invalid("invalid endpoint or batch larger than 4 MiB".into()));
}
let path = ide_plugin::resolve_in_root(root, &e.location.path.to_string_lossy()).map_err(HostError::Invalid)?;
if !texts.contains_key(&path) {
let text = workspace
.buffer_text(&path)
.map(Ok)
.unwrap_or_else(|| ide_plugin::read_text(&path).map_err(HostError::Invalid));
let text = text?;
document_bytes += text.len();
if document_bytes > 16 << 20 {
return Err(HostError::Invalid("endpoint declaration documents exceed 16 MiB".into()));
}
texts.insert(
path.clone(),
text.split('\n').map(|line| line.trim_end_matches('\r').encode_utf16().count()).collect(),
);
}
let lines = &texts[&path];
let range = e.location.range;
if range.start > range.end
|| [range.start, range.end]
.iter()
.any(|p| lines.get(p.line as usize).is_none_or(|len| p.col as usize > *len))
{
return Err(HostError::Invalid("endpoint location is outside its UTF-16 document range".into()));
}
validated.push(PluginEndpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: ide_core::Location { path: to_ipc(&path).into(), range, uri: None },
});
}
Ok(validated)
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::{Pos, Range};
struct Workspace;
impl super::super::PluginWorkspace for Workspace {
fn projects(&self) -> Vec<PathBuf> {
vec![]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
unreachable!()
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
unreachable!()
}
}
fn endpoint(path: PathBuf) -> Endpoint {
Endpoint {
method: Some("GET".into()),
path: "/users/{id}".into(),
framework: "Rails".into(),
handler: None,
location: ide_plugin::Location { path, range: Range::new(Pos::new(0, 2), Pos::new(0, 3)) },
}
}
#[test]
fn validates_entire_rooted_batch_with_utf16_positions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let file = root.join("routes.rb");
std::fs::write(&file, "😀x\n").unwrap();
let good = endpoint(file.clone());
let items = validate(&root, vec![good.clone()], &Workspace).unwrap();
assert_eq!(items[0].location.range.end.col, 3);
assert!(items[0].handler.is_none());
for bad in [
Endpoint { method: Some("GET\nPOST".into()), ..good.clone() },
Endpoint { path: "https://example.com".into(), ..good.clone() },
Endpoint { path: "/bad\r\nPOST /".into(), ..good.clone() },
Endpoint { handler: Some("bad\nhandler".into()), ..good.clone() },
Endpoint {
location: ide_plugin::Location {
path: file.clone(),
range: Range::new(Pos::new(0, 0), Pos::new(0, 4)),
},
..good.clone()
},
Endpoint {
location: ide_plugin::Location { path: root.join("../outside"), range: Range::default() },
..good.clone()
},
] {
assert!(validate(&root, vec![good.clone(), bad], &Workspace).is_err());
}
assert!(validate(&root, vec![good.clone(); 10_001], &Workspace).is_err());
#[cfg(unix)]
{
let outside = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
assert!(validate(&root, vec![endpoint(root.join("escape"))], &Workspace).is_err());
}
}
}
+11
Переглянути файл
@@ -140,6 +140,17 @@ pub async fn plugin_navigation_index(
result
}
/// The HTTP routes `plugin_id` reports for this window's project.
pub async fn plugin_endpoints(
host: &crate::Host,
plugins: Arc<PluginHost>,
window: &str,
plugin_id: String,
) -> IdeResult<Vec<crate::plugins::endpoints::PluginEndpoint>> {
let project = host.project(window)?;
blocking(move || Ok(plugins.endpoints(&plugin_id, &project.root)?.as_ref().clone())).await
}
pub async fn plugin_navigation_goto(
host: &crate::Host,
plugins: Arc<PluginHost>,
+7
Переглянути файл
@@ -17,6 +17,7 @@
mod api;
pub mod commit;
pub mod endpoints;
mod hover;
mod icon_extensions;
mod icon_patterns;
@@ -323,6 +324,7 @@ pub struct PluginHost {
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
endpoints: Mutex<endpoints::Endpoints>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -412,6 +414,7 @@ impl PluginHost {
run_detectors,
providers,
navigation: Mutex::default(),
endpoints: Mutex::default(),
index,
plugins: RwLock::default(),
state: Mutex::new(state),
@@ -2085,6 +2088,7 @@ impl PluginHost {
}
let Some(error) = actor.take_crash() else { return };
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
if error.starts_with(CANCELLED) {
// Cancelled on request: restart on next use, no backoff.
let mut meta = plugin.meta.lock();
@@ -2158,6 +2162,7 @@ impl PluginHost {
fn stop(&self, plugin: &LoadedPlugin) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
// A call waiting on the user would keep the join below waiting.
self.shared.block_prompts(plugin.id());
if let Some(actor) = plugin.runtime.lock().take() {
@@ -2170,6 +2175,7 @@ impl PluginHost {
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "{error}");
self.registry.dispose_plugin(plugin.id());
let mut meta = plugin.meta.lock();
@@ -2179,6 +2185,7 @@ impl PluginHost {
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
self.clear_plugin_navigation(plugin.id());
self.clear_plugin_endpoints(plugin.id());
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
if let Some(actor) = plugin.runtime.lock().take() {
actor.stop(false);
+1
Переглянути файл
@@ -59,6 +59,7 @@ pub fn register_builtin_points(
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)))?;
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
use super::ui_points as p;
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
+6
Переглянути файл
@@ -124,6 +124,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
| PluginEvent::CommitRequested { .. }
| PluginEvent::EndpointsChanged { .. }
)
}
@@ -567,6 +568,11 @@ impl RemotePlugins {
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
}
});
let h = host.clone();
d.add("plugin_endpoints", move |c| {
let h = h.clone();
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
});
d.add("plugin_line_markers", |c| async move {
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
});
+4
Переглянути файл
@@ -168,6 +168,10 @@ pub enum PluginEvent {
CommandsChanged,
/// Cached document associations were disposed (reload, disable or failure).
DocumentIndexesChanged,
/// The routes `plugin_id` reports for the project `root` may have changed:
/// ask `plugin_endpoints` again.
#[serde(rename_all = "camelCase")]
EndpointsChanged { plugin_id: String, root: ide_core::IpcPath },
/// The marketplace offers newer versions of installed plugins (announced
/// once per set of versions).
UpdatesAvailable { updates: Vec<PluginUpdate> },
+3
Переглянути файл
@@ -354,6 +354,9 @@ impl Host {
}
// Atomic saves by external tools surface as `created`.
documents.on_disk_changed(&[&changed[..], &ignored].concat());
if let Some(plugins) = crate::plugins::editor_plugin_host() {
plugins.invalidate_endpoints(&root, &[changed.clone(), batch.removed.clone()].concat());
}
// A framework's root marker (`angular.json`) created or deleted: its
// templates gain or lose their mode. Markers in `node_modules` only
// count here when the folder isn't excluded (it is by default).
+146
Переглянути файл
@@ -3353,6 +3353,152 @@ fn invalid_plugin_icons_refuse_the_package() {
}
}
#[test]
fn endpoint_exports_refresh_matching_files_and_follow_plugin_lifecycle() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
let routes = f.project.join("routes.txt");
std::fs::write(&routes, "/users/{id}\n").unwrap();
f.install(&sample("endpoints"));
let id = "test.endpoints";
let paths = || f.host.endpoints(id, &f.project).unwrap().iter().map(|e| e.path.clone()).collect::<Vec<_>>();
let first = f.host.endpoints(id, &f.project).unwrap();
assert_eq!(first[0].path, "/users/{id}");
assert!(first[0].method.is_none(), "any method is preserved");
assert_eq!(first[0].location.path, ide_core::IpcPath::from(to_ipc(&routes)));
std::thread::sleep(Duration::from_millis(70));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(paths(), ["/users/{id}"]);
assert_eq!(f.host.instances(), 0, "a cached list doesn't wake the plugin");
let changed = |f: &Fixture| {
f.take_events()
.iter()
.any(|(_, e)| matches!(e, PluginEvent::EndpointsChanged { plugin_id, .. } if plugin_id == id))
};
std::fs::write(&routes, "/updated\n").unwrap();
f.take_events();
f.host.invalidate_endpoints(&f.project, &[f.project.join("unrelated.txt")]);
assert!(!changed(&f));
assert_eq!(paths(), ["/users/{id}"]);
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert!(changed(&f), "windows are told to ask again");
assert_eq!(paths(), ["/updated"]);
std::fs::write(&routes, "not-a-path\n").unwrap();
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert_eq!(paths(), ["/updated"], "an invalid list doesn't replace the last valid one");
f.host.set_enabled(id, false).unwrap();
assert!(paths().is_empty());
std::fs::write(&routes, "/enabled\n").unwrap();
f.host.set_enabled(id, true).unwrap();
assert_eq!(paths(), ["/enabled"]);
f.take_events();
assert!(f.run(id, "crash", None).is_err());
assert!(changed(&f), "a crash withdraws the routes");
f.host.uninstall(id).unwrap();
assert!(f.host.endpoints(id, &f.project).is_err());
}
struct EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize,
inner: Arc<TestWorkspace>,
roots: Mutex<Vec<PathBuf>>,
entered: Mutex<Option<std::sync::mpsc::Sender<()>>>,
release: Mutex<std::sync::mpsc::Receiver<()>>,
}
impl PluginWorkspace for EndpointWorkspace {
fn projects(&self) -> Vec<PathBuf> {
self.roots.lock().clone()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0
&& let Some(entered) = self.entered.lock().take()
{
entered.send(()).unwrap();
self.release.lock().recv().unwrap();
}
self.inner.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.inner.document(path)
}
fn trusted(&self, root: &Path) -> bool {
self.roots.lock().iter().any(|r| r == root)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
self.roots.lock().first().cloned()
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits)
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
self.inner.publish_diagnostics(source, path, items)
}
}
#[test]
fn late_endpoint_results_cannot_restore_a_disabled_plugin_or_closed_project() {
for close_project in [false, true] {
let f = fixture(Limits::default());
std::fs::write(f.project.join("routes.txt"), "/late\n").unwrap();
f.install(&sample("endpoints"));
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone()]),
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
let late = std::thread::scope(|scope| {
// The plugin reads the file, then the host's check reads it again and waits here.
let query = scope.spawn(|| f.host.endpoints("test.endpoints", &f.project).unwrap());
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
if close_project {
workspace.roots.lock().clear();
} else {
f.host.set_enabled("test.endpoints", false).unwrap();
}
release_tx.send(()).unwrap();
query.join().unwrap()
});
assert!(late.is_empty());
assert!(f.host.endpoints("test.endpoints", &f.project).unwrap().is_empty());
}
}
#[test]
fn endpoint_results_are_isolated_per_root_and_withdraw_closed_roots() {
let f = fixture(Limits::default());
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
std::fs::write(f.project.join("routes.txt"), "/first\n").unwrap();
std::fs::write(other.join("routes.txt"), "/second\n").unwrap();
let (_, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone(), other.clone()]),
entered: Mutex::new(None),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
f.install(&sample("endpoints"));
for (root, route) in [(&f.project, "/first"), (&other, "/second")] {
let list = f.host.endpoints("test.endpoints", root).unwrap();
assert_eq!(list.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(), [route]);
}
workspace.roots.lock().retain(|r| r != &other);
assert!(f.host.endpoints("test.endpoints", &other).unwrap().is_empty(), "a closed project has no routes");
assert_eq!(f.host.endpoints("test.endpoints", &f.project).unwrap()[0].path, "/first");
}
#[test]
fn notification_actions_keep_commands_and_window_scope() {
let f = fixture(Limits::default());
+41 -2
Переглянути файл
@@ -16,8 +16,8 @@ use crate::types::{
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
};
use crate::wit_host::{
bindings, commit_checks_bindings, details_bindings, file_actions_bindings, hover_bindings, indexing_bindings,
inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
@@ -37,6 +37,7 @@ use bindings::openidea::plugin::types as wit_types;
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
use details_bindings::openidea::plugin::detail_types as wit_dt;
use endpoints_bindings::exports::openidea::plugin::endpoints_provider as wit_endpoints;
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
@@ -563,6 +564,7 @@ struct Exports {
hover: Option<wit_hover::Guest>,
commit_checks: Option<wit_cc::Guest>,
inlay: Option<wit_inlay::Guest>,
endpoints: Option<wit_endpoints::Guest>,
}
/// Most results one syntax call may return (more are dropped).
@@ -648,6 +650,7 @@ impl Runtime {
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
let syntax = match syntax_indices {
@@ -690,6 +693,10 @@ impl Runtime {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
let endpoints = match endpoint_indices {
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
None => None,
};
Ok(Self {
store,
exports: Exports {
@@ -703,6 +710,7 @@ impl Runtime {
hover,
commit_checks,
inlay,
endpoints,
file_actions,
},
limits: setup.limits,
@@ -1109,6 +1117,37 @@ impl Runtime {
}
}
/// Project routes, with an explicit project invocation and background budget.
pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result<Vec<crate::Endpoint>, CallError> {
if self.exports.endpoints.is_none() {
return Err(CallError::missing_export("endpoints-provider"));
}
let result = self.call(invocation, timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
let r = e
.endpoints
.as_ref()
.expect("checked")
.call_endpoints(&mut *store, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
r
})?;
let entries = result.map_err(|e| CallError::Guest(e.into()))?;
if entries.len() > MAX_PROVIDER_ITEMS {
return Err(CallError::Guest(GuestError::Invalid("too many endpoints".into())));
}
Ok(entries
.into_iter()
.map(|e| crate::Endpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: location_from_wit(e.location),
})
.collect())
}
/// The Services pane of node `key` of the plugin's services view `view`.
pub fn service_detail(
&mut self,
+10
Переглянути файл
@@ -646,6 +646,16 @@ pub enum ViewEvent {
Sort { column: u32, descending: bool },
}
/// A plugin's HTTP route declaration in a project.
#[derive(Clone, Debug)]
pub struct Endpoint {
pub method: Option<String>,
pub path: String,
pub framework: String,
pub handler: Option<String>,
pub location: Location,
}
/// A notification button that runs one of the publishing plugin's commands.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+12
Переглянути файл
@@ -1040,6 +1040,18 @@ pub(crate) mod inlay_bindings {
});
}
pub(crate) mod endpoints_bindings {
wasmtime::component::bindgen!({
world: "endpoints", path: "../../wit",
with: {
"openidea:plugin/types": super::bindings::openidea::plugin::types,
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
},
});
}
/// The optional `file-actions` export.
pub(crate) mod file_actions_bindings {
wasmtime::component::bindgen!({
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288
f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6
+333
Переглянути файл
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-endpoints"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
Переглянути файл
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-endpoints"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
panic = "abort"
Бінарний файл не відображається.
+1
Переглянути файл
@@ -0,0 +1 @@
344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b
+13
Переглянути файл
@@ -0,0 +1,13 @@
id = "test.endpoints"
name = "Endpoints fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
capabilities.required = ["project.read"]
[[contributes.endpoints]]
files = ["routes.txt"]
[[contributes.commands]]
id = "crash"
title = "Crash endpoint fixture"
+32
Переглянути файл
@@ -0,0 +1,32 @@
use openidea_plugin::endpoints::{Endpoint, EndpointsProvider};
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Range, register_plugin};
#[derive(Default)]
struct Fixture;
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
if id == "crash" {
panic!("endpoint fixture crash");
}
Err(Error::NotFound(id.into()))
}
}
impl EndpointsProvider for Fixture {
fn endpoints(&mut self, ctx: &Invocation) -> Result<Vec<Endpoint>, Error> {
let project = ctx.project().ok_or("no project invocation")?;
let text = project.read_file("routes.txt")?;
if text.is_empty() {
return Ok(vec![]);
}
Ok(vec![Endpoint {
method: None,
path: text.lines().next().unwrap().into(),
framework: "Fixture".into(),
handler: Some("Routes#show".into()),
location: Location {
path: format!("{}/routes.txt", project.root()?),
range: Range { start: Position::new(0, 0), end: Position::new(0, 0) },
},
}])
}
}
register_plugin!(Fixture, endpoints);
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e
47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027
c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1
58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed
56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89
ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a
+1 -1
Переглянути файл
@@ -1 +1 @@
951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548
89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e
284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d
243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949
4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979
+63
Переглянути файл
@@ -0,0 +1,63 @@
import { doubleShift, expect, test } from "./fixtures";
test("plugin routes feed search, HTTP completion and the declaration gutter and disappear on removal", async ({
workbench: page,
}) => {
await page.evaluate(async () => {
const c = "/src/workbench/contributions.ts";
const b = "/src/editor/core/backend.ts";
const { registerEndpointProvider, editorService } = await import(c);
const { docBackend } = await import(b);
docBackend().files.set("/dev/acme-api/routes.txt", "route declaration\n");
docBackend().files.set("/dev/acme-api/routes.http", "GET {{host}}/pe");
(window as unknown as { removeRoutes: () => void }).removeRoutes = registerEndpointProvider({
id: "plugin:routes:test",
endpoints: async (root: string) =>
root === "/dev/acme-api"
? [
{
method: "*",
url: "/people/{id}",
framework: "Rails",
handler: "People#show",
pluginId: "routes",
controller: "Rails",
methodName: "People#show",
path: "/dev/acme-api/routes.txt",
rel: "routes.txt",
line: 0,
col: 0,
},
]
: [],
});
await editorService().openFile("/dev/acme-api/routes.txt");
});
await expect(page.locator('.cm-openidea-gutter-endpoints button[title="Open in HTTP Client"]')).toBeVisible();
await doubleShift(page);
const search = page.getByRole("dialog", { name: "Search Everywhere" });
await search.getByRole("combobox").fill("/people");
await expect(search.getByRole("option", { name: /people/ })).toBeVisible();
await page.keyboard.press("Escape");
await page.evaluate(async () => {
const path = "/src/workbench/contributions.ts";
await (await import(path)).editorService().openFile("/dev/acme-api/routes.http");
});
const content = page.locator(".cm-content").last();
await content.click();
await page.keyboard.press("Meta+End");
await page.keyboard.press("Control+Space");
await expect(page.locator(".cm-tooltip-autocomplete").getByText("/people/{{id}}", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await doubleShift(page);
await search.getByRole("combobox").fill("/people");
await expect(search.getByRole("option", { name: /people/ })).toBeVisible();
await page.evaluate(() => (window as unknown as { removeRoutes: () => void }).removeRoutes());
await expect(search.getByRole("option", { name: /people/ })).toBeHidden();
await page.keyboard.press("Escape");
await page.evaluate(async () => {
const path = "/src/workbench/contributions.ts";
await (await import(path)).editorService().openFile("/dev/acme-api/routes.txt");
});
await expect(page.locator('.cm-openidea-gutter-endpoints button[title="Open in HTTP Client"]')).toBeHidden();
});
+33
Переглянути файл
@@ -1269,6 +1269,39 @@ The call acknowledges the request; cancellation or opening errors are handled by
the window. Neither API needs a capability. Paths belong to the UI machine,
including when plugin code runs on a remote host.
### Project HTTP endpoints (API 1.3)
A plugin with `project.read` can export `endpoints-provider` to share its routes
with Search Everywhere's Endpoints section, request URL completion in `.http`
files, and the declaration's **Open in HTTP Client** gutter action. The action
opens the generated-requests scratch; requests start at `http://localhost:8080`
and path parameters become HTTP client variables. Adjust the host for the app.
```toml
engines.openidea = "^1.3"
[capabilities]
required = ["project.read"]
[[contributes.endpoints]]
files = ["config/routes.rb", "routes/**/*.php"]
```
Implement `endpoints::EndpointsProvider` and use
`register_plugin!(MyPlugin, endpoints)` in the Rust SDK. `endpoints(ctx)` receives
an explicit project invocation. It runs the first time the IDE needs that
project's routes, and again after a file matching `files` is created, changed or
removed; in between the host answers from its cache. Return the complete list
(an empty list clears it). `method = None` accepts any method; the optional handler is a display name.
Each endpoint has a required declaration location inside that project, with
zero-based lines and UTF-16 columns. The generated request uses GET for any-method
routes. Calls run in the background and cannot open prompts.
The host validates a whole batch before replacing the last valid result (up to
10,000 endpoints, 4 MiB of route metadata, 16 MiB of declaration documents).
Disabling, uninstalling, reloading or crashing a plugin withdraws its routes;
idle instance reclamation keeps them cached. Closing a project withdraws its
cache. Windows only receive routes for their current project. This API does not
add an Endpoints tool window.
### Notifications with commands
`host::notify_with_actions(level, title, body, actions)` shows up to three
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136
10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1
9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41
2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5
c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c
65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c
d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210
d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb
a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19
6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1
15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28
11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb
+19
Переглянути файл
@@ -0,0 +1,19 @@
//! Project HTTP routes for Search Everywhere and the HTTP client.
use crate::{Error, Invocation, Location};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Endpoint {
/// `None` accepts any HTTP method.
pub method: Option<String>,
pub path: String,
pub framework: String,
pub handler: Option<String>,
/// Declaration range, with zero-based lines and UTF-16 columns.
pub location: Location,
}
/// Register with `register_plugin!(MyPlugin, endpoints)` and declare
/// `[[contributes.endpoints]] files = ["config/routes.rb"]` in the manifest.
pub trait EndpointsProvider {
fn endpoints(&mut self, ctx: &Invocation) -> Result<Vec<Endpoint>, Error>;
}
+58
Переглянути файл
@@ -41,6 +41,7 @@
pub mod commit;
pub mod details;
pub mod endpoints;
pub mod file_actions;
pub mod host;
pub mod hover;
@@ -209,6 +210,22 @@ pub mod file_actions_bindings {
});
}
#[cfg(target_arch = "wasm32")]
#[doc(hidden)]
pub mod endpoints_bindings {
wit_bindgen::generate!({
world: "endpoints", path: "wit", pub_export_macro: true,
export_macro_name: "__export_endpoints_world",
default_bindings_module: "openidea_plugin::endpoints_bindings",
with: {
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::types,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::workspace,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::editor,
"openidea:plugin/[email protected]": crate::bindings::openidea::plugin::invocation,
},
});
}
/// The plugin API version this SDK targets.
pub const API_VERSION: &str = "1.3";
@@ -269,6 +286,9 @@ macro_rules! __optional_exports {
(file_actions, $plugin:ty, $component:ident) => {
$crate::__file_actions_exports!($plugin, $component);
};
(endpoints, $plugin:ty, $component:ident) => {
$crate::__endpoints_exports!($plugin, $component);
};
(details, $plugin:ty, $component:ident) => {
$crate::__details_exports!($plugin, $component);
};
@@ -438,6 +458,22 @@ macro_rules! __indexing_exports {
};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __endpoints_exports {
($plugin:ty, $component:ident) => {
impl $crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Guest for $component {
fn endpoints(ctx: &$crate::bindings::openidea::plugin::invocation::Invocation) -> ::core::result::Result<
::std::vec::Vec<$crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Endpoint>,
$crate::bindings::openidea::plugin::types::Error,
> {
$crate::__rt::endpoints::<$plugin>(ctx)
}
}
$crate::endpoints_bindings::__export_endpoints_world!($component with_types_in $crate::endpoints_bindings);
};
}
/// `register_plugin!(T, details)`: `T` is also a [`details::Details`].
#[doc(hidden)]
#[macro_export]
@@ -993,6 +1029,28 @@ pub mod __rt {
with::<T, _>(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out)
}
pub fn endpoints<T: Plugin + crate::endpoints::EndpointsProvider + Default>(
ctx: &b::invocation::Invocation,
) -> Result<Vec<crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Endpoint>, b::types::Error>
{
use crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider as w;
let ctx = Invocation::__new(ctx);
with::<T, _>(|p| p.endpoints(&ctx))
.map(|items| {
items
.into_iter()
.map(|e| w::Endpoint {
method: e.method,
path: e.path,
framework: e.framework,
handler: e.handler,
location: loc(e.location),
})
.collect()
})
.map_err(error_out)
}
pub fn service_detail<T: Plugin + crate::details::Details + Default>(
view: String,
key: String,
+26
Переглянути файл
@@ -0,0 +1,26 @@
package openidea:[email protected];
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
use types.{error, location};
use invocation.{invocation};
/// One HTTP route.
record endpoint {
/// `none` accepts any HTTP method.
method: option<string>,
/// Path parameters use braces, e.g. `/photos/{id}`.
path: string,
/// Shown next to the route, e.g. `Rails`.
framework: string,
/// Display name, e.g. `PhotosController#show`.
handler: option<string>,
/// Where the route is declared, inside the project.
location: location,
}
/// The project's routes, complete. Called when the IDE first needs them and
/// again after a file matching `files` changes; cached in between. Runs in
/// the background: it can't ask the user.
endpoints: func(ctx: borrow<invocation>) -> result<list<endpoint>, error>;
}
+10
Переглянути файл
@@ -140,3 +140,13 @@ world commit-checks {
world file-actions-provider {
export file-actions;
}
/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`).
world plugin-endpoints {
include plugin;
export endpoints-provider;
}
world endpoints {
export endpoints-provider;
}
+1
Переглянути файл
@@ -505,6 +505,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_navigation_index,
plugin::plugin_navigation_goto,
plugin::plugin_navigation_markers,
plugin::plugin_endpoints,
plugin::plugin_market,
plugin::plugin_market_url,
plugin::plugin_set_market_url,
+10
Переглянути файл
@@ -787,6 +787,16 @@ pub async fn plugin_navigation_goto(
.await
}
#[tauri::command]
#[specta::specta]
pub async fn plugin_endpoints(
window: Window,
state: State<'_, AppState>,
plugin_id: String,
) -> IdeResult<Vec<ide_host::plugins::endpoints::PluginEndpoint>> {
ide_host::plugins::index_ops::plugin_endpoints(&state.host, state.plugins.clone(), window.label(), plugin_id).await
}
#[tauri::command]
#[specta::specta]
pub async fn plugin_navigation_markers(
+19
Переглянути файл
@@ -1139,6 +1139,7 @@ export const commands = {
targets: Location[],
} | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })),
pluginNavigationMarkers: (paths: string[]) => typedError<FileMarkers[], IdeError>(__TAURI_INVOKE("plugin_navigation_markers", { paths })),
pluginEndpoints: (pluginId: string) => typedError<PluginEndpoint[], IdeError>(__TAURI_INVOKE("plugin_endpoints", { pluginId })),
/** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */
pluginMarket: (refresh: boolean) => typedError<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })),
pluginMarketUrl: () => __TAURI_INVOKE<string | null>("plugin_market_url"),
@@ -5941,12 +5942,30 @@ export type PluginEditor = {
selections: Range[],
};
/** A route as the UI gets it. */
export type PluginEndpoint = {
/** `None` accepts any method. */
method: string | null,
/** Path parameters in braces: `/photos/{id}`. */
path: string,
framework: string,
/** Display name, e.g. `PhotosController#show`. */
handler: string | null,
/** Where the route is declared. */
location: Location,
};
/** Backend → UI notifications (Tauri event `ide://plugin`). */
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 routes `plugin_id` reports for the project `root` may have changed:
* ask `plugin_endpoints` again.
*/
{ type: "endpointsChanged"; pluginId: string; root: IpcPath } |
/**
* The marketplace offers newer versions of installed plugins (announced
* once per set of versions).
+2
Переглянути файл
@@ -118,6 +118,8 @@ interface MemDoc {
}
const EXT_LANG: Record<string, string> = {
http: "http",
rest: "http",
ts: "typescript",
mts: "typescript",
cts: "typescript",
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { CompletionContext } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { useProject } from "@/platform/project";
import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { completionTarget, endpointCompletion, renderEndpoint } from "./endpoints";
const route: EndpointEntry = {
method: "GET",
url: "/users/{id}",
controller: "",
methodName: "users.show",
handler: "users.show",
path: "/p/routes.txt",
rel: "",
line: 0,
col: 0,
};
describe("shared HTTP endpoints", () => {
it("completes only request URLs, preserving origins and variables", () => {
for (const url of ["/us", "http://localhost:8000/us", "https://example.com/us", "{{host}}/us"]) {
const text = `### Users\nGET ${url}`;
const result = completionTarget(text, text.length)!;
expect(text.slice(result.from)).toBe("/us");
expect(result.method).toBe("GET");
}
for (const text of [
"GET /users\nX-Test: /us",
"POST /users\n\nGET /us",
"# GET /us",
"@host = /us",
"GET /users HTTP/1.1",
]) {
expect(completionTarget(text, text.length)).toBeNull();
}
expect(completionTarget("GET /old\n### Next\nPOST /us", 28)?.method).toBe("POST");
});
it("renders optional handlers and any-method routes into HTTP blocks", () => {
expect(renderEndpoint(route)).toBe("### users.show\nGET http://localhost:8080/users/{{id}}\n");
expect(renderEndpoint({ ...route, method: "*", handler: undefined })).toBe(
"### * /users/{id}\nGET http://localhost:8080/users/{{id}}\n",
);
});
it("scopes providers and removes their completion entries on disposal", async () => {
const previous = useProject.getState();
useProject.setState({ project: { root: "/p" } as NonNullable<typeof previous.project> });
const dispose = registerEndpointProvider({
id: "test.routes",
endpoints: async (root) => (root === "/p" ? [route, { ...route, method: "POST" }] : []),
});
const broken = registerEndpointProvider({
id: "test.failed",
endpoints: () => {
throw new Error("provider unavailable");
},
});
try {
const state = EditorState.create({ doc: "GET {{host}}/us" });
const complete = () => endpointCompletion(new CompletionContext(state, state.doc.length, true));
expect((await complete())?.options.map((o) => o.label)).toEqual(["/users/{{id}}"]);
expect(await searchEndpoints("/other")).toEqual([]);
dispose();
expect((await complete())?.options).toEqual([]);
} finally {
dispose();
broken();
useProject.setState(previous);
}
});
});
+163
Переглянути файл
@@ -0,0 +1,163 @@
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { EditorState, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin } from "@codemirror/view";
import { gutter } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { gutterIconMarkup, showContextMenu } from "@/ui";
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { appendGeneratedRequests } from "./generatedRequests";
import { METHODS } from "./language";
import styles from "./HttpClient.module.css";
export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
export function renderEndpoint(e: EndpointEntry): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
}
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
await appendGeneratedRequests([renderEndpoint(endpoint)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
}
/** Only the URL of the first request line in a request block is completed. */
export function completionTarget(text: string, pos: number): { from: number; method: string; prefix: string } | null {
const before = text.slice(0, pos);
const start = before.lastIndexOf("\n") + 1;
const line = before.slice(start);
const m = new RegExp(`^\\s*(${METHODS.join("|")})\\s+(\\S*)$`).exec(line);
if (!m) return null;
const block =
before
.slice(0, start)
.split(/^\s*###.*$/m)
.at(-1) ?? "";
if (block.split("\n").some((l) => new RegExp(`^\\s*(?:${METHODS.join("|")})\\s+|^\\s*https?://`).test(l)))
return null;
const url = m[2]!;
const origin = /^(?:https?:\/\/[^/\s]+|\{\{[^}]+\}\})(?=\/|$)/.exec(url)?.[0] ?? "";
if (url && !origin && !url.startsWith("/")) return null;
return { from: pos - url.length + origin.length, method: m[1]!, prefix: url.slice(origin.length) };
}
export async function endpointCompletion(context: CompletionContext): Promise<CompletionResult | null> {
const target = completionTarget(context.state.doc.toString(), context.pos);
if (!target) return null;
const root = useProject.getState().project?.root ?? null;
const endpoints = await searchEndpoints(root);
if (context.aborted || root !== (useProject.getState().project?.root ?? null)) return null;
const seen = new Set<string>();
return {
from: target.from,
options: endpoints
.filter((e) => {
if ((e.method !== "*" && e.method !== target.method) || !e.url.startsWith(target.prefix) || seen.has(e.url))
return false;
seen.add(e.url);
return true;
})
.map((e) => ({ label: endpointUrl(e.url), detail: e.handler ?? e.framework, type: "url" })),
};
}
type Mark = { pos: number; endpoints: EndpointEntry[] };
const setMarks = StateEffect.define<Mark[]>();
const marks = StateField.define<Mark[]>({
create: () => [],
update: (value, tr) => {
for (const e of tr.effects) if (e.is(setMarks)) return e.value;
return tr.docChanged ? [] : value;
},
});
class EndpointMarker extends GutterMarker {
override toDOM(): Node {
const button = document.createElement("button");
button.type = "button";
button.className = styles.runMarker ?? "";
button.title = tx("Open in HTTP Client");
button.setAttribute("aria-label", button.title);
button.innerHTML = gutterIconMarkup("file-http") ?? "";
return button;
}
}
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.virtual) return [];
return [
marks,
ViewPlugin.fromClass(
class {
seq = 0;
destroyed = false;
unsubscribe: () => void;
projectUnsubscribe: () => void;
constructor(readonly view: EditorView) {
this.unsubscribe = onEndpointsChanged(() => this.refresh());
this.projectUnsubscribe = useProject.subscribe(() => this.refresh());
this.refresh();
}
update(u: { docChanged: boolean }) {
if (u.docChanged) this.refresh();
}
refresh() {
const seq = ++this.seq;
const root = useProject.getState().project?.root ?? null;
void searchEndpoints(root).then((entries) => {
if (this.destroyed || seq !== this.seq) return;
const byLine = new Map<number, EndpointEntry[]>();
for (const e of entries) {
if (!e.pluginId || e.path !== ctx.path || e.line >= this.view.state.doc.lines) continue;
const pos = this.view.state.doc.line(e.line + 1).from;
byLine.set(pos, [...(byLine.get(pos) ?? []), e]);
}
this.view.dispatch({ effects: setMarks.of([...byLine].map(([pos, endpoints]) => ({ pos, endpoints }))) });
});
}
destroy() {
this.destroyed = true;
this.unsubscribe();
this.projectUnsubscribe();
}
},
),
gutter({
class: `${styles.runGutter} cm-openidea-gutter-endpoints`,
markers: (view) =>
RangeSet.of(
view.state.field(marks).map((m) => new EndpointMarker().range(m.pos)),
true,
),
domEventHandlers: {
mousedown: (view, line, event) => {
if ((event as MouseEvent).button !== 0) return false;
const found = view.state.field(marks).find((m) => m.pos === line.from);
if (!found) return false;
event.preventDefault();
const rect = (event.target as HTMLElement).getBoundingClientRect();
showContextMenu(
{ x: rect.left, y: rect.bottom },
found.endpoints.map((e) => ({
label: `${e.method} ${e.url}`,
verbatim: true,
icon: "file-http",
run: () => {
void executeAction(OPEN_ENDPOINT, e);
},
})),
);
return true;
},
},
}),
];
}
+60
Переглянути файл
@@ -0,0 +1,60 @@
import { EditorView } from "@codemirror/view";
import { activeEditor, editorsFor, useEditorRegistry, type TextEditorHandle } from "@/editor/api";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { editorService } from "@/workbench/contributions";
import { appendRequests } from "./spring/request";
export const GENERATED_REQUESTS = "generated-requests.http";
async function scratchFile(): Promise<{ path: string; created: boolean }> {
const { appDataDir } = await import("@tauri-apps/api/path");
// `appDataDir()` is native (`C:\…`): the editor is found by its `/` path.
const dir = `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`;
const ignoreExisting = (e: unknown) => {
if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e;
};
await call(commands.fsCreateDir(dir)).catch(ignoreExisting);
const path = `${dir}/${GENERATED_REQUESTS}`;
let created = true;
await call(commands.fsCreateFile(path)).catch((e) => {
ignoreExisting(e);
created = false;
});
return { path, created };
}
/** The text editor of `path`, once the just opened tab has created it. */
function editorFor(path: string, timeoutMs = 5000): Promise<TextEditorHandle | null> {
const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null;
const now = find();
if (now) return Promise.resolve(now);
return new Promise((resolve) => {
const done = (e: TextEditorHandle | null) => {
clearTimeout(timer);
unsubscribe();
resolve(e);
};
const timer = setTimeout(() => done(null), timeoutMs);
const unsubscribe = useEditorRegistry.subscribe(() => {
const e = find();
if (e) done(e);
});
});
}
export async function appendGeneratedRequests(blocks: string[]): Promise<void> {
const file = await scratchFile();
if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] });
await editorService().openFile(file.path, { focus: true });
const e = await editorFor(file.path);
if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`);
const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks);
e.view.dispatch({
changes: { from, insert },
selection: { anchor: caret },
effects: EditorView.scrollIntoView(caret, { y: "center" }),
userEvent: "input",
});
}
+12
Переглянути файл
@@ -28,6 +28,8 @@ import {
menuGroupItems,
springEditorExtension,
} from "./spring/editor";
import { endpointEditorExtension, OPEN_ENDPOINT, openEndpoint } from "./endpoints";
import type { EndpointEntry } from "@/workbench/contributions";
import { registerSpringEndpointProvider } from "./spring/routeIndex";
const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin";
@@ -119,6 +121,7 @@ export function activate(): () => void {
}),
registerLanguageMode(httpLanguageDescription),
registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }),
registerEditorContribution({ id: "http.endpoints", order: 58, extensions: endpointEditorExtension }),
registerEditorContribution({ id: "http.spring", order: 57, extensions: springEditorExtension }),
registerMenuGroup({ id: "http.spring", menus: ["editor"], order: 40, items: (_menu, ctx) => menuGroupItems(ctx) }),
registerServiceView({
@@ -138,6 +141,15 @@ export function activate(): () => void {
directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [],
}),
registerActions([
{
id: OPEN_ENDPOINT,
title: "Open in HTTP Client",
category: "Tools",
icon: "file-http",
run: async ({ arg }) => {
if (arg) await openEndpoint(arg as EndpointEntry);
},
},
{
id: "http.runRequest",
title: "Run HTTP Request at Caret",
+6 -63
Переглянути файл
@@ -5,29 +5,19 @@
*/
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import {
activeEditor,
editorsFor,
intentionProviders,
useEditorRegistry,
type ContributedIntention,
type EditorDocContext,
type TextEditorHandle,
} from "@/editor/api";
import { tx } from "@/platform/i18n";
import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc";
import { ipcPath } from "@/platform/paths";
import { activeEditor, intentionProviders, type ContributedIntention, type EditorDocContext } from "@/editor/api";
import { appendGeneratedRequests } from "../generatedRequests";
import { call, commands } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { isVirtualUri } from "@/platform/virtualDocs";
import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui";
import { editorService, notifications, type MenuGroupContext } from "@/workbench/contributions";
import { notifications, type MenuGroupContext } from "@/workbench/contributions";
import { springAt, springControllers, type Controller, type Endpoint } from "./endpoints";
import { appendRequests, bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request";
import { bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request";
import { parseSource } from "./source";
import styles from "../HttpClient.module.css";
export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATED_REQUESTS = "generated-requests.http";
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
@@ -47,42 +37,6 @@ export interface Target {
path?: string;
}
async function scratchFile(): Promise<{ path: string; created: boolean }> {
const { appDataDir } = await import("@tauri-apps/api/path");
// `appDataDir()` is native (`C:\…`): the editor is found by its `/` path.
const dir = `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`;
const ignoreExisting = (e: unknown) => {
if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e;
};
await call(commands.fsCreateDir(dir)).catch(ignoreExisting);
const path = `${dir}/${GENERATED_REQUESTS}`;
let created = true;
await call(commands.fsCreateFile(path)).catch((e) => {
ignoreExisting(e);
created = false;
});
return { path, created };
}
/** The text editor of `path`, once the just opened tab has created it. */
function editorFor(path: string, timeoutMs = 5000): Promise<TextEditorHandle | null> {
const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null;
const now = find();
if (now) return Promise.resolve(now);
return new Promise((resolve) => {
const done = (e: TextEditorHandle | null) => {
clearTimeout(timer);
unsubscribe();
resolve(e);
};
const timer = setTimeout(() => done(null), timeoutMs);
const unsubscribe = useEditorRegistry.subscribe(() => {
const e = find();
if (e) done(e);
});
});
}
/** Renders `targets` of the source at `sourcePath`, appends them to the scratch and opens it at the first one. */
export async function generateRequests(sourcePath: string, text: string, targets: Target[]): Promise<void> {
if (!targets.length) return;
@@ -104,18 +58,7 @@ export async function generateRequests(sourcePath: string, text: string, targets
body: endpoint.bodyType ? skeleton(endpoint.bodyType, lookup) : undefined,
}),
);
const file = await scratchFile();
if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] });
await editorService().openFile(file.path, { focus: true });
const e = await editorFor(file.path);
if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`);
const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks);
e.view.dispatch({
changes: { from, insert },
selection: { anchor: caret },
effects: EditorView.scrollIntoView(caret, { y: "center" }),
userEvent: "input",
});
await appendGeneratedRequests(blocks);
} catch (err) {
notifications().notify({
level: "error",
+40 -1
Переглянути файл
@@ -10,7 +10,12 @@ import { useProject } from "@/platform/project";
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { InputDialogsHost } from "@/workbench/inputDialogs";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import {
onEndpointsChanged,
provideNotificationService,
searchEndpoints,
type NotificationService,
} from "@/workbench/contributions";
import { applyThemeAttributes, previewTheme, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
@@ -1292,3 +1297,37 @@ describe("plugin context keys", () => {
await waitFor(() => expect(evaluateWhen("plugin.p.f")).toBe(true));
});
});
it("asks the plugin for its routes once per plugin and again when they change", async () => {
const plugins = [plugin({ id: "routes" })];
const endpoints = vi.fn(async () => [
{
method: null,
path: "/users/{id}",
framework: "Rails",
handler: "Users#show",
location: { path: "/p/config/routes.rb", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 8 } } },
},
]);
setPluginsBackend({ ...createMemoryPluginsBackend(), endpoints });
const decl = (key: string) => ({ pluginId: "routes", point: "endpoints", key, decl: { files: [key] } });
syncUiContributions([decl("config/routes.rb"), decl("routes/*.rb")], plugins);
expect(await searchEndpoints("/p")).toMatchObject([
{ method: "*", url: "/users/{id}", handler: "Users#show", rel: "config/routes.rb", line: 2, col: 4 },
]);
expect(endpoints).toHaveBeenCalledWith("routes");
syncUiContributions([decl("config/routes.rb")], plugins);
expect(await searchEndpoints("/p")).toHaveLength(1);
const changed = vi.fn();
const stop = onEndpointsChanged(changed);
useProject.setState({ project: { root: "/p" } as never });
handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/other" });
expect(changed).not.toHaveBeenCalled();
handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/p" });
expect(changed).toHaveBeenCalledTimes(1);
stop();
syncUiContributions([], plugins);
expect(await searchEndpoints("/p")).toEqual([]);
});
+6 -1
Переглянути файл
@@ -20,11 +20,12 @@ import type {
PluginCommitCheckResult,
PluginContextKey,
PluginEditor,
PluginEndpoint,
PluginEvent,
PluginInfo,
PluginOutputLine,
PluginsSnapshot,
PluginSecretState,
PluginsSnapshot,
PluginStatusItem,
ServiceTab,
UiContribution,
@@ -101,6 +102,8 @@ export interface PluginsBackend {
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** The Services pane of node `key` of a plugin's services view. */
serviceDetail(pluginId: string, view: string, key: string): Promise<ServiceTab[]>;
/** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */
endpoints(pluginId: string): Promise<PluginEndpoint[]>;
/**
* Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its
* lines until it exits (`onExit`) or the returned stop function is called.
@@ -235,6 +238,7 @@ export const tauriPluginsBackend: PluginsBackend = {
await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null));
},
serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)),
endpoints: (pluginId) => call(commands.pluginEndpoints(pluginId)),
serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => {
const request = nextRequestId();
const channel = new Channel<PluginOutputLine>();
@@ -588,6 +592,7 @@ export function createMemoryPluginsBackend(
}),
viewEvent: async () => {},
serviceDetail: async () => [],
endpoints: async () => [],
answerCommit: async () => {},
commitChecks: async () => [],
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
+4 -1
Переглянути файл
@@ -9,7 +9,7 @@
import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
import { useEditorRegistry } from "@/editor/api";
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
import { notifications } from "@/workbench/contributions";
import { endpointsChanged, notifications } from "@/workbench/contributions";
import { useI18n } from "@/platform/i18n";
import { useProducerNames } from "@/platform/inspections";
import { languageListChanged } from "@/platform/languageList";
@@ -280,6 +280,9 @@ export function handlePluginEvent(e: PluginEvent) {
case "commandsChanged":
scheduleRefresh();
break;
case "endpointsChanged":
if (e.root === useProject.getState().project?.root) endpointsChanged();
break;
case "updatesAvailable":
announceUpdates(e.updates);
break;
+38
Переглянути файл
@@ -3,6 +3,7 @@
* declaration is projected onto the workbench, and how its items appear on
* the plugin details page. One `defineUiPoint` per contribution type.
*/
import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions";
import type { PluginInfo, ThemeDef } from "@/bindings";
import { registerAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
@@ -14,6 +15,7 @@ import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } fro
import { defineUiPoint, type UiItem } from "./projection";
import { SecretsDetails, type SecretDecl } from "./secrets";
import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints";
import { pluginsBackend } from "./backend";
import { isLive, runPluginCommand } from "./store";
import styles from "./PluginsPage.module.css";
@@ -141,9 +143,45 @@ function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiIte
);
}
const endpointPlugins = new Map<string, { declarations: number; dispose: () => void }>();
/** One endpoint provider per plugin, however many `[[contributes.endpoints]]` it declares. */
function providePluginEndpoints(pluginId: string): () => void {
let entry = endpointPlugins.get(pluginId);
if (!entry) {
const dispose = registerEndpointProvider({
id: `plugin:${pluginId}`,
endpoints: async (root): Promise<EndpointEntry[]> =>
(await pluginsBackend().endpoints(pluginId)).map((e) => ({
method: e.method ?? "*",
url: e.path,
framework: e.framework,
handler: e.handler ?? undefined,
controller: e.framework,
methodName: e.handler ?? "",
pluginId,
path: e.location.path,
rel: e.location.path.startsWith(`${root}/`) ? e.location.path.slice(root.length + 1) : e.location.path,
line: e.location.range.start.line,
col: e.location.range.start.col,
})),
});
entry = { declarations: 0, dispose };
endpointPlugins.set(pluginId, entry);
}
const held = entry;
held.declarations++;
return () => {
if (--held.declarations > 0) return;
held.dispose();
endpointPlugins.delete(pluginId);
};
}
/** Registers the client side of every first-party UI point. */
export function defineBuiltinUiPoints(): () => void {
const disposers = [
defineUiPoint("endpoints", { project: ({ pluginId }) => providePluginEndpoints(pluginId) }),
defineUiPoint<CommandDecl>("commands", {
title: "Commands",
project: ({ pluginId, decl }, { plugin }) =>
+7
Переглянути файл
@@ -26,6 +26,13 @@ const routes: EndpointEntry[] = [
];
describe("endpoints in Search Everywhere", () => {
it("finds any-method plugin routes and displays an optional handler or framework", () => {
const entry = route({ method: "*", controller: "Rails", methodName: "", framework: "Rails", handler: undefined });
expect(matchRoutes([entry], "GET /deeplink/menu/search", 5).items[0]?.container).toBe("Rails");
expect(matchRoutes([{ ...entry, handler: "Menus#search" }], "Menus#search", 5).items[0]?.container).toBe(
"Menus#search",
);
});
it("finds a controller method by the request path it answers", () => {
for (const query of [
"/deeplink/menu/search",
+11 -1
Переглянути файл
@@ -1,3 +1,4 @@
import { onEndpointsChanged } from "@/workbench/contributions";
import { useFilePresentation } from "@/platform/filePresentation";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import clsx from "clsx";
@@ -140,6 +141,15 @@ function SearchEverywhereDialog() {
const bumpToggles = () => setToggleTick((t) => t + 1);
useEffect(() => () => controller.dispose(), [controller]);
const [endpointVersion, setEndpointVersion] = useState(0);
useEffect(
() =>
onEndpointsChanged(() => {
controller.invalidateEndpoints();
setEndpointVersion((v) => v + 1);
}),
[controller],
);
useEffect(() => {
if (query === debounced) return;
@@ -182,7 +192,7 @@ function SearchEverywhereDialog() {
includeDisabled: tab === "actions" ? includeNonProject : undefined,
extra,
});
}, [controller, searchQuery, tab, includeNonProject, fileSet, filter, extra]);
}, [controller, searchQuery, tab, includeNonProject, fileSet, filter, extra, endpointVersion]);
useEffect(() => {
inputRef.current?.select();
+4
Переглянути файл
@@ -111,6 +111,10 @@ export class SeController {
private readonly onChange: (s: SeSnapshot) => void,
) {}
invalidateEndpoints() {
this.signatures.delete("endpoints");
}
run(req: SeRequest) {
const gen = ++this.gen;
this.disposed = false;
+3 -3
Переглянути файл
@@ -15,7 +15,7 @@ function item(r: EndpointEntry, indices: number[], score: number): SymbolItem {
name: display,
display,
symbolKind: "method",
container: `${r.controller}.${r.methodName}`,
container: r.handler ?? [r.controller, r.methodName].filter(Boolean).join("."),
path: r.path,
rel: r.rel,
line: r.line,
@@ -47,9 +47,9 @@ export function matchRoutes(
const hits: { item: SymbolItem; score: number }[] = [];
for (const r of routes) {
if (method && r.method !== method) continue;
if (method && r.method !== "*" && r.method !== method) continue;
const target = strip(r.url).toLowerCase();
const qualified = `${r.controller}.${r.methodName}`.toLowerCase();
const qualified = (r.handler ?? [r.controller, r.methodName].filter(Boolean).join(".")).toLowerCase();
let score = 0;
let indices: number[] = [];
if (wantPath) {
+3 -1
Переглянути файл
@@ -184,5 +184,7 @@
"JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.",
"Remove Module Overrides": "Remove Module Overrides",
"Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}",
"Cannot choose a directory": "Cannot choose a directory"
"Cannot choose a directory": "Cannot choose a directory",
"Open in HTTP Client": "Open in HTTP Client",
"Cannot generate the HTTP request": "Cannot generate the HTTP request"
}
+2 -1
Переглянути файл
@@ -6563,5 +6563,6 @@
"JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。",
"Remove Module Overrides": "移除模块覆盖设置",
"Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}",
"Cannot choose a directory": "无法选择目录"
"Cannot choose a directory": "无法选择目录",
"Open in HTTP Client": "在 HTTP 客户端中打开"
}
+21 -2
Переглянути файл
@@ -1474,6 +1474,9 @@ export function runAnythingProviderList(): RunAnythingProvider[] {
export interface EndpointEntry {
/** `GET`, `POST`, … */
method: string;
framework?: string;
handler?: string;
pluginId?: string;
/** Class-level and method-level mapping joined; `{var}` kept. */
url: string;
controller: string;
@@ -1490,17 +1493,33 @@ export interface EndpointProvider {
}
const endpointProviders = new Map<string, EndpointProvider>();
const endpointListeners = new Set<() => void>();
export function onEndpointsChanged(listener: () => void): () => void {
endpointListeners.add(listener);
return () => {
endpointListeners.delete(listener);
};
}
/** Endpoints a provider reports may have changed: `onEndpointsChanged` listeners ask again. */
export function endpointsChanged() {
for (const listener of endpointListeners) listener();
}
export function registerEndpointProvider(p: EndpointProvider): () => void {
endpointProviders.set(p.id, p);
endpointsChanged();
return () => {
if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id);
if (endpointProviders.get(p.id) === p) {
endpointProviders.delete(p.id);
endpointsChanged();
}
};
}
export async function searchEndpoints(root: string | null): Promise<EndpointEntry[]> {
if (!root) return [];
return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat();
const results = await Promise.allSettled([...endpointProviders.values()].map(async (p) => p.endpoints(root)));
return results.flatMap((result) => (result.status === "fulfilled" ? result.value : []));
}
// ---------------------------------------------------------------------------
+26
Переглянути файл
@@ -0,0 +1,26 @@
package openidea:[email protected];
/// HTTP endpoints found in a project (`[[contributes.endpoints]]`).
interface endpoints-provider {
use types.{error, location};
use invocation.{invocation};
/// One HTTP route.
record endpoint {
/// `none` accepts any HTTP method.
method: option<string>,
/// Path parameters use braces, e.g. `/photos/{id}`.
path: string,
/// Shown next to the route, e.g. `Rails`.
framework: string,
/// Display name, e.g. `PhotosController#show`.
handler: option<string>,
/// Where the route is declared, inside the project.
location: location,
}
/// The project's routes, complete. Called when the IDE first needs them and
/// again after a file matching `files` changes; cached in between. Runs in
/// the background: it can't ask the user.
endpoints: func(ctx: borrow<invocation>) -> result<list<endpoint>, error>;
}
+10
Переглянути файл
@@ -140,3 +140,13 @@ world commit-checks {
world file-actions-provider {
export file-actions;
}
/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`).
world plugin-endpoints {
include plugin;
export endpoints-provider;
}
world endpoints {
export endpoints-provider;
}