mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms
# Conflicts: # src/platform/locales/text/en.json # src/platform/locales/text/zh.json
This commit is contained in:
commit
cf1397b425
87 files changed
+1779
-118
No files matched your search
@@ -308,6 +308,13 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
/// The directory is canonicalized but the file name kept as found: `cargo`,
|
||||
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
|
||||
/// mise) that picks the tool by the name it was invoked under — resolving the
|
||||
/// link itself would start the shim with the tool's arguments. The grant
|
||||
/// still pins the target's content: a hash read through a link reads the file
|
||||
/// it points to, so relinking or replacing either still fails the
|
||||
/// approved-file check.
|
||||
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
|
||||
let candidate = Path::new(name);
|
||||
if candidate.is_absolute() {
|
||||
@@ -325,15 +332,21 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
for ext in &exts {
|
||||
let p = dir.join(format!("{name}{ext}"));
|
||||
if is_executable(&p) {
|
||||
return Ok(dunce_canonical(&p));
|
||||
return Ok(canonical_dir(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
/// `p` with its parent canonicalized but the file name as found: PATH
|
||||
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
|
||||
/// their real directory, while a linked program (`cargo` → rustup) keeps
|
||||
/// its link name so the shim still dispatches on argv[0].
|
||||
fn canonical_dir(p: &Path) -> PathBuf {
|
||||
let Some(dir) = p.parent() else { return p.to_path_buf() };
|
||||
let Some(name) = p.file_name() else { return p.to_path_buf() };
|
||||
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
@@ -474,6 +487,42 @@ mod tests {
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
/// A program found as a link stays the link: multicall shims (rustup,
|
||||
/// asdf, mise) pick the tool by the name they were invoked under, so
|
||||
/// `Command::new` must see the link. The hash still covers the target —
|
||||
/// reading through the link reads it — so replacing either still trips
|
||||
/// the approved-file check.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_programs_resolve_to_the_link() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
let real_dir = tmp.path().join("real");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
std::fs::create_dir(&real_dir).unwrap();
|
||||
let target = real_dir.join("shim");
|
||||
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
|
||||
|
||||
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
|
||||
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
|
||||
// Directory canonicalized, file name kept — still the link.
|
||||
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
|
||||
assert_eq!(resolved, want);
|
||||
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
|
||||
let mut l = Ledger::in_memory();
|
||||
let r = req("1.0.0", "h", &["process:cargo"], &[]);
|
||||
let cap: Capability = "process:cargo".parse().unwrap();
|
||||
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
|
||||
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
|
||||
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
|
||||
std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap();
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_and_grouping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))?;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
@@ -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"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e
|
||||
47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027
|
||||
c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1
|
||||
58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed
|
||||
56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89
|
||||
ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a
|
||||
@@ -1 +1 @@
|
||||
951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548
|
||||
89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e
|
||||
284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d
|
||||
243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949
|
||||
4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136
|
||||
10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1
|
||||
9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41
|
||||
2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5
|
||||
c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c
|
||||
65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c
|
||||
d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210
|
||||
d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb
|
||||
a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19
|
||||
6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1
|
||||
15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28
|
||||
11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -507,6 +507,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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1143,6 +1143,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"),
|
||||
@@ -5950,12 +5951,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).
|
||||
|
||||
@@ -118,6 +118,8 @@ interface MemDoc {
|
||||
}
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
http: "http",
|
||||
rest: "http",
|
||||
ts: "typescript",
|
||||
mts: "typescript",
|
||||
cts: "typescript",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"Install 'openidea' Command": "Install 'openidea' Command",
|
||||
"Uninstall 'openidea' Command": "Uninstall 'openidea' Command",
|
||||
"The openidea command is installed": "The openidea command is installed",
|
||||
"The openidea command is uninstalled": "The openidea command is uninstalled",
|
||||
"The openidea command is not installed": "The openidea command is not installed",
|
||||
"Cannot install the openidea command": "Cannot install the openidea command",
|
||||
"Cannot uninstall the openidea command": "Cannot uninstall the openidea command",
|
||||
"Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.",
|
||||
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.",
|
||||
"OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings",
|
||||
"Import JetBrains Settings": "Import JetBrains Settings",
|
||||
"These settings will be imported:": "These settings will be imported:",
|
||||
@@ -184,14 +193,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}",
|
||||
"Install 'openidea' Command": "Install 'openidea' Command",
|
||||
"Uninstall 'openidea' Command": "Uninstall 'openidea' Command",
|
||||
"The openidea command is installed": "The openidea command is installed",
|
||||
"The openidea command is uninstalled": "The openidea command is uninstalled",
|
||||
"The openidea command is not installed": "The openidea command is not installed",
|
||||
"Cannot install the openidea command": "Cannot install the openidea command",
|
||||
"Cannot uninstall the openidea command": "Cannot uninstall the openidea command",
|
||||
"Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.",
|
||||
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.",
|
||||
"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"
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"Install 'openidea' Command": "安装 'openidea' 命令",
|
||||
"Uninstall 'openidea' Command": "卸载 'openidea' 命令",
|
||||
"The openidea command is installed": "openidea 命令已安装",
|
||||
"The openidea command is uninstalled": "openidea 命令已卸载",
|
||||
"The openidea command is not installed": "openidea 命令未安装",
|
||||
"Cannot install the openidea command": "无法安装 openidea 命令",
|
||||
"Cannot uninstall the openidea command": "无法卸载 openidea 命令",
|
||||
"Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。",
|
||||
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。",
|
||||
"Cannot copy reference": "无法复制引用",
|
||||
"Group actions": "分组操作",
|
||||
"New Project Group": "新建项目组",
|
||||
@@ -6563,14 +6572,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}",
|
||||
"Install 'openidea' Command": "安装 'openidea' 命令",
|
||||
"Uninstall 'openidea' Command": "卸载 'openidea' 命令",
|
||||
"The openidea command is installed": "openidea 命令已安装",
|
||||
"The openidea command is uninstalled": "openidea 命令已卸载",
|
||||
"The openidea command is not installed": "openidea 命令未安装",
|
||||
"Cannot install the openidea command": "无法安装 openidea 命令",
|
||||
"Cannot uninstall the openidea command": "无法卸载 openidea 命令",
|
||||
"Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。",
|
||||
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。",
|
||||
"Cannot choose a directory": "无法选择目录"
|
||||
"Cannot choose a directory": "无法选择目录",
|
||||
"Open in HTTP Client": "在 HTTP 客户端中打开"
|
||||
}
|
||||
@@ -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 : []));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user