diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a31d09a7d..eb1259220 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -221,6 +221,7 @@ struct TaskState { /// A running agent and its session. #[derive(Clone)] struct Live { + sign_in_message: String, peer: acp::Conn, session: String, capabilities: AgentCapabilities, @@ -1301,11 +1302,7 @@ impl Task { match e { // Signed out, or the account was refused (Claude Code: "API Error: 403"). RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => { - let provider = self.state.lock().task.provider.clone(); - Failure::from(match self.shared.spec(&provider) { - Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint), - None => message, - }) + Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message)) } e => Failure::from(self.describe(e)), } @@ -1323,6 +1320,11 @@ impl Task { (s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone()) }; let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; + let launch_cwd = cwd.clone(); + let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd)) + .await + .map_err(|e| e.to_string())?; + let sign_in_message = spec.sign_in_message(); self.stderr.lock().clear(); self.config_sent.lock().clear(); let weak = Arc::downgrade(self); @@ -1368,7 +1370,7 @@ impl Task { .await .map_err(|e| match e { RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => { - format!("{} needs you to sign in. {}", spec.name, spec.login_hint) + sign_in_message.clone() } e => self.describe(e), })?; @@ -1440,7 +1442,7 @@ impl Task { } self.persist(); self.emit_task(); - let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load }; + let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load }; *self.live.lock() = Some(live.clone()); Ok(live) } diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs index 410bb89eb..14d388d58 100644 --- a/crates/ide-agent/src/provider.rs +++ b/crates/ide-agent/src/provider.rs @@ -81,6 +81,64 @@ fn builtins() -> Vec { .collect() } +impl ProviderSpec { + pub(crate) fn login_hint(&self, program: &Path) -> String { + if !program.is_absolute() { + return self.login_hint.clone(); + } + let search = ide_core::process::host_var_os("PATH").unwrap_or_default(); + let builtin = builtin_spec(&self.id); + let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search)); + render_login_hint(self, program, on_path.as_deref(), cfg!(windows)) + } + + pub(crate) fn sign_in_message(&self) -> String { + let name = &self.name; + let hint = self.login_hint(Path::new(&self.command)); + format!("{name} needs you to sign in. {hint}") + } + + /// Resolve once before connecting, so authentication guidance names that same install. + pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self { + if self.driver != Driver::Acp { + self.command = crate::native::newest_install(&self.command); + } + let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env); + self.command = + if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program } + .to_string_lossy() + .into_owned(); + self + } +} + +fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String { + let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() }; + let prefix = format!("Run `{}", builtin.command); + let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else { + return spec.login_hint.clone(); + }; + let same_path = |a: &Path, b: &Path| { + a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b) + }; + if on_path.is_some_and(|path| same_path(path, program)) { + return spec.login_hint.clone(); + } + let path = program.to_string_lossy(); + let quoted = if windows { + format!("& '{}'", path.replace('\'', "''")) + } else { + format!("'{}'", path.replace('\'', "'\\''")) + }; + let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command"); + let command = format!("{quoted}{args}"); + match suffix { + " in a terminal." => format!("Run `{command}` in a terminal."), + " in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."), + _ => spec.login_hint.clone(), + } +} + /// Whether `id` names a built-in agent that needs no registry install (native drivers /// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and /// updated from the ACP Registry into `/agents/.toml`. @@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider { args: spec.args.clone(), default_program: builtin.as_ref().map(|b| b.command.clone()), default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(), - login_hint: Some(spec.login_hint.clone()), + login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))), command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command), command_editable: reason.is_none(), command_read_only_reason: reason, @@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool { mod tests { use super::*; + #[test] + fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() { + for spec in builtins() { + let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe"); + for windows in [false, true] { + let program = + if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") }; + let hint = render_login_hint(&spec, program, None, windows); + if separate { + assert_eq!(hint, spec.login_hint, "{}", spec.id); + } else { + let prefix = + if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" }; + assert!(hint.starts_with(prefix), "{}: {hint}", spec.id); + assert_eq!( + render_login_hint(&spec, program, Some(program), windows), + spec.login_hint, + "a PATH-resolved command stays bare: {}", + spec.id + ); + assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint); + } + } + } + } + + #[test] + fn login_commands_quote_shell_metacharacters_and_keep_arguments() { + let cursor = builtin_spec("cursor").unwrap(); + assert_eq!( + render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false), + "Run `'/agent'\\''s tools/$cli' login` in a terminal." + ); + assert_eq!( + render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true), + r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal." + ); + let opencode = builtin_spec("opencode").unwrap(); + assert_eq!( + render_login_hint(&opencode, Path::new("/registry/opencode"), None, false), + "Run `'/registry/opencode' auth login` in a terminal." + ); + let mut custom = cursor; + custom.id = "custom".into(); + custom.login_hint = "Sign in with the agent's own command line first.".into(); + assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint); + } + + #[test] + fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() { + let dir = tempfile::tempdir().unwrap(); + let mut cursor = builtin_spec("cursor").unwrap(); + let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" }; + let program = dir.path().join(command); + std::fs::write(&program, "").unwrap(); + cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned())); + let resolved = cursor.clone().resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), program); + assert!(resolved.sign_in_message().contains(&*program.to_string_lossy())); + cursor.command = format!("tools/{command}"); + let resolved = cursor.resolve_for_launch(dir.path()); + assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command)); + } + + #[cfg(unix)] + #[test] + fn registry_login_hint_names_the_installed_executable() { + let mut spec = builtin_spec("cursor").unwrap(); + spec.command = "/registry path/dist-package/cursor-agent".into(); + assert_eq!( + describe(&spec).login_hint.as_deref(), + Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.") + ); + } + #[test] fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index ca9f69566..4f9116546 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -11,6 +11,7 @@ struct FakeAgent { calls: Mutex>, /// Without `loadSession` (a session lives only as long as its process). no_load: bool, + auth_on_new: bool, /// Config options `session/new` reports after the model one (per-model efforts, say). extra_options: Mutex>, } @@ -87,6 +88,11 @@ impl Handler for FakeAgent { fn on_request(&self, method: &str, params: Value) -> Result { self.calls.lock().push(method.to_owned()); + if (method == "session/new" && self.auth_on_new) + || (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth"))) + { + return Err((codes::INTERNAL_ERROR, "Authentication required".into())); + } let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); match method { "initialize" => { @@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc) -> AgentMan ) } +#[tokio::test(flavor = "multi_thread")] +async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() { + for auth_on_new in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() }); + let mgr = manager(tmp.path(), agent); + let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd"); + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + program.to_string_lossy().into_owned(); + let task = mgr + .create(NewAgentTask { + project_root: None, + prompt: text("again"), + provider: "cursor".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + if !auth_on_new { + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + // A settings change must not redirect guidance for the already running process. + mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command = + "different-cursor".into(); + mgr.prompt(&task.id, text("auth"), false).await.unwrap(); + } + wait_for(&mgr, &task.id, AgentStatus::Failed).await; + let entries = mgr.entries(&task.id, None).unwrap(); + let error = entries + .iter() + .find_map(|e| match e { + AgentEntry::Error { message, .. } => Some(message), + _ => None, + }) + .expect("authentication error"); + assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}"); + assert!(error.contains(&*program.to_string_lossy()), "{error}"); + assert!(error.ends_with(" login` in a terminal."), "{error}"); + mgr.shutdown(); + } +} + #[tokio::test(flavor = "multi_thread")] async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { let tmp = tempfile::tempdir().unwrap(); 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/api.rs b/crates/ide-host/src/plugins/api.rs index 4c55f35e4..418ace365 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -188,7 +188,7 @@ pub(super) struct Shared { contexts: Mutex>, closed_clients: Mutex>, /// plugin → kinds subscribed at run time (kept across idle reclaim). - pub subscriptions: Mutex>>, + pub subscriptions: Mutex, /// plugin → files it shows problems for, withdrawn when it stops. diagnostics: Mutex>>, pub secrets: RwLock>, @@ -331,7 +331,7 @@ impl Shared { let _ = workspace.publish_diagnostics(plugin, &path, Vec::new()); } } - if subscriptions && self.subscriptions.lock().remove(plugin).is_some() { + if subscriptions && self.subscriptions.lock().remove(plugin) { self.sink.emit(None, PluginEvent::SubscriptionsChanged); } } @@ -1166,13 +1166,22 @@ impl HostApi for PluginApi { }) } - fn subscribe(&self, kind: EventKind, on: bool) { - let changed = { - let mut subs = self.shared.subscriptions.lock(); - let set = subs.entry(self.id().to_owned()).or_default(); - if on { set.insert(kind) } else { set.remove(&kind) } - }; - if changed { + fn subscribe(&self, kind: EventKind) { + if self.shared.subscriptions.lock().set(self.id(), kind) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + } + + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError> { + let filter = super::event_paths::PathFilter::new(kind, globs)?; + if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) { + self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); + } + Ok(()) + } + + fn unsubscribe(&self, kind: &str) { + if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) { self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged); } } diff --git a/crates/ide-host/src/plugins/event_paths.rs b/crates/ide-host/src/plugins/event_paths.rs new file mode 100644 index 000000000..b8ff5eb54 --- /dev/null +++ b/crates/ide-host/src/plugins/event_paths.rs @@ -0,0 +1,176 @@ +//! Project-relative event filters, kept with runtime subscriptions across idle reclaim. +use super::{LoadedPlugin, PluginHost}; +use globset::{GlobBuilder, GlobMatcher}; +use ide_plugin::{EventKind, HostError, WorkbenchEvent}; +use std::{ + collections::{BTreeSet, HashMap}, + path::Path, +}; + +#[derive(Clone, Default)] +pub(super) struct Subscriptions { + pub kinds: HashMap>, + pub paths: HashMap>, +} + +#[derive(Clone)] +pub(super) struct PathFilter { + pub globs: Vec, + matchers: Vec, +} + +impl PathFilter { + pub fn new(kind: &str, globs: Vec) -> Result { + if !matches!(kind, "file-opened" | "file-saved") { + return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into())); + } + if globs.len() > 64 { + return Err(HostError::Invalid("at most 64 event path globs".into())); + } + let mut matchers = Vec::new(); + for input in &globs { + let glob = input.trim_start_matches("./"); + if glob.is_empty() + || input.len() > 1024 + || glob.starts_with('/') + || glob.contains('\\') + || glob.contains(':') + || glob.chars().any(char::is_control) + || glob.split('/').any(|c| c == "..") + { + return Err(HostError::Invalid( + "event globs must be project-relative paths, at most 1024 bytes each".into(), + )); + } + matchers.push( + GlobBuilder::new(glob) + .literal_separator(true) + .build() + .map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))? + .compile_matcher(), + ); + } + Ok(Self { globs, matchers }) + } + + pub fn matches(&self, root: &Path, path: &Path) -> bool { + let path = ide_plugin::resolve_real(path); + let root = ide_plugin::resolve_real(root); + path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative))) + } +} + +impl Subscriptions { + pub fn remove(&mut self, plugin: &str) -> bool { + self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some() + } + /// Subscribes to every event of `kind`, dropping its path filter. + pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some()); + self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed + } + pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool { + let mut changed = false; + if let Some(kind) = EventKind::parse(kind) { + changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind); + } + let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone()); + previous.is_none_or(|old| old.globs != filter.globs) | changed + } + pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool { + let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some()); + let off = EventKind::parse(kind) + .is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind))); + removed | off + } +} + +impl PluginHost { + pub(super) fn filter_path_events( + &self, + plugin: &LoadedPlugin, + client: Option<&str>, + events: &[WorkbenchEvent], + subs: &Subscriptions, + ) -> Vec { + let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() }; + let workspace = self.shared.workspace(); + events + .iter() + .filter(|event| { + let path = match event { + WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path), + _ => return true, + }; + let Some(filter) = filters.get(event.kind().name()) else { return true }; + if !path.is_absolute() { + return false; + } + let Some(workspace) = &workspace else { return false }; + let root = match client { + Some(client) => workspace.project_of(client), + None => { + let real = ide_plugin::resolve_real(path); + workspace + .projects() + .into_iter() + .filter(|r| real.starts_with(ide_plugin::resolve_real(r))) + .max_by_key(|r| r.components().count()) + } + }; + let Some(root) = root else { return false }; + if plugin.dev_root.as_deref().is_some_and(|r| r != root) { + return false; + } + filter.matches(&root, path) + }) + .cloned() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_validation_is_bounded_and_project_relative() { + for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] { + assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}"); + } + assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err()); + assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err()); + assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err()); + assert!(PathFilter::new("file-saved", vec![]).is_ok()); + } + + #[test] + fn matchers_use_or_semantics_and_component_boundaries() { + let root = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(root.path()).unwrap(); + let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap(); + for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] { + assert!(filter.matches(&root, &root.join(path)), "{path}"); + } + for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] { + assert!(!filter.matches(&root, &root.join(path)), "{path}"); + } + assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml"))); + assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a"))); + } + + #[test] + fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() { + let mut subs = Subscriptions::default(); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap()); + assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened)); + subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap()); + assert!(subs.paths["plugin"]["file-opened"].globs.is_empty()); + subs.set("plugin", EventKind::FileOpened); + assert!(subs.paths["plugin"].is_empty()); + subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap()); + assert!(subs.unsubscribe("plugin", "file-saved")); + assert!(subs.remove("plugin")); + assert!(subs.paths.is_empty() && subs.kinds.is_empty()); + } +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0aeb50452..b02846219 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -18,6 +18,7 @@ mod api; pub mod commit; pub mod endpoints; +mod event_paths; mod hover; mod icon_extensions; mod icon_patterns; @@ -1450,7 +1451,7 @@ impl PluginHost { let mut out = BTreeSet::new(); for p in self.plugins.read().values() { for kind in EventKind::ALL { - if self.wants(p, kind, subs.get(p.id())) { + if self.wants(p, kind, subs.kinds.get(p.id())) { out.insert(kind); } } @@ -1483,7 +1484,8 @@ impl PluginHost { let mut queues = self.queues.lock(); for p in plugins { let wanted: Vec = - events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect(); + events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect(); + let wanted = self.filter_path_events(&p, client, &wanted, &subs); if wanted.is_empty() { continue; } @@ -1563,6 +1565,18 @@ impl PluginHost { /// Returns whether the plugin was activated. fn deliver(&self, id: &str, client: Option, events: Vec) -> bool { let Ok(plugin) = self.get(id) else { return false }; + let subs = self.shared.subscriptions.lock().clone(); + let events: Vec<_> = self + .filter_path_events(&plugin, client.as_deref(), &events, &subs) + .into_iter() + .filter(|event| { + !matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_)) + || self.wants(&plugin, event.kind(), subs.kinds.get(id)) + }) + .collect(); + if events.is_empty() { + return false; + } if !plugin.meta.lock().pending.is_empty() { return false; } diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index af9bf0d69..e961f51b7 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -19,6 +19,7 @@ type Events = Arc, PluginEvent)>>>; /// What plugins see of the test project: real `Documents`, as in the IDE. struct TestWorkspace { root: PathBuf, + other_roots: Mutex>, documents: Arc, trusted: bool, /// (producer, path) → published diagnostics. @@ -35,6 +36,7 @@ impl TestWorkspace { ); Arc::new(Self { root: root.to_path_buf(), + other_roots: Mutex::default(), documents: Arc::new(documents), trusted: true, diagnostics: Mutex::default(), @@ -67,7 +69,7 @@ impl TestWorkspace { impl PluginWorkspace for TestWorkspace { fn projects(&self) -> Vec { - vec![self.root.clone()] + std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect() } fn buffer_text(&self, path: &Path) -> Option { self.documents.text_of(path) @@ -1653,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() { f.install(&official("git-toolbox")); let untrusted = Arc::new(TestWorkspace { root: f.project.clone(), + other_roots: Mutex::default(), documents: f.workspace.documents.clone(), trusted: false, diagnostics: Mutex::default(), @@ -3565,3 +3568,121 @@ fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capab assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. }))); } } + +#[test] +fn plugin_readonly_fields_and_table_row_extensions_round_trip() { + let f = fixture(Limits::default()); + f.install(&sample("tables")); + let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap(); + let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into())); + assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false }); + assert_eq!( + fields[2].value, + ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true } + ); + assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_))); + for (event, status) in [ + (ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"), + (ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"), + ] { + f.take_events(); + f.host.view_event("tables", "tables", event, f.inv()).unwrap(); + assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables"))); + assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status)); + } +} + +#[test] +fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() { + let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() }); + f.install(&sample("path-events")); + f.run("test.path-events", "count", None).unwrap(); + let matched = to_ipc(&f.project.join("config/app.yaml")); + let other = to_ipc(&f.project.join("src/main.rs")); + let deliver = |events| { + f.host.post_events(Some("main"), events); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + }; + std::thread::sleep(Duration::from_millis(150)); + f.host.tick(); + assert_eq!(f.status("test.path-events"), PluginStatus::Inactive); + let activations = f.host.info_of("test.path-events").unwrap().stats.activations; + deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]); + assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2")); + assert!(f.run("test.path-events", "filter", Some("../*")).is_err()); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "invalid filter preserves previous one" + ); + + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]); + f.run("test.path-events", "filter", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + assert_eq!( + f.run("test.path-events", "count", None).unwrap().as_deref(), + Some("3"), + "queued event uses current filter" + ); + deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3")); + f.run("test.path-events", "all", None).unwrap(); + deliver(vec![WorkbenchEvent::FileOpened(other.clone())]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]); + f.run("test.path-events", "off", None).unwrap(); + f.host.flush_events_at(Instant::now() + Duration::from_secs(3)); + assert!(f.host.wait_events(Duration::from_secs(10))); + deliver(vec![WorkbenchEvent::FileOpened(other)]); + assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4")); + + f.host.set_enabled("test.path-events", false).unwrap(); + assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind())); +} + +#[test] +fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() { + let f = fixture(Limits::default()); + f.install(&sample("path-events")); + let path = f.project.join("src/notes.txt"); + assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project))); + assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(), + None + ); + let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() }; + let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0)); + assert_eq!(hovers.len(), 1); + assert_eq!(hovers[0].markdown, to_ipc(&f.project)); + let nested = f.project.join("nested"); + std::fs::create_dir(&nested).unwrap(); + f.workspace.other_roots.lock().push(nested.clone()); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(), + Some(to_ipc(&nested)) + ); + std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap(); + assert_eq!( + f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(), + None + ); + } + f.host.set_capability("test.path-events", "project.read", false).unwrap(); + assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read")); + f.host.set_capability("test.path-events", "events", false).unwrap(); + assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events")); +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 6aa79979b..a44c98a15 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -346,7 +346,7 @@ impl Drop for KillOnDrop { /// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so /// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else /// is kept as given. -fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { +pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf { let path = std::path::Path::new(command); if path.components().count() > 1 || path.is_absolute() { return path.to_path_buf(); diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 1b417b2b0..1878bb790 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -20,6 +20,6 @@ pub use runtime::{ API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api, component_api, host_api, }; -pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root}; +pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real}; pub use types::*; pub use wasmtime::component::Component; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index e0bdf562b..35f154751 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -110,6 +110,17 @@ pub trait HostApi: Send + Sync { } /// Roots of the open projects. fn projects(&self) -> Vec; + /// The most specific open project containing a document (including a new file). + fn project_of(&self, path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let real = crate::resolve_real(path); + self.projects() + .into_iter() + .filter(|root| real.starts_with(crate::resolve_real(root))) + .max_by_key(|root| root.components().count()) + } /// Unsaved text of an open editor's file. fn buffer_text(&self, path: &Path) -> Option; /// Version and text of the open document at `path`. @@ -166,7 +177,11 @@ pub trait HostApi: Send + Sync { cancel: &CancellationToken, timeout: Duration, ) -> Result; - fn subscribe(&self, kind: EventKind, on: bool); + /// Delivers every event of `kind`, dropping its path filter. + fn subscribe(&self, kind: EventKind); + /// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`. + fn subscribe_paths(&self, kind: &str, globs: Vec) -> Result<(), HostError>; + fn unsubscribe(&self, kind: &str); /// Starts a progress indicator; cancelling it from the UI cancels `cancel`. fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64; fn task_report(&self, id: u64, done: u32, total: Option, message: Option); @@ -1604,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View { options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(), selected: clip(selected, MAX_TITLE), }, - // A field type of a newer API: left out. - wit_vt::FieldValue::Other(_) => return None, + wit_vt::FieldValue::Other(e) => match e.kind.as_str() { + "label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)), + "readonly-text" => { + #[derive(serde::Deserialize)] + struct ReadonlyText { + text: String, + #[serde(default)] + multiline: bool, + } + let value: ReadonlyText = serde_json::from_str(&e.data).ok()?; + FieldValue::ReadonlyText { + text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }), + multiline: value.multiline, + } + } + _ => return None, + }, }; Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value }) }) @@ -1658,6 +1688,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { ViewEvent::InsertRow { before } => W::InsertRow(before), ViewEvent::DeleteRows { rows } => W::DeleteRows(rows), ViewEvent::Message { text } => W::Message(text), + ViewEvent::TableSelect { row } => { + W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) }) + } + ViewEvent::TableActivate { row } => { + W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) }) + } // A sortable table's header click, as an `other` view-event (plugins/ // README.md §10): the SDKs decode the kinds they know. ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension { @@ -1671,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent { mod tests { use super::*; + #[test] + fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() { + let fields = [ + ("label", r#""Title \"quoted\"""#), + ("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#), + ("readonly-text", r#"{"text":"author"}"#), + ("label", "{}"), + ("readonly-text", r#"{"text":false}"#), + ("future", "null"), + ] + .into_iter() + .enumerate() + .map(|(i, (kind, data))| wit_vt::Field { + key: i.to_string(), + label: "Field".into(), + value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }), + }) + .collect(); + let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None }); + let ViewBody::Form(fields) = view.body else { panic!("form") }; + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into())); + assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true }); + assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false }); + } + + #[test] + fn table_row_events_leave_through_released_extension_case() { + for (event, kind) in [ + (ViewEvent::TableSelect { row: 51 }, "table-select"), + (ViewEvent::TableActivate { row: 51 }, "table-activate"), + ] { + let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") }; + assert_eq!(extension.kind, kind); + assert_eq!(extension.data, r#"{"row":51}"#); + } + } + #[cfg(windows)] #[test] fn provider_and_resolved_action_paths_match_ipc_events() { diff --git a/crates/ide-plugin/src/sandbox.rs b/crates/ide-plugin/src/sandbox.rs index 7d966ff16..71a03d615 100644 --- a/crates/ide-plugin/src/sandbox.rs +++ b/crates/ide-plugin/src/sandbox.rs @@ -21,7 +21,7 @@ fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> { /// the canonical path when it exists, else the canonicalized deepest existing /// ancestor with the still-missing tail resolved textually — so the location /// of a missing path is still judged where it would really land. -fn resolve_real(joined: &Path) -> PathBuf { +pub fn resolve_real(joined: &Path) -> PathBuf { let mut tail = Vec::new(); let mut candidate = joined.to_path_buf(); let mut real = loop { diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 357f459aa..d12365203 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -527,6 +527,8 @@ pub struct ViewListItem { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", tag = "kind", content = "value")] pub enum FieldValue { + Label(String), + ReadonlyText { text: String, multiline: bool }, Text(String), Multiline(String), Checkbox(bool), @@ -644,6 +646,8 @@ pub enum ViewEvent { // A sortable table's header click: the column and the direction (leaves // as an `other` view-event, kind `sort`). See `TableSort`. Sort { column: u32, descending: bool }, + TableSelect { row: u32 }, + TableActivate { row: u32 }, } /// A plugin's HTTP route declaration in a project. diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 568e3b03c..bf9d92b09 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -460,6 +460,10 @@ impl workspace::Host for StoreData { Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit)) } + fn project_of(&mut self, path: String) -> wasmtime::Result>> { + self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose() + } + fn projects(&mut self) -> Fallible>> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -758,14 +762,19 @@ impl events::Host for StoreData { { return Ok(Err(e)); } - self.host.subscribe(kind, true); + self.host.subscribe(kind); Ok(Ok(())) } - fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { - if let Some(kind) = EventKind::parse(&kind) { - self.host.subscribe(kind, false); + fn subscribe_paths(&mut self, kind: String, globs: Vec) -> Fallible<()> { + if let Err(e) = self.require(Capability::Events) { + return Ok(Err(e)); } + Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit)) + } + + fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> { + self.host.unsubscribe(&kind); Ok(()) } } 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 65cc7254d..51d9c12f7 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 @@ -f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6 +73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 2363d3bf4..1b3352545 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b +4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 37482b7f6..b01c003f5 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 3f4dc2f39..41ad8e0bb 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 @@ -47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b +83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1 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 40bf2f7cf..f07847b94 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 @@ -c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6 +bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0 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 0418a5dbd..ea69603ef 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 @@ -58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a +1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef 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 a001ad8cc..ac690f4be 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 @@ -56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3 +17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index bf157c042..ec949d20b 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 ef2677b58..228a0f7ef 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 @@ -ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a +85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e 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 96e1c60f6..3b42c446a 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 @@ -89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540 +ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6 diff --git a/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock b/crates/ide-plugin/tests/fixtures/path-events/Cargo.lock new file mode 100644 index 000000000..9295140e5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/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-path-events" +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/path-events/Cargo.toml b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml new file mode 100644 index 000000000..f4ec67ef5 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-path-events" +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/path-events/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm new file mode 100644 index 000000000..043a98de0 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources new file mode 100644 index 000000000..00cae590f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -0,0 +1 @@ +66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d diff --git a/crates/ide-plugin/tests/fixtures/path-events/plugin.toml b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml new file mode 100644 index 000000000..c959419ad --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/plugin.toml @@ -0,0 +1,34 @@ +id = "test.path-events" +name = "Path event fixture" +version = "0.1.0" +engines.openidea = "^1.3" +wasm = "dist/plugin.wasm" +activation = ["on-command:*"] + +[capabilities] +required = ["storage"] +optional = ["events", "project.read"] + +[[contributes.providers]] +kinds = ["hover"] +languages = ["text", "plaintext"] + +[[contributes.commands]] +id = "project" +title = "project" + +[[contributes.commands]] +id = "filter" +title = "filter" + +[[contributes.commands]] +id = "all" +title = "all" + +[[contributes.commands]] +id = "off" +title = "off" + +[[contributes.commands]] +id = "count" +title = "count" diff --git a/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs new file mode 100644 index 000000000..fc9a8eed3 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/path-events/src/lib.rs @@ -0,0 +1,47 @@ +use openidea_plugin::hover::{Document, Hover, HoverProvider}; +use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin}; + +#[derive(Default)] +struct Fixture; + +impl Plugin for Fixture { + fn activate(&mut self, _: &Context) -> Result<(), Error> { + // Persist the chosen filter so reactivation does not overwrite it. + let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into()); + let globs: Vec<_> = glob.lines().collect(); + let _ = host::subscribe_paths("file-opened", &globs); + let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]); + Ok(()) + } + fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result, Error> { + match id { + "project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(), + "filter" => { + let glob = arg.unwrap_or_default(); + host::subscribe_paths("file-opened", &glob.lines().collect::>())?; + host::storage::set("filter", glob)?; + Ok(None) + } + "all" => { + host::subscribe(EventKind::FileOpened)?; + Ok(None) + } + "off" => { + host::unsubscribe(EventKind::FileOpened); + Ok(None) + } + "count" => Ok(host::storage::get("count")?), + _ => Err(Error::NotFound(id.into())), + } + } + fn handle_events(&mut self, events: &[Event], _: &Invocation) { + let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::().ok()).unwrap_or(0); + host::storage::set("count", &(count + events.len()).to_string()).unwrap(); + } +} +impl HoverProvider for Fixture { + fn hover(&mut self, doc: &Document, _: Position) -> Option { + Some(Hover::new(host::project_of(&doc.path)?.root().ok()?)) + } +} +register_plugin!(Fixture, hover); 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 0b3aeaf03..dc17a0146 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 @@ -284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c +98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018 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 0f00f1fc0..fe09a781d 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 @@ -243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504 +33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 265487df1..8b2e2ce3e 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 d554de5bb..0f9e3ec65 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 @@ -4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979 +92654b80a977c272264c15d8b608e8bdac6c5d5612a5da2707bab156e655f615 diff --git a/crates/ide-plugin/tests/fixtures/tables/plugin.toml b/crates/ide-plugin/tests/fixtures/tables/plugin.toml index b6f0ff762..08d7e23e4 100644 --- a/crates/ide-plugin/tests/fixtures/tables/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/tables/plugin.toml @@ -7,12 +7,27 @@ name = "Tables" version = "0.1.0" description = "Test fixture: a sortable table the plugin sorts itself." authors = ["OpenIDEA contributors"] -engines.openidea = "^1.0" +engines.openidea = "^1.3" wasm = "dist/plugin.wasm" -activation = ["on-view:tables"] +activation = ["on-view:tables", "on-view:fields", "on-command:inspect"] [[contributes.views]] id = "tables" title = "Tables" icon = "plugin" location = "right" + +[[contributes.views]] +id = "fields" +title = "Read-only Fields" +icon = "plugin" +location = "right" + +[[contributes.commands]] +id = "inspect" +title = "Inspect Row" + +[[contributes.menus]] +menu = "viewItem.context" +view = "tables" +command = "inspect" diff --git a/crates/ide-plugin/tests/fixtures/tables/src/lib.rs b/crates/ide-plugin/tests/fixtures/tables/src/lib.rs index 55f60c2cd..6b8930748 100644 --- a/crates/ide-plugin/tests/fixtures/tables/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/tables/src/lib.rs @@ -4,7 +4,7 @@ //! `sort`), and the rows render in the order the plugin was asked for. use openidea_plugin::host; -use openidea_plugin::views::{Body, Table, TableSort, View, ViewEvent, Viewport, Views}; +use openidea_plugin::views::{Body, Field, FieldValue, Table, TableSort, View, ViewEvent, Viewport, Views}; use openidea_plugin::{Error, Invocation, Plugin, register_plugin}; pub const VIEW: &str = "tables"; @@ -15,6 +15,8 @@ const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("tom #[derive(Default)] pub struct Tables { sort: Option, + status: Option, + note: Option, } impl Tables { @@ -34,13 +36,34 @@ impl Tables { } impl Plugin for Tables { - fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result, Error> { + fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result, Error> { + if id == "inspect" { + self.status = Some(format!("context:{}", arg.unwrap_or(""))); + host::refresh_view(VIEW); + return Ok(self.status.clone()); + } Err(Error::NotFound(format!("command {id}"))) } } impl Views for Tables { fn render(&mut self, view: &str, _viewport: Option, _ctx: &Invocation) -> Result { + if view == "fields" { + return Ok(View::new(Body::Form(vec![ + Field::new("title", "Title", FieldValue::label("Merge request #42")), + Field::new("author", "Author", FieldValue::readonly_text("Ada Lovelace", false)), + Field::new( + "description", + "Description", + FieldValue::readonly_text("Ready for review\nAll checks passed", true), + ), + Field::new( + "editable", + "Editable note", + FieldValue::Text(self.note.clone().unwrap_or_else(|| "Edit me".into())), + ), + ]))); + } if view != VIEW { return Err(Error::NotFound(view.into())); } @@ -51,13 +74,32 @@ impl Views for Tables { rows: self.rows(), editable: false, }; - Ok(View::new(Body::sortable_table(&table, self.sort))) + let mut view = View::new(Body::sortable_table(&table, self.sort)); + view.status = self.status.clone(); + Ok(view) } fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> { + if view == "fields" { + if let ViewEvent::Field(key, value) = event { + if key == "editable" { + self.note = Some(value); + host::refresh_view("fields"); + } + } + return Ok(()); + } if view != VIEW { return Err(Error::NotFound(view.into())); } + if let Some(row) = event.table_select() { + self.status = Some(format!("select:{row}")); + host::refresh_view(VIEW); + } + if let Some(row) = event.table_activate() { + self.status = Some(format!("activate:{row}")); + host::refresh_view(VIEW); + } // The host may have encoded the click for a newer SDK: the typed // helper reads the `other` kind `sort` this SDK knows. if let Some(sort) = event.sort() { 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/plugins/README.md b/plugins/README.md index 61d491c93..fa14654b0 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -419,6 +419,27 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON). The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm. +API 1.3 adds `host::subscribe_paths("file-opened", &["config/*.yaml"])` +(`events.subscribe-paths`). The host matches project-relative globs before +queueing an event or waking an idle plugin. `file-saved` works the same way. +Patterns use `/`, `*` stays within a directory, and `**` crosses +directories. Up to 64 patterns of 1024 bytes each are accepted; absolute paths, +parent traversal and malformed patterns fail without replacing the old filter. + +Register filters in `activate`, using a startup or command activation to install +them before observing events. Patterns are ORed; another call replaces the same +kind's filter, an empty list pauses it, `subscribe(kind)` restores all paths, +and `unsubscribe(kind)` removes it. Filters survive idle reclaim and disappear +on disable, uninstall or reload. Each window's events use its current project; +files outside that project do not match. + +Provider calls can use `host::project_of(&doc.path)` (`workspace.project-of`) +without an invocation project or enumerating every open project. It returns the +deepest open project containing the absolute path, or `None` outside open roots. +Symlinks and missing file tails are resolved before checking containment. +Obtaining a handle grants no read access: `Project::root` and file operations +still require `project.read`. + ### Commits A plugin can work on the commit the user is preparing, in the Commit tool @@ -618,6 +639,26 @@ For a table the user can sort by clicking a header, build it with column headers clickable and asks for a column and direction with `ViewEvent::sort()` — sort your rows and render again; the sort you report shows as an arrow in the header. +API 1.3 adds read-only form values without changing the released field record: +`FieldValue::label(text)` uses `other { kind: "label", data }`, where `data` +is a JSON string. `FieldValue::readonly_text(text, multiline)` uses kind +`readonly-text` with JSON `{ "text": "…", "multiline": false }` (the flag +defaults to false). Labels render as plain selectable values; read-only text +keeps the single-line or multiline text control and never sends field events. + +Table row clicks and Space send `other` kind `table-select`; double-click +and Enter send `table-activate`, both with JSON `{ "row": 0 }`. +`ViewEvent::table_select()` and `table_activate()` decode the absolute, +zero-based index, including `first_row` for paged tables. These work for both +plain tables and the same `other` table shape used by sortable tables above; +no new table shape is needed. Editable cells still double-click to edit; +activate their row with Enter on the focused row or double-click its row number. +`viewItem.context` menus also work on table rows: the command argument and +`viewItem` context key are the decimal row index, e.g. `"0"`. Editable tables +keep their insert/delete entries alongside contributed commands. After sorting +or changing data, plugins resolve row indices against their current row order. +Declare `engines.openidea = "^1.3"` when relying on these additions. + Bodies, field types and view events added by later APIs travel as `Other { kind, data }`: an IDE that doesn't know a body says it needs updating, and leaves out a field type it doesn't know. diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 45abe7179..4c26aa7a6 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316 +abd041ece9d10d591c377e1a432dc83d0bf4240f502bb62f8c1d1876585f2d9c diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ede72b964..3937291d0 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf +7e6d23a7ac4da5429c453ec33dae8ce4e19ca87114a741b289cd4041b05fb59f diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 7b5db1cf8..46f165e97 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 f934d73f2..a994d32a5 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a +d63205db552dfd0d48c6834e662e7423481bc0d54b4a66755bae20ba085d5c99 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 024e3ba88..0ff05c2b1 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696 +2ef0482dce9edcf0bb0e8e13e5df7fd1fe735832d8091dbe4603f49bb01dd22a diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index fab3d5b66..99dff7b05 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892 +4a1788b4a53f87cfe921f70102d76d6bc31668fbc3f993d81d60f8d2c92143bb diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index cb3f47b28..37f95f763 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852 +d2992f8f58b131ba05e70262d4c825f61449c3b45f6c9543eb90f27bf8ef8748 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 2f706ae66..f8e01f0aa 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809 +582fb1f8b6c24e54c70b1f6028cd4cdfed067482f4a4635d6e7720608bca3584 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 3e5f9a1d5..e6a1f675b 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a +3cfbfad0d9554cc7dad2e3255054e9a06f60f6e0745a99a608ef29143c93f42b diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index 3c9407408..81d96c857 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 f6e51b94e..3cd7203d1 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268 +2238f16cbfca44f1ab02768e733e37fa713c0ee817ef32e94d3c27d8c07bb09c diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index fe62a5e73..b9561d44b 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949 +dff3656603212315d37e27a498e7090d494e0a52df20c4bd9541fb0c933aeb47 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ff114fb2c..c90232e94 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb +8bb9095d0dae184aabbc8f2abfe9a3bf6c44ff97e73b7eda6f39d3504d040340 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 0d9d9b01f..b08e3df37 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -182,6 +182,10 @@ mod imp { wit::workspace::projects().map_err(error) } + pub fn project_of(path: &str) -> Option { + wit::workspace::project_of(path) + } + pub fn project_root(p: &ProjectRes) -> Result { p.root().map_err(error) } @@ -278,6 +282,10 @@ mod imp { } } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + wit::events::subscribe_paths(kind, &globs.iter().map(|g| (*g).to_owned()).collect::>()).map_err(error) + } + pub fn subscribe(k: EventKind) -> Result<(), Error> { wit::events::subscribe(k.name()).map_err(error) } @@ -621,6 +629,12 @@ pub fn projects() -> Result, Error> { Ok(imp::projects()?.into_iter().map(|inner| Project { inner }).collect()) } +/// The deepest open project containing an absolute document path, including in providers. +/// Obtaining the handle needs no capability; reading it still needs `project.read`. +pub fn project_of(path: &str) -> Option { + imp::project_of(path).map(|inner| Project { inner }) +} + /// A file open in an editor. pub struct TextEditor { inner: imp::EditorRes, @@ -790,6 +804,13 @@ pub fn locale() -> String { imp::locale() } +/// Subscribe to project-relative path globs for file-opened or file-saved. +/// Replaces the previous filter. Empty globs pause delivery; `subscribe` restores all paths. +/// Requires `events`. Register during activation before the events you want to observe. +pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + imp::subscribe_paths(kind, globs) +} + /// Delivers `kind` events to [`crate::Plugin::handle_events`] for the rest of /// the session (also after the instance is reclaimed: an event activates the /// plugin again). Needs `events` (`events.input` for `ActionPerformed`). diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index fb0c05b44..a01e8f510 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -72,6 +72,7 @@ struct State { context: BTreeMap, storage: BTreeMap, subscriptions: BTreeSet, + path_subscriptions: BTreeMap>, tasks: Vec, vcs: Option, remotes: Vec<(String, String)>, @@ -230,6 +231,10 @@ impl MockHost { self.state.borrow().storage.clone() } + pub fn path_subscriptions(&self) -> BTreeMap> { + self.state.borrow().path_subscriptions.clone() + } + pub fn subscriptions(&self) -> BTreeSet { self.state.borrow().subscriptions.clone() } @@ -613,6 +618,27 @@ pub mod current { }) } + pub fn project_of(path: &str) -> Option { + let path = path.replace('\\', "/"); + let absolute = + path.starts_with('/') || (path.as_bytes().get(1) == Some(&b':') && path.as_bytes().get(2) == Some(&b'/')); + if !absolute || path.split('/').any(|part| part == "..") { + return None; + } + with(|s| { + s.root + .iter() + .chain(&s.other_projects) + .filter(|root| { + let root = root.replace('\\', "/"); + path == root + || path.strip_prefix(root.trim_end_matches('/')).is_some_and(|rest| rest.starts_with('/')) + }) + .max_by_key(|root| root.replace('\\', "/").split('/').count()) + .map(|root| MockProject { root: root.clone() }) + }) + } + pub fn project_root(p: &ProjectRes) -> Result { with(|s| { require(s, "project.read")?; @@ -797,6 +823,38 @@ pub mod current { }) } + pub fn subscribe_paths(kind: &str, globs: &[&str]) -> Result<(), Error> { + with(|s| { + require(s, "events")?; + if !matches!(kind, "file-opened" | "file-saved") + || globs.len() > 64 + || globs.iter().any(|g| { + let relative = g.trim_start_matches("./"); + relative.is_empty() + || g.len() > 1024 + || relative.starts_with('/') + || g.contains('\\') + || g.contains(':') + || g.chars().any(char::is_control) + || g.split('/').any(|p| p == "..") + }) + { + return Err(Error::Invalid("invalid event path filter".into())); + } + match kind { + "file-opened" => { + s.subscriptions.insert(EventKind::FileOpened); + } + "file-saved" => { + s.subscriptions.insert(EventKind::FileSaved); + } + _ => {} + } + s.path_subscriptions.insert(kind.to_owned(), globs.iter().map(|g| (*g).to_owned()).collect()); + Ok(()) + }) + } + pub fn subscribe(kind: EventKind) -> Result<(), Error> { with(|s| { let cap = match kind { @@ -807,13 +865,17 @@ pub mod current { if let Some(cap) = cap { require(s, cap)?; } + s.path_subscriptions.remove(kind.name()); s.subscriptions.insert(kind); Ok(()) }) } pub fn unsubscribe(kind: EventKind) { - with(|s| s.subscriptions.remove(&kind)); + with(|s| { + s.path_subscriptions.remove(kind.name()); + s.subscriptions.remove(&kind); + }); } pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result, Error> { @@ -965,6 +1027,38 @@ mod tests { use super::*; use crate::host; + #[test] + fn path_subscriptions_replace_pause_and_restore() { + let mock = MockHost::install(); + host::subscribe_paths("file-opened", &["config/*.yaml"]).unwrap(); + assert_eq!(mock.path_subscriptions()["file-opened"], ["config/*.yaml"]); + host::subscribe_paths("file-opened", &[]).unwrap(); + assert!(mock.path_subscriptions()["file-opened"].is_empty()); + assert!(host::subscribe_paths("file-opened", &["../secret"]).is_err()); + host::subscribe(EventKind::FileOpened).unwrap(); + assert!(mock.path_subscriptions().is_empty()); + host::subscribe_paths("file-saved", &["**/*.rs"]).unwrap(); + host::unsubscribe(EventKind::FileSaved); + assert!(mock.path_subscriptions().is_empty()); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::subscribe_paths("file-opened", &[]), Err(Error::Denied(_)))); + } + + #[test] + fn project_lookup_uses_deepest_root_without_granting_read_access() { + let mock = MockHost::install(); + mock.add_project("/project/nested"); + assert_eq!(host::project_of("/project/nested/new.txt").unwrap().root().unwrap(), "/project/nested"); + assert!(host::project_of("/project-other/file").is_none()); + assert!(host::project_of("relative/file").is_none()); + mock.add_project("C:/workspace"); + assert_eq!(host::project_of(r"C:\workspace\file.txt").unwrap().root().unwrap(), "C:/workspace"); + mock.add_project("//server/share"); + assert_eq!(host::project_of(r"\\server\share\file.txt").unwrap().root().unwrap(), "//server/share"); + let _denied = MockHost::install().with_capabilities(&[]); + assert!(matches!(host::project_of("/project/file").unwrap().root(), Err(Error::Denied(_)))); + } + #[test] fn invocations_carry_selected_paths() { let host = MockHost::install(); diff --git a/sdk/rust/openidea-plugin/src/views.rs b/sdk/rust/openidea-plugin/src/views.rs index 9acf2b9bd..bb6729c8d 100644 --- a/sdk/rust/openidea-plugin/src/views.rs +++ b/sdk/rust/openidea-plugin/src/views.rs @@ -125,6 +125,21 @@ pub enum FieldValue { }, } +impl FieldValue { + /// A plain read-only value, carried by `other` kind `label` (API 1.3). + pub fn label(text: &str) -> Self { + Self::Other { kind: "label".into(), data: format!("\"{}\"", crate::text::escape_json(text)) } + } + + /// Selectable text with no field-change events (API 1.3). + pub fn readonly_text(text: &str, multiline: bool) -> Self { + Self::Other { + kind: "readonly-text".into(), + data: format!(r#"{{"text":"{}","multiline":{multiline}}}"#, crate::text::escape_json(text)), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Field { pub key: String, @@ -273,6 +288,21 @@ pub enum ViewEvent { } impl ViewEvent { + /// The absolute zero-based row selected in a table (API 1.3). + pub fn table_select(&self) -> Option { + self.table_row("table-select") + } + + /// The absolute zero-based row activated in a table (API 1.3). + pub fn table_activate(&self) -> Option { + self.table_row("table-activate") + } + + fn table_row(&self, expected: &str) -> Option { + let Self::Other { kind, data } = self else { return None }; + (kind == expected).then(|| json_number(data, "row")).flatten() + } + /// The click on a sortable table's header ([`Body::sortable_table`]): the /// column and the direction to order it by (`other` view-event, kind /// `sort`; the first click asks ascending, a second on the same column @@ -342,6 +372,27 @@ pub trait Views { mod tests { use super::*; + #[test] + fn readonly_fields_escape_text_without_changing_released_variants() { + assert_eq!(FieldValue::label("a\"\n"), FieldValue::Other { kind: "label".into(), data: r#""a\"\n""#.into() }); + assert_eq!( + FieldValue::readonly_text("a\nβ", true), + FieldValue::Other { kind: "readonly-text".into(), data: r#"{"text":"a\nβ","multiline":true}"#.into() } + ); + } + + #[test] + fn table_row_events_only_decode_their_own_valid_payloads() { + let event = |kind: &str, data: &str| ViewEvent::Other { kind: kind.into(), data: data.into() }; + assert_eq!(event("table-select", r#"{"row":51}"#).table_select(), Some(51)); + assert_eq!(event("table-activate", r#"{"row":0}"#).table_activate(), Some(0)); + for data in ["{}", r#"{"row":-1}"#, r#"{"row":4294967296}"#, r#"{"row":"1"}"#] { + assert_eq!(event("table-select", data).table_select(), None); + } + assert_eq!(event("select", r#"{"row":1}"#).table_select(), None); + assert_eq!(event("table-select", r#"{"row":1}"#).table_activate(), None); + } + #[test] fn a_sortable_table_becomes_the_other_table_body() { let table = Table { diff --git a/sdk/rust/openidea-plugin/wit/events.wit b/sdk/rust/openidea-plugin/wit/events.wit index 92175d555..ffb86d39b 100644 --- a/sdk/rust/openidea-plugin/wit/events.wit +++ b/sdk/rust/openidea-plugin/wit/events.wit @@ -36,6 +36,10 @@ interface events { /// and an IDE that doesn't know one fails with `invalid`. /// Requires `events` (`events.input` for `action-performed`). subscribe: func(kind: string) -> result<_, error>; + /// Replaces this kind's subscription with project-relative globs (OR). + /// Empty globs pause delivery; `subscribe` restores unfiltered delivery. + /// Kinds: file-opened, file-saved. Requires `events`. + subscribe-paths: func(kind: string, globs: list) -> result<_, error>; unsubscribe: func(kind: string); } diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index b31964e8b..c764d50e8 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -8,6 +8,10 @@ interface workspace { /// `path` is an absolute directory path on the UI machine. open-project: func(path: string, new-window: bool) -> result<_, error>; + /// The open project containing an absolute document path, or `none`. + /// Reading the returned project still requires `project.read`. + project-of: func(path: string) -> option; + /// What a path inside a project is. enum file-kind { file, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7aeb867a9..aba69c62d 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 17f7e04fa..e11cceeec 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 959a85122..d7a5d977f 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. * @@ -3796,7 +3801,10 @@ export type FacetConfig = { webRoot?: string, }; -export type FieldValue = { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { +export type FieldValue = { kind: "label"; value: string } | { kind: "readonlyText"; value: { + text: string, + multiline: boolean, +} } | { kind: "text"; value: string } | { kind: "multiline"; value: string } | { kind: "checkbox"; value: boolean } | { kind: "choice"; value: { options: string[], selected: string, } }; @@ -4488,7 +4496,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`. */ @@ -8129,7 +8139,7 @@ export type ViewButton = { }; /** A user action in a view. */ -export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean }; +export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean } | { kind: "tableSelect"; row: number } | { kind: "tableActivate"; row: number }; export type ViewField = { key: string, diff --git a/src/features/agents/BuiltinAgents.tsx b/src/features/agents/BuiltinAgents.tsx index 13a4c879b..9b32a16bf 100644 --- a/src/features/agents/BuiltinAgents.tsx +++ b/src/features/agents/BuiltinAgents.tsx @@ -1,7 +1,7 @@ /** Built-in launch settings; each executable is staged independently until Apply. */ import { useCallback, useEffect, useState } from "react"; import type { AgentProvider } from "@/bindings"; -import { tx, useTx } from "@/platform/i18n"; +import { tx, useTxTemplate } from "@/platform/i18n"; import clsx from "clsx"; import { Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui"; import { useSettingsForm } from "@/workbench/contributions"; @@ -242,7 +242,7 @@ const formatEnv = (env: Record) => /** Login hints are plain text with optional backtick-delimited command spans. */ function LoginHint({ text }: { text: string }) { - const translate = useTx(); + const translate = useTxTemplate(); return translate(text) .split(/(`[^`]+`)/g) .map((part, i) => (i % 2 === 1 ? {part.slice(1, -1)} : part)); diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index a33171219..35e97cb82 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -58,6 +58,7 @@ import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { ImagePreview, imageSrc } from "./ImagePreview"; import { AgentReply } from "./AgentReply"; import { Markdown } from "./Markdown"; +import { useAgentErrorText } from "./loginHint"; import { branchLabel, entriesKey, @@ -866,12 +867,16 @@ const Entry = memo(function Entry({ return (
- {entry.message} +
); } }); +function AgentErrorText({ message }: { message: string }) { + return {useAgentErrorText(message)}; +} + function UserMessage({ task, entry, diff --git a/src/features/agents/loginHint.test.ts b/src/features/agents/loginHint.test.ts new file mode 100644 index 000000000..056c105cb --- /dev/null +++ b/src/features/agents/loginHint.test.ts @@ -0,0 +1,26 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; +import { useAgentErrorText } from "./loginHint"; + +afterEach(() => _resetI18nForTests()); + +it.each([ + ["Run `cursor-agent login` in a terminal.", "在终端中运行 `cursor-agent login`。"], + ["Run `'/registry path/cursor-agent' login` in a terminal.", "在终端中运行 `'/registry path/cursor-agent' login`。"], + [ + "Run `& 'C:\\Agent Tools\\cursor-agent.cmd' login` in a terminal.", + "在终端中运行 `& 'C:\\Agent Tools\\cursor-agent.cmd' login`。", + ], + ["Run `'/registry/claude'` in a terminal and sign in.", "在终端中运行 `'/registry/claude'` 并登录。"], +])("translates sign-in guidance with its command intact: %s", async (hint, translated) => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText(`Cursor needs you to sign in. ${hint}`)); + expect(result.current).toBe(`Cursor 需要你登录。${translated}`); +}); + +it("preserves external agent errors", async () => { + await setLocale("zh"); + const { result } = renderHook(() => useAgentErrorText("Agent says: authentication failed")); + expect(result.current).toBe("Agent says: authentication failed"); +}); diff --git a/src/features/agents/loginHint.ts b/src/features/agents/loginHint.ts new file mode 100644 index 000000000..231dc0a26 --- /dev/null +++ b/src/features/agents/loginHint.ts @@ -0,0 +1,9 @@ +import { useTx, useTxTemplate } from "@/platform/i18n"; + +/** Translate the IDE's sign-in guidance while preserving the agent's own errors. */ +export function useAgentErrorText(message: string): string { + const tx = useTx(); + const translate = useTxTemplate(); + const auth = /^(.+) needs you to sign in\. (.+)$/s.exec(message); + return auth ? tx`${auth[1]} needs you to sign in. ${translate(auth[2]!)}` : message; +} 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/plugins/__tests__/views.test.tsx b/src/features/plugins/__tests__/views.test.tsx index e6a8a2247..8f008fe1b 100644 --- a/src/features/plugins/__tests__/views.test.tsx +++ b/src/features/plugins/__tests__/views.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import type { View, ViewEvent } from "@/bindings"; @@ -419,6 +419,7 @@ describe("PluginView", () => { render(); await user.dblClick(await screen.findByText("London")); const input = screen.getByLabelText("Cell value"); + expect(events.some((e) => e.kind === "tableActivate")).toBe(false); await user.clear(input); await user.type(input, "Paris{Enter}"); await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" })); @@ -533,3 +534,182 @@ describe("PluginView", () => { expect(await screen.findByText("boom")).toBeInTheDocument(); }); }); + +describe("API 1.3 read-only values and table rows", () => { + it("shows selectable labels and readonly text without sending field changes", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "form", + value: [ + { key: "title", label: "Title", value: { kind: "label", value: "Merge request" } }, + { + key: "author", + label: "Author", + value: { kind: "readonlyText", value: { text: "Ada", multiline: false } }, + }, + { + key: "body", + label: "Description", + value: { kind: "readonlyText", value: { text: "one\ntwo", multiline: true } }, + }, + ], + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render(); + expect(await screen.findByLabelText("Title")).toHaveTextContent("Merge request"); + for (const label of ["Author", "Description"]) { + const input = screen.getByLabelText(label); + expect(input).toHaveAttribute("readonly"); + await user.type(input, "changed"); + await user.tab(); + } + expect(screen.getByLabelText("Author")).toHaveValue("Ada"); + expect(screen.getByLabelText("Description")).toHaveValue("one\ntwo"); + expect(events).toEqual([]); + }); + + it("keeps editable table row actions beside contributed commands", async () => { + const events: ViewEvent[] = []; + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Value"], + rows: [["Editable"]], + totalRows: 1, + firstRow: 0, + editable: true, + sortable: false, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + [{ label: "Inspect editable", run: () => {} }]} /> + , + ); + const cell = await screen.findByText("Editable"); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(screen.getByText("Inspect editable")).toBeInTheDocument(); + expect(screen.getByText("Insert Row Above")).toBeInTheDocument(); + await user.click(screen.getByText("Delete Row")); + expect(events).toContainEqual({ kind: "deleteRows", rows: [0] }); + hideContextMenu(); + }); + + it("uses absolute row indices after paging and supports keyboard context menus", async () => { + const events: ViewEvent[] = []; + const itemMenu = vi.fn(() => [{ label: "Inspect paged row", run: () => {} }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/paged"]], + totalRows: 100, + firstRow: 50, + editable: false, + sortable: true, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const grid = await screen.findByRole("grid"); + fireEvent.scroll(grid, { target: { scrollTop: 50 * 24 } }); + const cell = await screen.findByText("/paged"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 50 }); + (cell.closest('[role="row"]') as HTMLElement).focus(); + await user.keyboard("{Enter}{Shift>}{F10}{/Shift}"); + expect(events).toContainEqual({ kind: "tableActivate", row: 50 }); + expect(itemMenu).toHaveBeenCalledWith("50"); + expect(screen.getByText("Inspect paged row")).toBeInTheDocument(); + hideContextMenu(); + }); + + it.each([false, true])( + "sends absolute row events and contributed context commands (sortable=%s)", + async (sortable) => { + const events: ViewEvent[] = []; + const menuRun = vi.fn(); + const itemMenu = vi.fn((key: string) => [{ label: `Inspect ${key}`, run: menuRun }]); + setPluginsBackend( + backendWith( + () => ({ + toolbar: [], + status: null, + body: { + kind: "table", + value: { + columns: ["Route"], + rows: [["/users"], ["/posts"]], + totalRows: 2, + firstRow: 0, + editable: false, + sortable, + sort: null, + }, + }, + }), + events, + ), + ); + const user = userEvent.setup(); + render( + <> + + + , + ); + const cell = await screen.findByText("/posts"); + await user.click(cell); + expect(events).toContainEqual({ kind: "tableSelect", row: 1 }); + expect(cell.closest('[role="row"]')).toHaveAttribute("aria-selected", "true"); + await user.dblClick(cell); + expect(events).toContainEqual({ kind: "tableActivate", row: 1 }); + const row = cell.closest('[role="row"]') as HTMLElement; + row.focus(); + await user.keyboard("{Enter} "); + expect(events.filter((e) => e.kind === "tableActivate")).toHaveLength(2); + await user.pointer({ target: cell, keys: "[MouseRight]" }); + expect(itemMenu).toHaveBeenCalledWith("1"); + await user.click(screen.getByText("Inspect 1")); + expect(menuRun).toHaveBeenCalledOnce(); + expect(screen.queryByText("Insert Row Above")).not.toBeInTheDocument(); + hideContextMenu(); + }, + ); +}); diff --git a/src/features/plugins/views/PluginView.module.css b/src/features/plugins/views/PluginView.module.css index d53ff8b24..b8f4c443a 100644 --- a/src/features/plugins/views/PluginView.module.css +++ b/src/features/plugins/views/PluginView.module.css @@ -226,3 +226,22 @@ width: var(--h-floating-toolbar-button); height: var(--h-floating-toolbar-button); } + +.readonlyValue { + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} + +/* Selectable, but framed like a disabled field so it doesn't read as editable. */ +.readonlyField:read-only { + border: 0; + border-radius: var(--radius-control, 4px); + background: transparent; + box-shadow: inset 0 0 0 1px var(--border); +} + +.tableRow:focus-visible { + outline: 1px solid var(--focus-ring); + outline-offset: -1px; +} diff --git a/src/features/plugins/views/PluginView.tsx b/src/features/plugins/views/PluginView.tsx index b92909f5b..669b2cb36 100644 --- a/src/features/plugins/views/PluginView.tsx +++ b/src/features/plugins/views/PluginView.tsx @@ -16,6 +16,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import clsx from "clsx"; import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings"; import { tx, VerbatimTextContext } from "@/platform/i18n"; import { @@ -46,7 +47,7 @@ export interface PluginViewProps { revision?: number; /** Called after an event was handled (custom editors re-render on the document instead). */ label?: string; - /** Context menu entries of a tree or list item, by item key. */ + /** Context menu entries by item key (tables use the absolute row index). */ itemMenu?: (key: string) => MenuItem[]; } @@ -203,7 +204,7 @@ function Body({ content, send, pluginId, view, onViewport, itemMenu }: BodyProps ); case "table": - return ; + return ; case "html": return ; } @@ -283,6 +284,17 @@ function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => {field.label} )} + {v.kind === "label" && ( + + {v.value} + + )} + {v.kind === "readonlyText" && + (v.value.multiline ? ( +