diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 298547f89..a80657afb 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -15,6 +15,8 @@ use std::sync::Arc; pub enum IdeEvent { /// Project window membership or selection changed. WindowGroupsChanged, + /// A window entered or left full screen, itself or as a member of a full-screen window group. + WindowFullscreenChanged, ProjectTrustChanged { root: crate::IpcPath, trusted: bool, diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 8cf3a44d1..737db0e2f 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide let settings = settings.clone(); config = host .blocking(move || { - ide_sdk::go::debug_environment(&root, &settings, &mut config.env); + ide_sdk::go::configure_environment(&root, &settings, &mut config.env); ide_sdk::go::apply_to_debug( &mut config.env, &mut config.extra_json, diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 967095879..1e57b5576 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -1,7 +1,11 @@ //! Repository version lookups for manifest inspections and completion. use crate::{Host, rpc::Dispatcher}; use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId}; -use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo}; +use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go}; +use std::collections::BTreeMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::process::Command; struct CratesIo; impl DependencyVersionSource for CratesIo { @@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo { } } +struct GoModules { + root: PathBuf, + env: BTreeMap, +} + +impl DependencyVersionSource for GoModules { + fn id(&self) -> &'static str { + "go-modules" + } + + fn cache_key(&self, package: &str) -> Option { + let mut hash = DefaultHasher::new(); + (&self.root, &self.env, package).hash(&mut hash); + Some(format!("{}-{:016x}", self.id(), hash.finish())) + } + + fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult> { + let search = + self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path); + let go = ide_core::tool_path::find_command("go", &search, &self.root) + .ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?; + // A partially edited go.mod is often invalid. Query outside it while + // keeping the SDK and proxy/private module configuration. + let dir = tempfile::tempdir()?; + let mut cmd = Command::new(go); + cmd.current_dir(dir.path()) + .env("PATH", search) + .envs(&self.env) + .env("GO111MODULE", "on") + .env("GOFLAGS", "") + .env("GOWORK", "off") + .env("GIT_TERMINAL_PROMPT", "0") + .args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]); + let query = cancel.child_token(); + // Fetches run on Host::blocking's Tokio pool, which keeps a runtime current. + let deadline = tokio::spawn({ + let query = query.clone(); + async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + query.cancel(); + } + }); + let output = ide_tools::run_streamed(cmd, &query, &|_| {}); + deadline.abort(); + Ok(go::listed_versions(&output?)) + } +} + +#[cfg(all(test, unix))] +#[path = "dependencies/go_tests.rs"] +mod go_tests; + +fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult { + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { + workspace + .projects + .into_iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()) + }); + let settings = match &member { + Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(), + None => project.settings.effective(), + }; + let root = member.unwrap_or_else(|| project.root.clone()); + let mut env = BTreeMap::new(); + ide_sdk::go::configure_environment(&root, &settings, &mut env); + Ok(GoModules { root, env }) +} + /// `offset` limits a completion request to its version string; inspections omit it. pub async fn dependency_versions( host: &Host, @@ -31,11 +105,24 @@ pub async fn dependency_versions( ) -> IdeResult> { let project = host.project(window)?; let cache = host.dependency_versions.clone(); - let cancel = host.begin_request(request); + let config_dir = host.config_dir.clone(); + let cancel = host.begin_project_read_request(window, request); let result = host .blocking(move || { let snap = project.documents.snapshot(doc)?; - if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") { + if snap.large || snap.read_only { + return Ok(Vec::new()); + } + if snap.path.file_name().is_some_and(|name| name == "go.mod") { + let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else { + return Ok(Vec::new()); + }; + project.require_trust()?; + let source = go_source(&project, &config_dir, snap.path.as_ref())?; + let versions = cache.versions(&source, &dependency.package, &cancel)?; + return Ok(vec![DependencyVersions { dependency, versions, update: None }]); + } + if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") { return Ok(Vec::new()); } let mut out = Vec::new(); diff --git a/crates/ide-host/src/ops/dependencies/go_tests.rs b/crates/ide-host/src/ops/dependencies/go_tests.rs new file mode 100644 index 000000000..cdec57e0e --- /dev/null +++ b/crates/ide-host/src/ops/dependencies/go_tests.rs @@ -0,0 +1,107 @@ +use super::*; +use ide_workspace::dependencies::VersionCache; +use std::os::unix::fs::PermissionsExt; +use std::time::{Duration, Instant}; + +fn source(dir: &Path, script: &str) -> GoModules { + let bin = dir.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let go = bin.join("go"); + std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap(); + GoModules { + root: dir.to_owned(), + env: BTreeMap::from([ + ("PATH".into(), bin.to_string_lossy().into_owned()), + ("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()), + ("GOPROXY".into(), "https://user:secret@proxy.example".into()), + ("GOPRIVATE".into(), "private.example/token".into()), + ("GOWORK".into(), "broken.work".into()), + ("GOFLAGS".into(), "-mod=vendor".into()), + ]), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() { + let dir = tempfile::tempdir().unwrap(); + let source = source( + dir.path(), + r#"set -eu +[ "$PWD" != "$PROJECT_ROOT" ] +[ "$GOPROXY" = 'https://user:secret@proxy.example' ] +[ "$GOPRIVATE" = 'private.example/token' ] +[ "$GOWORK" = off ] +[ "$GOFLAGS" = '' ] +[ "$GO111MODULE" = on ] +[ "$GIT_TERMINAL_PROMPT" = 0 ] +[ "$1 $2 $3 $4" = 'list -m -versions -f' ] +[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ] +[ "$6 $7" = '-- example.com/Org/Lib/v2' ] +printf 'v2.9.0\nv2.10.0\n'"#, + ); + let key = source.cache_key("example.com/Org/Lib/v2").unwrap(); + assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap()); + let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() }; + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + other.root = source.root.clone(); + other.env.insert("GOPROXY".into(), "https://other.example".into()); + assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap()); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert_eq!(versions, ["v2.10.0", "v2.9.0"]); + let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect(); + assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter"); + assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json")); + let cached = std::fs::read_to_string(entries[0].path()).unwrap(); + for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] { + assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_go_queries_are_silent_and_not_persisted() { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1"); + let cache_dir = dir.path().join("cache"); + let disk = cache_dir.clone(); + let versions = tokio::task::spawn_blocking(move || { + VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new()) + }) + .await + .unwrap() + .unwrap(); + assert!(versions.is_empty()); + assert!(!disk.exists()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() { + for cancel_parent in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let source = source(dir.path(), "exec /bin/sleep 60"); + let cancel = CancellationToken::new(); + let query = cancel.clone(); + let started = Instant::now(); + let work = tokio::task::spawn_blocking(move || { + VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query) + }); + if cancel_parent { + tokio::time::sleep(Duration::from_millis(150)).await; + cancel.cancel(); + assert!(work.await.unwrap().is_err()); + assert!(started.elapsed() < Duration::from_secs(3)); + } else { + assert!(work.await.unwrap().unwrap().is_empty()); + assert!(started.elapsed() >= Duration::from_secs(10)); + assert!(started.elapsed() < Duration::from_secs(15)); + assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller"); + } + } +} diff --git a/crates/ide-host/src/plugins/endpoints.rs b/crates/ide-host/src/plugins/endpoints.rs new file mode 100644 index 000000000..204768328 --- /dev/null +++ b/crates/ide-host/src/plugins/endpoints.rs @@ -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, +} + +pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result { + 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 { + 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, + /// Path parameters in braces: `/photos/{id}`. + pub path: String, + pub framework: String, + /// Display name, e.g. `PhotosController#show`. + pub handler: Option, + /// 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>, + 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>> { + 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 = 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, + workspace: &dyn super::PluginWorkspace, +) -> Result, 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> = 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 { + vec![] + } + fn buffer_text(&self, _: &Path) -> Option { + None + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + None + } + fn trusted(&self, _: &Path) -> bool { + true + } + fn project_of(&self, _: &str) -> Option { + None + } + fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result { + unreachable!() + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> 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()); + } + } +} diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index 9047a9ff9..a67127980 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -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, + window: &str, + plugin_id: String, +) -> IdeResult> { + 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, diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 8f47579ad..0aeb50452 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -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, navigation: Mutex, + endpoints: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -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); diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index ba8b94f3f..3fcb09b84 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -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)))?; diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index ca463134f..31d1462ef 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -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 }); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index da5885f05..639babe9e 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -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 }, diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 8329111cb..92e1485e2 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -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). diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 1bb51692f..af9bf0d69 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -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::>(); + 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, + roots: Mutex>, + entered: Mutex>>, + release: Mutex>, +} +impl PluginWorkspace for EndpointWorkspace { + fn projects(&self) -> Vec { + self.roots.lock().clone() + } + fn buffer_text(&self, path: &Path) -> Option { + 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 { + self.roots.lock().first().cloned() + } + fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result { + self.inner.apply_edits(path, version, edits) + } + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> 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::>(), [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()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index b3e26c02c..e0bdf562b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -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, commit_checks: Option, inlay: Option, + endpoints: Option, } /// 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, 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, diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 168753aca..357f459aa 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -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, + pub path: String, + pub framework: String, + pub handler: Option, + 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")] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 9ab31d453..568e3b03c 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -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!({ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 1e0a4b5d6..610b9b9e7 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index f86b4c3bb..65cc7254d 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288 +f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock new file mode 100644 index 000000000..fb5e979aa --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml new file mode 100644 index 000000000..441dff6b6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm new file mode 100644 index 000000000..a85348d92 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources new file mode 100644 index 000000000..2363d3bf4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -0,0 +1 @@ +344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b diff --git a/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml b/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml new file mode 100644 index 000000000..2d25637f8 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs b/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs new file mode 100644 index 000000000..eea4238ae --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs @@ -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, Error> { + if id == "crash" { + panic!("endpoint fixture crash"); + } + Err(Error::NotFound(id.into())) + } +} +impl EndpointsProvider for Fixture { + fn endpoints(&mut self, ctx: &Invocation) -> Result, 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); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 015e757dc..37482b7f6 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 034cd7b3b..3f4dc2f39 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e +47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index f5c370a19..04486b8dd 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 31e2cce52..40bf2f7cf 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027 +c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 596b975d1..626ee3d8b 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index d85f352cd..0418a5dbd 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1 +58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 32a057954..578f845e3 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 6d32b97fc..a001ad8cc 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed +56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 0b8deed22..bf157c042 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index b94a6b5ce..ef2677b58 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89 +ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 14438862a..96e1c60f6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548 +89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 006add95d..10292aee9 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index c722a242a..0b3aeaf03 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e +284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index b732a322d..ce1898c67 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index f4afbd269..0f00f1fc0 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d +243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 8e4fba2ca..265487df1 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index fb9532efb..d554de5bb 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949 +4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979 diff --git a/crates/ide-sdk/src/go.rs b/crates/ide-sdk/src/go.rs index 3a30f772c..d6a815d3e 100644 --- a/crates/ide-sdk/src/go.rs +++ b/crates/ide-sdk/src/go.rs @@ -228,7 +228,7 @@ mod command_tests { } } -pub fn debug_environment( +pub fn configure_environment( root: &std::path::Path, settings: &ide_core::settings::Settings, env: &mut std::collections::BTreeMap, diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index c53426a54..26d7fe3c9 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -558,13 +558,15 @@ pub fn available_shells() -> Vec { /// argument-less) POSIX shell is started as a login shell, like Terminal.app, /// the Git Bash shortcut and IDEA: login start-up is what loads the completions /// and prompts users expect (`/etc/profile`, Git for Windows' git-completion). +/// `OPENIDEA_SKIP_SHELL_ENV` disables the implicit login argument; explicit +/// arguments still take precedence, and interactive startup files still run. pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell); if !args.is_empty() { return (shell, args.to_vec()); } - let login = - matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh"); + let login = std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_none() + && matches!(shell_title(&shell).as_str(), "zsh" | "bash" | "sh" | "fish" | "ksh" | "dash" | "tcsh" | "csh"); let args = if login { vec!["-l".to_owned()] } else { Vec::new() }; (shell, args) } diff --git a/crates/ide-workspace/src/dependencies/cache.rs b/crates/ide-workspace/src/dependencies/cache.rs index 7be33fe7e..fb448e230 100644 --- a/crates/ide-workspace/src/dependencies/cache.rs +++ b/crates/ide-workspace/src/dependencies/cache.rs @@ -39,11 +39,11 @@ impl VersionCache { package: &str, cancel: &CancellationToken, ) -> IdeResult> { - let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); - if !safe(source.id()) || !safe(package) || package.len() > 64 { + let Some(key) = source.cache_key(package).filter(|key| { + !key.is_empty() && key.len() <= 128 && key.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)) + }) else { return Ok(Vec::new()); - } - let key = format!("{}-{}", source.id(), package.to_ascii_lowercase()); + }; let slot = self.entries.lock().entry(key.clone()).or_default().clone(); let mut cached = loop { if cancel.is_cancelled() { diff --git a/crates/ide-workspace/src/dependencies/go.rs b/crates/ide-workspace/src/dependencies/go.rs new file mode 100644 index 000000000..c93e797a6 --- /dev/null +++ b/crates/ide-workspace/src/dependencies/go.rs @@ -0,0 +1,118 @@ +use super::ManifestDependency; + +fn module_path(path: &str) -> bool { + let Some(domain) = path.split('/').next() else { return false }; + path.len() <= 512 + && domain.contains('.') + && !domain.starts_with(['.', '-']) + && path.split('/').all(|part| { + !part.is_empty() + && !part.ends_with('.') + && part.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)) + }) +} + +/// Finds the version at a UTF-16 offset, including incomplete require lines. +pub fn dependency_at(text: &str, offset: u32) -> Option { + let mut block = None; + let mut start = 0; + for line in text.split_inclusive('\n') { + let line_len = line.encode_utf16().count() as u32; + let code = line.split("//").next()?.trim_end_matches(['\r', '\n']); + let trimmed = code.trim(); + if offset < start + line_len || offset == start + line_len && !line.ends_with('\n') { + let body = match block { + Some("require") => code.trim_start(), + None => code.trim_start().strip_prefix("require")?.strip_prefix([' ', '\t'])?.trim_start(), + _ => return None, + }; + let (path, rest) = body.split_once([' ', '\t'])?; + let package = path.trim_matches('"'); + if !module_path(package) { + return None; + } + let version = rest.trim_start(); + let token = version.split_ascii_whitespace().next().unwrap_or_default(); + let from = start + code[..code.len() - version.len()].encode_utf16().count() as u32; + let (requirement, from) = match token.strip_prefix('"') { + Some(quoted) => (quoted.strip_suffix('"').unwrap_or(quoted), from + 1), + None => (token, from), + }; + let to = from + requirement.encode_utf16().count() as u32; + return (from <= offset && offset <= to).then(|| ManifestDependency { + package: package.to_owned(), + requirement: requirement.to_owned(), + from, + to, + }); + } + if trimmed == ")" { + block = None; + } else if block.is_none() { + block = trimmed.strip_suffix('(').map(str::trim); + } + start += line_len; + } + None +} + +/// Go lists versions in ascending semantic order. Ignore tool output unrelated to versions. +pub fn listed_versions(output: &str) -> Vec { + output + .lines() + .rev() + .map(str::trim) + .filter(|line| line.strip_prefix('v').is_some_and(|version| semver::Version::parse(version).is_ok())) + .map(str::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completes_blank_partial_and_quoted_require_versions() { + for (text, package, version) in [ + ("module example.com/m\nrequire golang.org/x/text |", "golang.org/x/text", ""), + ( + "// 中文 😀\r\nrequire (\r\n\tgithub.com/Org/Lib/v2 v2.|1.0 // indirect\r\n)\r\n", + "github.com/Org/Lib/v2", + "v2.1.0", + ), + ("require \"rsc.io/quote\" \"v1.|2.0\"", "rsc.io/quote", "v1.2.0"), + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + let text = text.replace('|', ""); + let item = dependency_at(&text, offset).unwrap(); + assert_eq!(item.package, package); + assert_eq!(item.requirement, version); + let units: Vec<_> = text.encode_utf16().collect(); + assert_eq!(String::from_utf16(&units[item.from as usize..item.to as usize]).unwrap(), version); + } + } + + #[test] + fn ignores_comments_other_directives_and_non_module_arguments() { + for text in [ + "// require rsc.io/quote v|", + "require rsc.io/quote v1.2.0 // in|direct", + "require rsc.io/qu|ote v1.2.0", + "replace (\n\trsc.io/quote v|\n)", + "require ../quote v|", + "require -flag.example v|", + "require rsc.io/quote@latest v|", + ] { + let offset = text.split_once('|').unwrap().0.encode_utf16().count() as u32; + assert!(dependency_at(&text.replace('|', ""), offset).is_none(), "{text}"); + } + } + + #[test] + fn keeps_go_version_spelling_and_prefers_newer_versions() { + assert_eq!( + listed_versions("go: notice\nv1.9.0\nv1.10.0-rc.1\nv2.0.0+incompatible\n"), + ["v2.0.0+incompatible", "v1.10.0-rc.1", "v1.9.0"] + ); + } +} diff --git a/crates/ide-workspace/src/dependencies/mod.rs b/crates/ide-workspace/src/dependencies/mod.rs index eef3a69a3..9b802782a 100644 --- a/crates/ide-workspace/src/dependencies/mod.rs +++ b/crates/ide-workspace/src/dependencies/mod.rs @@ -1,6 +1,7 @@ //! Manifest dependencies and repository versions, independent of editor and transport. mod cache; pub mod cargo; +pub mod go; pub use cache::VersionCache; use ide_core::{CancellationToken, IdeResult}; @@ -11,6 +12,12 @@ use specta::Type; pub trait DependencyVersionSource: Send + Sync { /// A filesystem-safe, stable repository identifier. fn id(&self) -> &'static str; + /// Override for case-sensitive module paths or project-specific repositories. + fn cache_key(&self, package: &str) -> Option { + let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b)); + let id = self.id(); + (safe(id) && safe(package) && package.len() <= 64).then(|| format!("{id}-{}", package.to_ascii_lowercase())) + } /// Non-yanked versions in descending semantic order. fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult>; } diff --git a/e2e/pluginEndpoints.spec.ts b/e2e/pluginEndpoints.spec.ts new file mode 100644 index 000000000..a9a222780 --- /dev/null +++ b/e2e/pluginEndpoints.spec.ts @@ -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(); +}); diff --git a/plugins/README.md b/plugins/README.md index 1097322da..61d491c93 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index edb14100e..41b13eb14 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index bdb860332..45abe7179 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136 +10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index dced97eff..113710bcc 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index f7806c87f..ede72b964 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1 +9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index e2211c9ed..7b5db1cf8 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 235c0be73..f934d73f2 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41 +2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 52d7b9fe4..0e75c8192 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 9c15b9a23..024e3ba88 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5 +c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 4ed139ef8..29c352cd3 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 94cb748f0..fab3d5b66 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c +65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index dfbdd68c6..94d6c2566 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 7e650af2a..cb3f47b28 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c +d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 266959871..239765768 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 2bb0b757d..2f706ae66 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210 +d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 66fc3eebd..d2663a440 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 504f78743..3e5f9a1d5 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb +a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index beb27e62f..3c9407408 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 59d9dc3bd..f6e51b94e 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19 +6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 1c1896f2b..4aba760b8 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 13c6569f9..fe62a5e73 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1 +15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 15934b232..d4921ebbe 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index a389f5fa7..ff114fb2c 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28 +11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb diff --git a/sdk/rust/openidea-plugin/src/endpoints.rs b/sdk/rust/openidea-plugin/src/endpoints.rs new file mode 100644 index 000000000..777dd5c54 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/endpoints.rs @@ -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, + pub path: String, + pub framework: String, + pub handler: Option, + /// 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, Error>; +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 853d85ad0..03c8e24a6 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -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/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": 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::(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) } + pub fn endpoints( + ctx: &b::invocation::Invocation, + ) -> Result, b::types::Error> + { + use crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider as w; + let ctx = Invocation::__new(ctx); + with::(|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( view: String, key: String, diff --git a/sdk/rust/openidea-plugin/wit/endpoints.wit b/sdk/rust/openidea-plugin/wit/endpoints.wit new file mode 100644 index 000000000..95302fcb0 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/endpoints.wit @@ -0,0 +1,26 @@ +package openidea:plugin@1.3.0; + +/// 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, + /// 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, + /// 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) -> result, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index ad07bbacc..311c4cef6 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -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; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9b9173e6f..335e9f52d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -431,6 +431,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_toggle_fullscreen, window::window_flash_taskbar, window::window_set_titlebar_height, + window::window_is_fullscreen, window_drag::window_start_drag, window::window_take_launch, window::window_close_context, @@ -505,6 +506,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 472d55869..2d69c0edd 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -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> { + 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( diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index fb1534d85..a93262ec3 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -193,6 +193,20 @@ pub fn window_open_projects( .collect()) } +/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window +/// group's full-screen Space. +#[tauri::command] +#[specta::specta] +pub fn window_is_fullscreen(window: WebviewWindow) -> bool { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + crate::window_groups::in_fullscreen(window.app_handle(), &window) + } + #[cfg(not(target_os = "macos"))] + window.is_fullscreen().unwrap_or(false) +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct CloseContext { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7b6a63add..752fe29a9 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -135,6 +135,7 @@ pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWin if let Some(frame) = frame { native.setFrame_display(frame, true); } + target.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }) } diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index bf32bbac3..eabe726ec 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -12,11 +12,12 @@ use objc2::{ runtime::ProtocolObject, }; use objc2_app_kit::{ - NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle, - NSWindowWillEnterFullScreenNotification, + NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification, + NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification, }; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; +use tauri::Manager; /// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. const COMPACT_MAX_HEIGHT: f64 = 45.0; @@ -55,7 +56,7 @@ impl Drop for Observer { struct Tracked { state: Rc, - _observers: [Observer; 2], + _observers: [Observer; 3], } thread_local! { @@ -71,7 +72,7 @@ fn apply(window: &NSWindow, state: &Fullscreen) { } } -fn track(window: &NSWindow) -> Tracked { +fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked { let state = Rc::new(Fullscreen::default()); if window.styleMask().contains(NSWindowStyleMask::FullScreen) { state.will_enter(); @@ -79,11 +80,14 @@ fn track(window: &NSWindow) -> Tracked { let observe = |name, change: fn(&Fullscreen)| { let weak = Weak::new(window); let state = state.clone(); + let app = app.clone(); let block = block2::RcBlock::new(move |_| { change(&state); if let Some(window) = weak.load() { apply(&window, &state); } + // The web title bar shows the window buttons itself in full screen. + app.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }); let observer = unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( @@ -98,6 +102,7 @@ fn track(window: &NSWindow) -> Tracked { let observers = unsafe { [ observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter), + observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter), observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit), ] }; @@ -106,12 +111,13 @@ fn track(window: &NSWindow) -> Tracked { pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { let label = window.label().to_owned(); + let app = window.app_handle().clone(); // with_webview runs on the AppKit thread. window.with_webview(move |webview| unsafe { let Some(mtm) = MainThreadMarker::new() else { return }; let native = &*webview.ns_window().cast::(); - let state = - WINDOWS.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native)).state.clone()); + let state = WINDOWS + .with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone()); if native.toolbar().is_none() { let toolbar = NSToolbar::new(mtm); native.setToolbar(Some(&toolbar)); diff --git a/src/bindings.ts b/src/bindings.ts index f15f5c5fb..a763377ba 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -903,6 +903,11 @@ export const commands = { windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), + /** + * Whether this window fills the screen: in native full screen, or (macOS) shown in its window + * group's full-screen Space. + */ + windowIsFullscreen: () => __TAURI_INVOKE("window_is_fullscreen"), /** * Starts dragging `window` while the primary mouse button is still down; returns whether a drag began. * @@ -1139,6 +1144,7 @@ export const commands = { targets: Location[], } | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), pluginNavigationMarkers: (paths: string[]) => typedError(__TAURI_INVOKE("plugin_navigation_markers", { paths })), + pluginEndpoints: (pluginId: string) => typedError(__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(__TAURI_INVOKE("plugin_market", { refresh })), pluginMarketUrl: () => __TAURI_INVOKE("plugin_market_url"), @@ -4478,7 +4484,9 @@ export type IdeError = { export type IdeEvent = /** Project window membership or selection changed. */ -{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | +{ type: "windowGroupsChanged" } | +/** A window entered or left full screen, itself or as a member of a full-screen window group. */ +{ type: "windowFullscreenChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | /** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */ @@ -5941,12 +5949,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). diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 4407f1b25..a1b294ef8 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -118,6 +118,8 @@ interface MemDoc { } const EXT_LANG: Record = { + http: "http", + rest: "http", ts: "typescript", mts: "typescript", cts: "typescript", diff --git a/src/features/dependencies/dependencies.test.ts b/src/features/dependencies/dependencies.test.ts index e3322c592..53cae7e8d 100644 --- a/src/features/dependencies/dependencies.test.ts +++ b/src/features/dependencies/dependencies.test.ts @@ -46,14 +46,14 @@ afterEach(() => { vi.clearAllMocks(); vi.useRealTimers(); }); -function editor(path = "/p/Cargo.toml", readOnly = false) { +function editor(path = "/p/Cargo.toml", readOnly = false, source = text) { const ctx = { doc: 7, path, language: "toml", uiMode: "TOML", readOnly }; const contribution = editorContributions().find((c) => c.id === "dependency-versions")!; const view = new EditorView({ parent: document.body, state: EditorState.create({ - doc: text, - selection: { anchor: from }, + doc: source, + selection: { anchor: Math.min(from, source.length) }, extensions: [lspDoc.of(ctx), contribution.extensions(ctx)], }), }); @@ -208,3 +208,37 @@ describe("Cargo dependency versions", () => { expect(result.options[0]).toMatchObject({ label: "2.0.0-beta.1", apply: "^2.0.0-beta.1" }); }); }); + +describe("Go module dependency versions", () => { + it("requests versions at the caret and replaces the entire version without running inspections", async () => { + const source = "module example.com/app\n\nrequire (\n\tgolang.org/x/text v0.1.0 // indirect\n)\n"; + const from = source.indexOf("v0.1.0"); + const to = from + "v0.1.0".length; + const view = editor("/p/go.mod", false, source); + await settle(); + expect(dependencyVersions).not.toHaveBeenCalled(); + dependencyVersions.mockResolvedValue({ + status: "ok", + data: [ + { + dependency: { package: "golang.org/x/text", requirement: "v0.1.0", from, to }, + versions: ["v1.2.0", "v0.12.0", "v0.1.0"], + update: null, + }, + ], + }); + const complete = view.state.languageDataAt<(ctx: CompletionContext) => Promise>( + "openideaCompletion", + from, + )[0]!; + expect(await complete(new CompletionContext(view.state, from + 3, false, view))).toMatchObject({ + from, + to, + options: [ + { label: "v0.12.0", apply: "v0.12.0" }, + { label: "v0.1.0", apply: "v0.1.0" }, + ], + }); + expect(dependencyVersions).toHaveBeenCalledExactlyOnceWith(7, from + 3, expect.any(Number)); + }); +}); diff --git a/src/features/dependencies/index.ts b/src/features/dependencies/index.ts index 3a47f969f..c90df0743 100644 --- a/src/features/dependencies/index.ts +++ b/src/features/dependencies/index.ts @@ -168,12 +168,25 @@ function extension(doc: EditorDocContext): Extension { }, }; }); + return [ + versionsField, + plugin, + intentionProviders.of((view) => { + const selection = view.state.selection.main; + return dependencyFixes(view, selection.from, selection.to); + }), + quickFixProviders.of(dependencyFixes), + versionCompletion(doc), + ]; +} + +function versionCompletion(doc: EditorDocContext): Extension { const completion = async (ctx: CompletionContext): Promise => { if (!isTauri()) return null; const id = nextRequestId(); const text = ctx.state.doc; const root = useProject.getState().project?.root; - ctx.addEventListener("abort", () => void commands.cancelRequest(id)); + ctx.addEventListener("abort", () => void commands.cancelRequest(id), { onDocChange: true }); try { // The completion context is immutable; only use it while its document is current. const view = ctx.view; @@ -188,21 +201,17 @@ function extension(doc: EditorDocContext): Extension { return null; } }; - return [ - versionsField, - plugin, - intentionProviders.of((view) => { - const selection = view.state.selection.main; - return dependencyFixes(view, selection.from, selection.to); - }), - quickFixProviders.of(dependencyFixes), - EditorState.languageData.of(() => [{ openideaCompletion: completion }]), - ]; + return EditorState.languageData.of(() => [{ openideaCompletion: completion }]); } export function activate(): () => void { return registerEditorContribution({ id: "dependency-versions", - extensions: (doc) => (!doc.virtual && !doc.readOnly && /(?:^|\/)Cargo\.toml$/.test(doc.path) ? extension(doc) : []), + extensions: (doc) => { + if (doc.virtual || doc.readOnly) return []; + if (/(?:^|\/)Cargo\.toml$/.test(doc.path)) return extension(doc); + if (/(?:^|\/)go\.mod$/.test(doc.path)) return versionCompletion(doc); + return []; + }, }); } diff --git a/src/features/http-client/endpoints.test.ts b/src/features/http-client/endpoints.test.ts new file mode 100644 index 000000000..6d96ca826 --- /dev/null +++ b/src/features/http-client/endpoints.test.ts @@ -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 }); + 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); + } + }); +}); diff --git a/src/features/http-client/endpoints.ts b/src/features/http-client/endpoints.ts new file mode 100644 index 000000000..161481966 --- /dev/null +++ b/src/features/http-client/endpoints.ts @@ -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 { + 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 { + 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(); + 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(); +const marks = StateField.define({ + 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(); + 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; + }, + }, + }), + ]; +} diff --git a/src/features/http-client/generatedRequests.ts b/src/features/http-client/generatedRequests.ts new file mode 100644 index 000000000..67998f9f1 --- /dev/null +++ b/src/features/http-client/generatedRequests.ts @@ -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 { + 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 { + 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", + }); +} diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index a745a95a8..90f6a4581 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -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", diff --git a/src/features/http-client/spring/editor.ts b/src/features/http-client/spring/editor.ts index a594af1bd..b807de4db 100644 --- a/src/features/http-client/spring/editor.ts +++ b/src/features/http-client/spring/editor.ts @@ -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 { - 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 { 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", diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 5407f6832..9e21553ab 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -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([]); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index e753e145b..154b3d588 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -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; /** The Services pane of node `key` of a plugin's services view. */ serviceDetail(pluginId: string, view: string, key: string): Promise; + /** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */ + endpoints(pluginId: string): Promise; /** * 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(); @@ -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) => { diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 2b8690aa1..0a81aeb9a 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -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; diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index aff27805a..3e3f5cc2f 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -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 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 => + (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("commands", { title: "Commands", project: ({ pluginId, decl }, { plugin }) => diff --git a/src/features/search/__tests__/routes.test.ts b/src/features/search/__tests__/routes.test.ts index efd0d1ecf..ee1766b53 100644 --- a/src/features/search/__tests__/routes.test.ts +++ b/src/features/search/__tests__/routes.test.ts @@ -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", diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index c629fb680..c98cb7ec2 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -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(); diff --git a/src/features/search/everywhere/controller.ts b/src/features/search/everywhere/controller.ts index b63c8979f..9959660d4 100644 --- a/src/features/search/everywhere/controller.ts +++ b/src/features/search/everywhere/controller.ts @@ -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; diff --git a/src/features/search/everywhere/routes.ts b/src/features/search/everywhere/routes.ts index f6c68a0fa..bf4ab8237 100644 --- a/src/features/search/everywhere/routes.ts +++ b/src/features/search/everywhere/routes.ts @@ -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) { diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 493b0cf13..0dce59ac9 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -196,6 +196,7 @@ "window.caption.minimize": "Minimize", "window.caption.maximize": "Maximize", "window.caption.restore": "Restore Down", + "window.caption.exitFullScreen": "Exit Full Screen", "window.caption.close": "Close", "menu.help.keymapReference": "Keymap Reference", "menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index cbdee3b12..fa5e2c547 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -187,5 +187,7 @@ "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.", "Remove Module Overrides": "Remove Module Overrides", "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}", - "Cannot choose a directory": "Cannot choose a directory" + "Cannot choose a directory": "Cannot choose a directory", + "Open in HTTP Client": "Open in HTTP Client", + "Cannot generate the HTTP request": "Cannot generate the HTTP request" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ab7a712c5..f086722b0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6566,5 +6566,6 @@ "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。", "Remove Module Overrides": "移除模块覆盖设置", "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}", - "Cannot choose a directory": "无法选择目录" + "Cannot choose a directory": "无法选择目录", + "Open in HTTP Client": "在 HTTP 客户端中打开" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 9db8c6437..d93abbf75 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -196,6 +196,7 @@ "window.caption.minimize": "最小化", "window.caption.maximize": "最大化", "window.caption.restore": "向下还原", + "window.caption.exitFullScreen": "退出全屏", "window.caption.close": "关闭", "menu.help.keymapReference": "快捷键参考", "menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 1476e486b..ea1a96da6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -436,6 +436,16 @@ html { --fg-caption-close-hover: #ffffff; } +/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */ +:root { + --mac-window-close: #ff5f57; + --mac-window-minimize: #febc2e; + --mac-window-zoom: #28c840; + --mac-window-glyph: rgb(0 0 0 / 55%); + --mac-window-edge: rgb(0 0 0 / 12%); + --mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent); +} + /* * Islands Light. Every colour token of :root is redefined here. Values are * PyCharm's own; its secondary greys, comment / metadata colours, line numbers diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 8368918a5..f857b06df 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -248,6 +248,9 @@ const ICONS = { "window-maximize": "openidea/general/windowMaximize", "window-restore": "openidea/general/windowRestore", "window-close": "openidea/general/windowClose", + "mac-window-close": "openidea/general/macWindowClose", + "mac-window-minimize": "openidea/general/macWindowMinimize", + "mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen", hide: "general/hide", pin: "general/pin", eye: "general/show", diff --git a/src/ui/openidea/general/macWindowClose.svg b/src/ui/openidea/general/macWindowClose.svg new file mode 100644 index 000000000..dcf91cfbd --- /dev/null +++ b/src/ui/openidea/general/macWindowClose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/general/macWindowExitFullScreen.svg b/src/ui/openidea/general/macWindowExitFullScreen.svg new file mode 100644 index 000000000..4c0b528a9 --- /dev/null +++ b/src/ui/openidea/general/macWindowExitFullScreen.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/openidea/general/macWindowMinimize.svg b/src/ui/openidea/general/macWindowMinimize.svg new file mode 100644 index 000000000..ab44ef431 --- /dev/null +++ b/src/ui/openidea/general/macWindowMinimize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/workbench/MacWindowButtons.tsx b/src/workbench/MacWindowButtons.tsx new file mode 100644 index 000000000..fe4113345 --- /dev/null +++ b/src/workbench/MacWindowButtons.tsx @@ -0,0 +1,87 @@ +/** + * macOS window buttons in the title bar's slot while the window fills the screen. Native full + * screen moves the system's buttons into the bar revealed with the menu bar, and a project tab + * shown in its group's full-screen Space has none, so the slot kept for them would be empty. + * Close closes this project's tab, the green button leaves full screen, minimize is disabled. + * Like the system's, they take no keyboard focus. + */ +import { useEffect, useState } from "react"; +import clsx from "clsx"; +import { t } from "@/platform/i18n"; +import { commands, onIdeEvent } from "@/platform/ipc"; +import { Icon } from "@/ui"; +import styles from "./TitleBar.module.css"; + +type AppWindow = import("@tauri-apps/api/window").Window; + +function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { + const [fullscreen, setFullscreen] = useState(false); + const [active, setActive] = useState(() => document.hasFocus()); + useEffect(() => { + let disposed = false; + const offs: (() => void)[] = []; + const sync = () => void commands.windowIsFullscreen().then((on) => !disposed && setFullscreen(on)); + sync(); + offs.push(onIdeEvent("windowFullscreenChanged", sync)); + void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { + const w: AppWindow = getCurrentWindow(); + const listeners = await Promise.all([ + w.onResized(sync), + w.onFocusChanged(({ payload }) => { + setActive(payload); + sync(); + }), + ]); + if (disposed) listeners.forEach((off) => off()); + else offs.push(...listeners); + }); + return () => { + disposed = true; + offs.forEach((off) => off()); + }; + }, []); + return { fullscreen, active }; +} + +function closeWindow() { + void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close()); +} + +export function MacWindowButtons() { + const { fullscreen, active } = useFullscreenWindow(); + if (!fullscreen) return null; + return ( +
e.preventDefault()} + > + + + +
+ ); +} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index 2c4443521..de70f2b38 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -195,3 +195,52 @@ background: var(--caption-close-pressed); color: var(--fg-caption-close-hover); } + +/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */ +.macButtons { + position: fixed; + top: 0; + left: 0; + height: var(--h-titlebar); + display: flex; + align-items: center; + gap: 8px; + padding-left: 12px; + zoom: calc(1 / var(--titlebar-zoom, 1)); + z-index: 1; +} +.macButton { + width: 12px; + height: 12px; + padding: 0; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--mac-window-glyph); + box-shadow: inset 0 0 0 0.5px var(--mac-window-edge); +} +.macButton > * { + opacity: 0; +} +.macButtons:hover .macButton:not(:disabled) > * { + opacity: 1; +} +.macClose { + background: var(--mac-window-close); +} +.macMinimize { + background: var(--mac-window-minimize); +} +.macZoom { + background: var(--mac-window-zoom); +} +.macButton:active:not(:disabled) { + filter: brightness(0.8); +} +/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */ +.macButton:disabled, +.macButtonsInactive:not(:hover) .macButton { + background: var(--mac-window-inactive); +} diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 806068a97..aeedf51c0 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -21,6 +21,7 @@ import { widgetTitle, } from "./mainToolbar"; import { WindowControls } from "./WindowControls"; +import { MacWindowButtons } from "./MacWindowButtons"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch"; @@ -301,6 +302,7 @@ export function TitleBar({ if (bare) return (
+ {overlay && } {frameless && ( <> @@ -323,6 +325,7 @@ export function TitleBar({ showContextMenu(e, menu); }} > + {overlay && } {frameless && !minimal && } {title && ( diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index d2275d351..141a6bfa9 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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(); +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 { 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 : [])); } // --------------------------------------------------------------------------- diff --git a/wit/endpoints.wit b/wit/endpoints.wit new file mode 100644 index 000000000..95302fcb0 --- /dev/null +++ b/wit/endpoints.wit @@ -0,0 +1,26 @@ +package openidea:plugin@1.3.0; + +/// 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, + /// 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, + /// 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) -> result, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index ad07bbacc..311c4cef6 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -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; +}