diff --git a/Cargo.lock b/Cargo.lock index b73037d5..61acdb2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,7 +4380,6 @@ dependencies = [ "tokio", "toml 0.9.12+spec-1.1.0", "tracing", - "which", ] [[package]] @@ -4490,7 +4489,6 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", - "which", "zip", ] diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 350f1455..e9b91f52 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -314,8 +314,8 @@ impl AgentManager { } /// The slash commands to complete for `provider`, in `task`'s directory or (without one) - /// in the project: what the agent offered last time there, plus, for Claude Code, the - /// skills and custom commands on disk it has not listed. + /// in the project: what the agent offered last time there, unadvertised builtins, + /// and, for Claude Code, skills and custom commands on disk it has not listed. pub async fn commands(&self, provider: &str, task: Option<&str>) -> IdeResult> { let (provider, offered, cwd) = match task { Some(id) => { @@ -325,6 +325,14 @@ impl AgentManager { } None => (provider.to_owned(), self.shared.known_commands(provider), self.shared.paths.project_root.clone()), }; + // Commands the agent implements itself and never lists (its CLI builtins): + // merged without duplicating what `availableCommands` or the disk offer. + let mut offered = offered; + for &(name, description) in builtin_commands(&provider) { + if !offered.iter().any(|c| c.name == name) { + offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None }); + } + } if provider != "claude" && provider != "claude-acp" { return Ok(offered); } @@ -539,6 +547,10 @@ impl AgentManager { o.current = Some(value); } s.task.model = model_of(&s.task.config).or(s.task.model.take()); + let provider = s.task.provider.clone(); + let config = s.task.config.clone(); + drop(s); + task.shared.remember(&provider, Some(&config), None); } task.approve_pending(); task.persist(); @@ -1203,7 +1215,6 @@ impl Task { (if known { reported } else { s.task.config.clone() }, s.wanted.clone()) }; config::add_client_options(&provider_id, &mut options); - self.shared.remember(&provider_id, Some(&options), None); for choice in &wanted { // An option the session doesn't offer yet is set once it does (`apply_late_choices`). let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; @@ -1232,6 +1243,7 @@ impl Task { Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value), } } + self.shared.remember(&provider_id, Some(&options), None); { let mut s = self.state.lock(); s.task.model = model_of(&options).or(s.task.model.take()); @@ -1649,6 +1661,16 @@ fn model_of(config: &[AgentConfigOption]) -> Option { config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) } +/// Commands the agent implements on its own and never lists in `availableCommands` — +/// its CLI builtins, passed through as prompt text (e.g. `/compact`). `(name, description)`. +fn builtin_commands(provider: &str) -> &'static [(&'static str, &'static str)] { + match provider { + // OpenCode ACP handles `/compact` via session.summarize but omits it from its command list. + "opencode" => &[("compact", "compact the conversation")], + _ => &[], + } +} + fn file_name(path: &Path) -> String { path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()) } diff --git a/crates/ide-agent/src/native/mod.rs b/crates/ide-agent/src/native/mod.rs index 10f11309..665e9a04 100644 --- a/crates/ide-agent/src/native/mod.rs +++ b/crates/ide-agent/src/native/mod.rs @@ -124,4 +124,24 @@ mod tests { assert_eq!(newest_install_in("nope", &search), "nope"); assert_eq!(newest_install_in("bin/claude", &search), "bin/claude"); } + + #[cfg(unix)] + #[test] + fn newest_install_compares_versions_across_path_entries() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("old"); + let new = dir.path().join("new"); + for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] { + std::fs::create_dir_all(path).unwrap(); + let executable = path.join("agent"); + std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + for paths in [[&old, &new], [&new, &old]] { + let search = std::env::join_paths(paths).unwrap(); + assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy()); + } + } } diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 6de90d78..c9bef601 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -335,7 +335,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) { /// HTTPS only, redirects included. Tests serve downloads over `file://`. fn http() -> IdeResult { - let http = Http::new(&ide_tools::default_search_path())?; + let http = Http::new(&ide_core::tool_path::search_path())?; Ok(if cfg!(test) { http } else { http.https_only() }) } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 20cfd546..21215f84 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -210,7 +210,7 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { let names = |c: Vec| c.into_iter().map(|c| c.name).collect::>(); let mgr_a = manager_at(&a, &models, Arc::new(FakeAgent::default())); - // Before any session: the skills on disk. + // Before any session: only the skills on disk. assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]); let task = mgr_a .create(NewAgentTask { @@ -239,6 +239,42 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { assert_eq!(names(mgr_a.providers().into_iter().find(|p| p.id == "claude").unwrap().commands), ["review"]); } +#[tokio::test(flavor = "multi_thread")] +async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager_at(tmp.path(), &tmp.path().join("models.json"), agent); + for provider in ["claude", "claude-acp", "opencode2", "codex", "custom"] { + assert!(mgr.commands(provider, None).await.unwrap().is_empty(), "{provider}"); + } + let commands = mgr.commands("opencode", None).await.unwrap(); + assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::>(), ["compact"]); + let task = mgr + .create(NewAgentTask { + prompt: text("Hello"), + provider: "opencode".into(), + config: Vec::new(), + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let advertised = AgentCommand { + name: "compact".into(), + description: "Agent supplied description".into(), + hint: Some("instructions".into()), + }; + mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()]; + // The task's provider wins over the caller's provider; metadata is not replaced. + let commands = mgr.commands("claude", Some(&task.id)).await.unwrap(); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].name, advertised.name); + assert_eq!(commands[0].description, advertised.description); + assert_eq!(commands[0].hint, advertised.hint); + mgr.shutdown(); +} + async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { for _ in 0..500 { let task = mgr.task(id).unwrap(); diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 1b58ba68..fcb39a1f 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod remote_settings; pub mod settings; pub mod task; pub mod text; +pub mod tool_path; pub mod version_managers; pub mod workflow_settings; diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs new file mode 100644 index 00000000..6abe16a4 --- /dev/null +++ b/crates/ide-core/src/tool_path.rs @@ -0,0 +1,193 @@ +//! Where the IDE looks up the executables it runs: language servers, the tools +//! their settings come from, installers. An app started from the desktop gets a +//! minimal `PATH`, and version managers set theirs up in shell rc files. + +use crate::process::{host_var_os, login_env}; +use crate::version_managers; +use std::collections::HashSet; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +/// Where executables are looked up: this process's `PATH` (the login shell's in the +/// app), the login shell's `PATH` as last read, then per-user directories and mise's, +/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server +/// launchers, their settings hooks and tool detection all look here, so a settings +/// hook finds the executable its server is launched from. +pub fn search_path() -> OsString { + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); + let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())); + let shims: Vec = version_managers::managers().iter().map(|m| m.shims()).collect(); + let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims); + std::env::join_paths(dirs).unwrap_or_default() +} + +/// Resolves a command on the shared path, or an explicit path relative to `root`. +pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option { + let path = Path::new(command); + let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty()); + let pathext = host_var_os("PATHEXT"); + let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str)); + let dirs = if explicit { + vec![root.to_path_buf()] + } else { + std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect() + }; + dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p))) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { return false }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + true +} + +/// [`search_path`]'s directories, in order and once each. Only absolute ones: a +/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which +/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a +/// directory name) is left out rather than failing the whole path. +fn search_dirs( + process_path: Option<&OsStr>, + shell_path: Option<&OsStr>, + home: Option<&Path>, + shims: &[PathBuf], +) -> Vec { + let mut dirs: Vec = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect(); + if let Some(home) = home { + dirs.extend(USER_DIRS.iter().map(|d| home.join(d))); + dirs.extend(nvm_bins(&home.join(".nvm/versions/node"))); + } + // Version managers' shims after real tool directories: a shim exists for a command any + // installed version has, and fails when the selected one lacks it. Before the system's, + // as the managers' shell setup puts them. + dirs.extend(home.map(|h| h.join(".pyenv/shims"))); + dirs.extend(shims.iter().cloned()); + dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from)); + let mut seen = HashSet::new(); + dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone())); + dirs +} + +/// Tool directories below the home directory. +const USER_DIRS: &[&str] = &[ + ".cargo/bin", + ".local/bin", + "go/bin", + ".bun/bin", + ".deno/bin", + ".npm-global/bin", + ".volta/bin", + // Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS). + ".local/share/fnm/aliases/default/bin", + "Library/Application Support/fnm/aliases/default/bin", + ".fnm/aliases/default/bin", + ".local/share/pnpm/bin", + ".local/share/pnpm", + "Library/pnpm/bin", + "Library/pnpm", +]; + +const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"]; + +/// The `bin` directories of nvm's Node versions in `dir`, newest first. +fn nvm_bins(dir: &Path) -> Vec { + let mut versions: Vec<(Vec, PathBuf)> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin"))) + .collect(); + versions.sort(); + versions.into_iter().rev().map(|(_, bin)| bin).collect() +} + +/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9. +fn version_key(name: &str) -> Vec { + name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_resolve_on_path_and_explicit_paths_under_root() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" }; + std::fs::write(bin.join(name), "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let search = std::env::join_paths([&bin]).unwrap(); + assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command("absent", &search, dir.path()), None); + for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] { + assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name))); + } + } + + #[cfg(unix)] + #[test] + fn non_executable_files_do_not_shadow_commands() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dirs = [tmp.path().join("first"), tmp.path().join("second")]; + for (dir, mode) in dirs.iter().zip([0o644, 0o755]) { + std::fs::create_dir(dir).unwrap(); + let file = dir.join("ngserver"); + std::fs::write(&file, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let path = std::env::join_paths(&dirs).unwrap(); + assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver"))); + assert_eq!(find_command("first/ngserver", &path, tmp.path()), None); + } + + #[cfg(unix)] + #[test] + fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() { + let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()]; + let dirs = search_dirs( + Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")), + // nushell prints `$PATH` as is. + Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")), + Some(Path::new("/home/me")), + &shims, + ); + let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap(); + assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]); + assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims")); + assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin")); + assert!(dirs.iter().all(|d| d.is_absolute())); + let unique: HashSet<&PathBuf> = dirs.iter().collect(); + assert_eq!(unique.len(), dirs.len()); + // A `:` can't be in a `PATH` entry: those below such a home are left out. + let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]); + assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home"))); + } + + #[test] + fn nvm_versions_newest_first() { + let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id())); + for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] { + std::fs::create_dir_all(dir.join(v).join("bin")).unwrap(); + } + let bins = nvm_bins(&dir); + std::fs::remove_dir_all(&dir).unwrap(); + let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect(); + assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]); + } +} diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 317f9319..ed05a90b 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -21,7 +21,6 @@ futures.workspace = true globset.workspace = true toml.workspace = true percent-encoding = "2" -which = "8" [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 782df3b8..3343763d 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -1,11 +1,12 @@ //! Starting server processes: binary resolution and the [`Launcher`] seam. use ide_core::CancellationToken; +use ide_core::tool_path::search_path; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; #[derive(Debug)] pub enum LaunchError { @@ -72,8 +73,7 @@ impl Launcher for ProcessLauncher { cancel: &CancellationToken, ) -> Result { let search = server_search_path(config); - let binary = which::which_in(&config.command, Some(&search), root) - .ok() + let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); @@ -95,7 +95,7 @@ impl Launcher for ProcessLauncher { } fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool { - which::which_in(&config.command, Some(server_search_path(config)), root).is_ok() + ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some() } } @@ -104,9 +104,6 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path) } -/// `PATH` plus the login shell's `PATH` and common tool directories, so -/// servers resolve even when the app is launched from a GUI shell with a -/// minimal environment. /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use /// the newest installed toolchain that ships the binary; with none, the tool is @@ -153,74 +150,6 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option { } } -pub fn search_path() -> OsString { - static PATH: OnceLock = OnceLock::new(); - PATH.get_or_init(|| { - let mut dirs: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(shell_path) = login_shell_path() { - dirs.extend(std::env::split_paths(&shell_path)); - } - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".bun/bin", ".deno/bin", ".npm-global/bin", ".volta/bin"] - { - dirs.push(home.join(rel)); - } - // nvm: newest installed node's bin. - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = std::collections::HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() - }) - .clone() -} - -#[cfg(unix)] -fn login_shell_path() -> Option { - use std::io::Read; - use std::process::{Command, Stdio}; - use std::time::{Duration, Instant}; - let shell = std::env::var_os("SHELL")?; - const MARK: &str = "__OPENIDEA_PATH__"; - let mut child = ide_core::process::prepare(&mut Command::new(shell)) - .args(["-l", "-c", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - let deadline = Instant::now() + Duration::from_secs(3); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), - _ => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - } - let mut out = String::new(); - child.stdout.take()?.read_to_string(&mut out).ok()?; - let start = out.find(MARK)? + MARK.len(); - let end = start + out[start..].find(MARK)?; - Some(OsString::from(&out[start..end])) -} - -#[cfg(not(unix))] -fn login_shell_path() -> Option { - None -} - /// Workspace root for `file`: the outermost ancestor within `project_root` /// containing a root marker (so a Cargo/npm workspace gets one server, not one /// per member), else `project_root`. `None` when `file` is outside the project. @@ -258,6 +187,19 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[test] + fn marked_root_needs_a_marker() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("apps/web/src/app.ts"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()]; + assert_eq!(marked_root(root, &file, &markers), None); + std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap(); + assert_eq!(marked_root(root, &file, &markers), Some(root.join("apps/web"))); + assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None); + } + #[test] fn search_path_contains_env_path() { let p = search_path(); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 785a1d79..403a8219 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -24,7 +24,7 @@ mod uri; mod virtual_doc; pub use inspect::{FileInspection, InspectHost, InspectOptions}; -pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path}; +pub use launch::{LaunchError, Launcher, ProcessLauncher}; pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES}; pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; @@ -1921,9 +1921,22 @@ impl LspManager { /// Servers no longer enabled stop; newly enabled ones attach to open documents. pub async fn reconfigure(&self) { self.stop_unlisted().await; + let Some(me) = self.me.upgrade() else { return }; let servers: Vec> = self.servers.lock().clone(); for s in servers { - let next = self.configured(&s); + // The settings hook may run tools (`npm root -g`), as when the server starts. + let next = match tokio::task::spawn_blocking({ + let (me, s) = (me.clone(), s.clone()); + move || me.configured(&s) + }) + .await + { + Ok(next) => next, + Err(e) => { + tracing::warn!("{}: settings hook failed: {e}", s.name); + continue; + } + }; let current = s.effective_config(); if next == current { continue; diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index d4225ada..8d9a24e7 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -17,7 +17,6 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true tracing.workspace = true -which = "8" zip.workspace = true [dev-dependencies] diff --git a/crates/ide-sdk/src/angular.rs b/crates/ide-sdk/src/angular.rs index d7566418..54c5591a 100644 --- a/crates/ide-sdk/src/angular.rs +++ b/crates/ide-sdk/src/angular.rs @@ -1,15 +1,18 @@ //! Angular Language Server (`ngserver`): its package, where TypeScript and //! `@angular/language-service` come from, and the project's Angular version. -use crate::npm::package_dir; +use crate::npm::{package_dir, shim_package}; +use std::collections::BTreeMap; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; pub(crate) const SERVER: &str = "angular-language-server"; const PACKAGE: &str = "@angular/language-server"; -/// `@angular/language-server`'s directory for an `ngserver` executable. -pub(crate) fn server_package(bin: &Path) -> Option { - package_dir(bin, PACKAGE) +/// `@angular/language-server`'s directory for an `ngserver` executable found on +/// `path` and run in `cwd` with `env`. +pub(crate) fn server_package(bin: &Path, path: &OsStr, cwd: &Path, env: &BTreeMap) -> Option { + package_dir(bin, PACKAGE).or_else(|| shim_package(bin, PACKAGE, path, cwd, env)) } /// Why an `ngserver` whose package isn't found can't start: it loads TypeScript diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index acd00692..527e1924 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -519,8 +519,8 @@ fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_ fn configure_angular(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { // Found as the launcher finds it; missing, it asks to be installed. let path = cfg.env.get("PATH").map_or_else(search_path, OsString::from); - let Ok(bin) = which::which_in(&cfg.command, Some(path), server_root) else { return }; - let Some(package) = angular::server_package(&bin) else { + let Some(bin) = ide_core::tool_path::find_command(&cfg.command, &path, server_root) else { return }; + let Some(package) = angular::server_package(&bin, &path, server_root, &cfg.env) else { cfg.launch_error = Some(angular::missing_package_message(&bin)); return; }; diff --git a/crates/ide-sdk/src/exec.rs b/crates/ide-sdk/src/exec.rs index 03333c50..aef10b05 100644 --- a/crates/ide-sdk/src/exec.rs +++ b/crates/ide-sdk/src/exec.rs @@ -3,35 +3,13 @@ use ide_core::output_encoding::decode_tool_output; use ide_core::{CancellationToken, IdeError, IdeResult}; -use std::ffi::OsString; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -/// The process `PATH` (already the login shell's in the app) plus common tool dirs. -pub fn search_path() -> OsString { - let mut dirs: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(home) = crate::home_dir() { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".pyenv/shims", ".bun/bin", ".npm-global/bin", ".volta/bin"] { - dirs.push(home.join(rel)); - } - // nvm: newest installed node's bin, as language servers are launched. - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = std::collections::HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() -} +pub(crate) use ide_core::tool_path::search_path; /// `dir` in front of the search path, as a `PATH` value. pub(crate) fn path_with(dirs: &[&Path]) -> String { @@ -45,7 +23,7 @@ pub(crate) fn path_with(dirs: &[&Path]) -> String { /// Resolves a command name on the search path. pub(crate) fn which(name: &str) -> Option { let path = search_path(); - std::env::split_paths(&path).map(|d| d.join(crate::exe(name))).find(|p| p.is_file()) + ide_core::process::find_executable(name, &path) } /// Runs `program args` and returns trimmed stdout (stderr when stdout is empty) @@ -66,27 +44,41 @@ pub(crate) fn query_in( if let Some(cwd) = cwd { cmd.current_dir(cwd); } + if !env.iter().any(|(k, _)| *k == "PATH") { + cmd.env("PATH", search_path()); + } let mut child = ide_core::process::prepare(&mut cmd) .args(args) - .env("PATH", search_path()) .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .ok()?; - let mut stdout = child.stdout.take()?; - let mut stderr = child.stderr.take()?; - let out = std::thread::spawn(move || { - let mut s = Vec::new(); - let _ = stdout.read_to_end(&mut s); - decode_tool_output(&s) - }); - let err = std::thread::spawn(move || { - let mut s = Vec::new(); - let _ = stderr.read_to_end(&mut s); - decode_tool_output(&s) - }); + // Chunks as they come, `None` at the end of a pipe. A reader stops once the query + // has returned, closing the pipe on whatever still writes to it. + let (tx, rx) = mpsc::channel::<(bool, Option>)>(); + let pipes: [(bool, Box); 2] = + [(true, Box::new(child.stdout.take()?)), (false, Box::new(child.stderr.take()?))]; + for (is_out, mut pipe) in pipes { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + let chunk = match pipe.read(&mut buf) { + Ok(0) => None, + Ok(n) => Some(buf[..n].to_vec()), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => None, + }; + let end = chunk.is_none(); + if tx.send((is_out, chunk)).is_err() || end { + return; + } + } + }); + } + drop(tx); let deadline = Instant::now() + timeout; let status = loop { match child.try_wait() { @@ -99,12 +91,27 @@ pub(crate) fn query_in( } } }; - let (out, err) = (out.join().unwrap_or_default(), err.join().unwrap_or_default()); if !status.success() { return None; } - let text = if out.trim().is_empty() { err } else { out }; - Some(text.trim().to_owned()) + // Up to the deadline, or briefly past it for a program that exited just before: + // a process it left running (a wrapper's background job) can hold the pipes + // open, or keep writing to them, after it exits. + let until = deadline.max(Instant::now() + Duration::from_millis(100)); + let (mut out, mut err, mut open) = (Vec::new(), Vec::new(), 2); + while open > 0 { + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + match rx.recv_timeout(left) { + Ok((is_out, Some(chunk))) => if is_out { &mut out } else { &mut err }.extend(chunk), + Ok((_, None)) => open -= 1, + Err(_) => break, + } + } + let text = if out.trim_ascii().is_empty() { err } else { out }; + Some(decode_tool_output(&text).trim().to_owned()) } /// Runs a command to completion, reporting every output line (stdout and diff --git a/crates/ide-sdk/src/godot.rs b/crates/ide-sdk/src/godot.rs index 31999cbe..bdc888da 100644 --- a/crates/ide-sdk/src/godot.rs +++ b/crates/ide-sdk/src/godot.rs @@ -23,7 +23,7 @@ pub fn find(custom: Option) -> Option { if let Some(p) = custom { return Some(p); } - find_in(&crate::search_path(), &install_locations(crate::home_dir().as_deref())) + find_in(&crate::exec::search_path(), &install_locations(crate::home_dir().as_deref())) } pub(crate) fn find_in(search: &OsStr, locations: &[PathBuf]) -> Option { diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 66ebdbbf..3d178c62 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -25,7 +25,6 @@ use specta::Type; use std::path::{Path, PathBuf}; pub use discover::{detect, jdk_sdk}; -pub use exec::search_path; /// What [`detect`] looks for. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-sdk/src/npm.rs b/crates/ide-sdk/src/npm.rs index 15a58ec2..89c94edb 100644 --- a/crates/ide-sdk/src/npm.rs +++ b/crates/ide-sdk/src/npm.rs @@ -1,7 +1,9 @@ //! Node-based language servers installed with npm: the package behind a command. +use std::collections::BTreeMap; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::time::Duration; /// `command` as given (a path) or found on `path`, npm shims included. pub(crate) fn find_command(command: &str, path: &OsStr) -> Option { @@ -9,15 +11,13 @@ pub(crate) fn find_command(command: &str, path: &OsStr) -> Option { if p.is_absolute() { return p.is_file().then(|| p.to_path_buf()); } - let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; - std::env::split_paths(path) - .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) - .find(|c| c.is_file()) + ide_core::process::find_executable(command, path) } /// The npm package an executable comes from: next to a `node_modules/.bin` shim /// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a -/// Unix global `/bin` or a symlink into the package. +/// Unix global `/bin`, a symlink into the package, Volta's package image +/// or the package a shim script runs (pnpm). pub(crate) fn package_dir(bin: &Path, package: &str) -> Option { let dir = bin.parent()?; let mut candidates = vec![dir.join("node_modules").join(package)]; @@ -31,6 +31,197 @@ pub(crate) fn package_dir(bin: &Path, package: &str) -> Option { } if let Some(prefix) = dir.parent() { candidates.push(prefix.join("lib").join("node_modules").join(package)); + // Volta: `/bin/` runs `/tools/image/packages/`. + let image = prefix.join("tools").join("image").join("packages").join(package); + candidates.push(image.join("lib").join("node_modules").join(package)); + candidates.push(image.join("node_modules").join(package)); + } + let installed = |c: &PathBuf| c.join("package.json").is_file(); + candidates.into_iter().find(installed).or_else(|| script_targets(bin, package).into_iter().find(installed)) +} + +/// How shim scripts name their own directory: sh and PowerShell `$basedir` (pnpm +/// 11's sh shims also `$basedir_abs` and `$basedir_win`), cmd `%~dp0` (pnpm's +/// `%dp0%` is set from it). +const SCRIPT_DIR: [&str; 8] = + ["${basedir}", "$basedir", "${basedir_abs}", "$basedir_abs", "${basedir_win}", "$basedir_win", "%~dp0", "%dp0%"]; + +/// `path` after the [`SCRIPT_DIR`] it starts with. sh reads `$basedir_abs` as one +/// name, not as `$basedir` followed by `_abs`. +fn after_script_dir(path: &str) -> Option<&str> { + SCRIPT_DIR.iter().find_map(|d| { + let rest = path.strip_prefix(d)?; + let longer_name = d.starts_with('$') + && !d.ends_with('}') + && rest.starts_with(|c: char| c == '_' || c.is_ascii_alphanumeric()); + (!longer_name).then_some(rest) + }) +} + +/// The `package` directories a shim script runs from: relative to the script's +/// directory, e.g. pnpm's `"$basedir/../global/v11//node_modules//bin/"` +/// or `"%~dp0\..\global\..."`, or absolute. +fn script_targets(bin: &Path, package: &str) -> Vec { + const MAX_SCRIPT: u64 = 64 * 1024; + if std::fs::metadata(bin).map_or(true, |m| m.len() > MAX_SCRIPT) { + return Vec::new(); + } + let Ok(bytes) = std::fs::read(bin) else { return Vec::new() }; + let text = String::from_utf8_lossy(&bytes).replace('\\', "/"); + let bases: Vec = [ + bin.parent().map(Path::to_path_buf), + dunce::canonicalize(bin).ok().and_then(|r| r.parent().map(Path::to_path_buf)), + ] + .into_iter() + .flatten() + .collect(); + let needle = format!("node_modules/{package}"); + // What sh and cmd split words on; a folder name may contain a no-break space. + let separator = |c: char| c == '"' || c == '\'' || c.is_ascii_whitespace(); + let mut targets = Vec::new(); + for (at, _) in text.match_indices(&needle) { + let end = at + needle.len(); + if !text[end..].chars().next().is_none_or(|c| c == '/' || separator(c)) { + continue; + } + // Within quotes the path starts at the opening one and may contain spaces. + let line = text[..at].rfind('\n').map_or(0, |i| i + 1); + let start = match ['"', '\''].into_iter().find(|q| text[line..at].matches(*q).count() % 2 == 1) { + Some(quote) => text[..at].rfind(quote), + None => text[..at].rfind(separator), + } + .map_or(0, |i| i + 1); + let path = &text[start..end]; + // `"$basedir"/../...`: the directory quoted on its own. + let after_dir = text[..start] + .strip_suffix(['"', '\'']) + .is_some_and(|before| SCRIPT_DIR.iter().any(|d| before.ends_with(d))); + match after_script_dir(path).or(after_dir.then_some(path)) { + Some(rest) => targets.extend(bases.iter().map(|b| b.join(rest.trim_start_matches('/')))), + None if Path::new(path).is_absolute() => targets.push(PathBuf::from(path)), + // `$HOME/...`, `%APPDATA%\...` or relative to wherever the script runs. + None => {} + } + } + targets.into_iter().map(|t| dunce::canonicalize(&t).unwrap_or(t)).collect() +} + +/// For a version manager's shim (`~/.asdf/shims`, mise's `shims`), which names +/// only the command: `package` in the global `node_modules` of the manager's +/// npm shim, run as the server is (in `cwd`, with its `env` and `path`), so the +/// manager picks the same Node. The shims go first on `path`, as the manager's +/// shell setup puts them: npm (`#!/usr/bin/env node`) then runs on the manager's +/// Node, not on a system one earlier on `path` that has another global root. +pub(crate) fn shim_package( + bin: &Path, + package: &str, + path: &OsStr, + cwd: &Path, + env: &BTreeMap, +) -> Option { + let shims = bin.parent()?; + if shims.file_name() != Some(OsStr::new("shims")) { + return None; + } + // Only the npm shim next to it: any other npm may be another installation's. + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe"] } else { &[""] }; + let npm = exts.iter().map(|e| shims.join(format!("npm{e}"))).find(|p| p.is_file())?; + let path = std::env::join_paths(std::iter::once(shims.to_path_buf()).chain(std::env::split_paths(path))).ok()?; + // The login shell's variables (`ASDF_DATA_DIR`, `MISE_ENV`...) under the server's own. + let shell_env: Vec<_> = ide_core::process::shell_env().collect(); + let mut env: Vec<(&str, &str)> = shell_env + .iter() + .filter_map(|(k, v)| Some((k.to_str()?, v.to_str()?))) + .chain(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .collect(); + env.push(("PATH", path.to_str()?)); + let root = crate::exec::query_in(&npm, &["root", "-g"], Some(cwd), &env, Duration::from_secs(10))?; + let dir = PathBuf::from(root.lines().last()?.trim()).join(package); + dir.join("package.json").is_file().then_some(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACKAGE: &str = "@angular/language-server"; + + /// `/bin/ngserver` running `script`. + fn shim(dir: &Path, script: &str) -> PathBuf { + std::fs::create_dir_all(dir.join("bin")).unwrap(); + let shim = dir.join("bin/ngserver"); + std::fs::write(&shim, script).unwrap(); + shim + } + + /// The package installed in `/node_modules`. + fn install(dir: &Path) -> PathBuf { + let package = dir.join("node_modules").join(PACKAGE); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), "{}").unwrap(); + dunce::canonicalize(package).unwrap() + } + + #[test] + fn shim_scripts_name_the_package_relative_to_their_directory() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("global/v11/abc")); + for script in [ + "#!/bin/sh\nexec node \"$basedir/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"${basedir}/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"$basedir\"/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver \"$@\"\n", + // pnpm 11. + "#!/bin/sh\nexec \"$basedir/node\" \"$basedir_abs/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"${basedir_abs}/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node.exe \"$basedir_win/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "@\"%~dp0\\..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + "@\"%~dp0..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + "@SET dp0=%~dp0\r\n\"%dp0%\\..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + ] { + assert_eq!(package_dir(&shim(tmp.path(), script), PACKAGE), Some(package.clone()), "{script}"); + } + } + + #[test] + fn quoted_shim_targets_keep_their_spaces() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("Global Packages/abc")); + let absolute = package.display().to_string(); + for script in [ + "#!/bin/sh\nexec node \"$basedir/../Global Packages/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n" + .to_owned(), + "@\"%~dp0\\..\\Global Packages\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n".to_owned(), + format!("#!/bin/sh\nexec node \"{absolute}/bin/ngserver\" \"$@\"\n"), + format!("#!/bin/sh\nexec node '{absolute}/bin/ngserver' \"$@\"\n"), + ] { + assert_eq!(package_dir(&shim(tmp.path(), &script), PACKAGE), Some(package.clone()), "{script}"); + } + } + + #[test] + fn shim_scripts_with_an_absolute_path_and_non_ascii_spaces() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("山田\u{3000}太郎\u{a0}x")); + let script = format!("#!/bin/sh\nexec node \"{}/bin/ngserver\" \"$@\"\n", package.display()); + assert_eq!(package_dir(&shim(tmp.path(), &script), PACKAGE), Some(package)); + } + + #[test] + fn shim_scripts_skip_other_packages_and_other_directories() { + let tmp = tempfile::tempdir().unwrap(); + install(&tmp.path().join("home")); + for script in [ + "exec node \"$basedir/../home/node_modules/@angular/language-server-next/bin/ngserver\"\n", + "exec node \"$HOME/node_modules/@angular/language-server/bin/ngserver\"\n", + "exec node \"$basedirx/../home/node_modules/@angular/language-server/bin/ngserver\"\n", + "exec node ../home/node_modules/@angular/language-server/bin/ngserver\n", + ] { + assert_eq!(script_targets(&shim(tmp.path(), script), PACKAGE), Vec::::new(), "{script}"); + } + let large = format!( + "{}\n\"$basedir/../home/node_modules/@angular/language-server/bin/ngserver\"\n", + "#".repeat(64 * 1024) + ); + assert_eq!(script_targets(&shim(tmp.path(), &large), PACKAGE), Vec::::new()); } - candidates.into_iter().find(|c| c.join("package.json").is_file()) } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 92fda6da..7af792e0 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -673,6 +673,160 @@ fn angular_language_server_probes_its_own_package_then_the_project() { assert_eq!((cfg.launch_error, cfg.args), (None, vec!["--stdio".to_owned()])); } +#[test] +fn angular_language_server_is_found_behind_version_manager_shims() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + let install = |node_modules: &Path| { + let package = node_modules.join("@angular/language-server"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), "{}").unwrap(); + package + }; + let probes = |command: &Path, s: &Settings, env: &[(&str, &str)]| { + let mut cfg = server("angular-language-server"); + cfg.command = command.to_string_lossy().into_owned(); + cfg.env.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + configure_server(&mut cfg, &project, &project, s); + cfg.args.iter().position(|a| a == "--ngProbeLocations").map(|i| cfg.args[i + 1].clone()) + }; + let with_project = |package: &Path| Some(format!("{},{}", package.to_string_lossy(), project.to_string_lossy())); + + // pnpm: the shim script runs the package from pnpm's global directory. + let pnpm = tmp.path().join("pnpm"); + let package = install(&pnpm.join("global/v11/abc/node_modules")); + let shim = npm_shim(&pnpm.join("bin"), "ngserver"); + let script = "exec node \"$basedir/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n"; + std::fs::write(&shim, format!("#!/bin/sh\n{script}")).unwrap(); + assert_eq!(probes(&shim, &Settings::default(), &[]), with_project(&dunce::canonicalize(&package).unwrap())); + + // Volta: the package's image, next to the shim's directory. Joined as the lookup + // joins it: the probe locations compare as text, and Windows keeps a `/` as given. + let volta = tmp.path().join("volta"); + let image = volta.join("tools").join("image").join("packages").join("@angular/language-server"); + let package = install(&image.join("lib").join("node_modules")); + let shim = npm_shim(&volta.join("bin"), "ngserver"); + assert_eq!(probes(&shim, &Settings::default(), &[]), with_project(&package)); + + // asdf, mise: the shim names only the command; the manager's npm shim knows + // where global packages are, run as the server is: in the project (whose + // `.tool-versions` picks the Node), with the login shell's and its own variables. + let root_of = |package: &Path| package.parent().unwrap().parent().unwrap().display().to_string(); + let package = install(&tmp.path().join("asdf/installs/nodejs/24/lib/node_modules")); + std::fs::write(project.join(".tool-versions"), "nodejs 24\n").unwrap(); + // The only writer in this test binary. + ide_core::process::set_shell_env(vec![("OPENIDEA_TEST_SHELL".into(), "1".into())]); + let shims = tmp.path().join(".asdf/shims"); + // The manager's npm runs on the first Node on PATH (`#!/usr/bin/env node`). + std::fs::write( + npm_shim(&shims, "npm"), + if cfg!(windows) { + "@if exist .tool-versions if \"%OPENIDEA_TEST_SHELL%\"==\"1\" call node\r\n" + } else { + "#!/bin/sh\n[ -f .tool-versions ] && [ \"$OPENIDEA_TEST_SHELL\" = 1 ] && exec node\n" + }, + ) + .unwrap(); + std::fs::write( + npm_shim(&shims, "node"), + if cfg!(windows) { + "@echo %OPENIDEA_TEST_NPM_ROOT%\r\n" + } else { + "#!/bin/sh\necho \"$OPENIDEA_TEST_NPM_ROOT\"\n" + }, + ) + .unwrap(); + // Another installation's npm and Node, first on PATH. + let node = tmp.path().join("node/bin"); + let other = root_of(&install(&tmp.path().join("node/lib/node_modules"))); + for name in ["npm", "node"] { + std::fs::write( + npm_shim(&node, name), + if cfg!(windows) { format!("@echo {other}\r\n") } else { format!("#!/bin/sh\necho '{other}'\n") }, + ) + .unwrap(); + } + let mut s = Settings::default(); + s.languages.node.interpreter = node.join("node").to_string_lossy().into_owned(); + let root = root_of(&package); + let env = [("OPENIDEA_TEST_NPM_ROOT", root.as_str())]; + assert_eq!(probes(&npm_shim(&shims, "ngserver"), &s, &env), with_project(&package)); + // Not without the manager's own npm, nor for an executable elsewhere: the npm + // on PATH may be another installation's. + assert_eq!(probes(&npm_shim(&tmp.path().join("mise/shims"), "ngserver"), &s, &env), None); + assert_eq!(probes(&npm_shim(&tmp.path().join("bin"), "ngserver"), &s, &env), None); +} + +#[cfg(unix)] +#[test] +fn query_output_is_read_within_the_deadline() { + // Keep process startup outside a tight timing budget even under workspace load. + let timeout = std::time::Duration::from_secs(60); + let env = [("PATH", "/usr/bin:/bin")]; + let started = std::time::Instant::now(); + let out = crate::exec::query_in(Path::new("/bin/sh"), &["-c", "printf /global/root"], None, &env, timeout); + assert_eq!(out.as_deref(), Some("/global/root")); + // EOF must return promptly, without waiting for the generous deadline. + assert!(started.elapsed() < std::time::Duration::from_secs(30)); +} + +#[cfg(unix)] +#[test] +fn query_drains_both_pipes_before_the_child_exits() { + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_secs(60), + ) + .unwrap(); + assert_eq!(out.len(), 262144); +} + +#[cfg(unix)] +#[test] +fn query_is_bounded_when_descendants_keep_pipes_open() { + let tmp = tempfile::tempdir().unwrap(); + let pid = tmp.path().join("pid"); + // The descendant outlives the query; clean it up even if an assertion fails. + struct Descendant(std::path::PathBuf); + impl Drop for Descendant { + fn drop(&mut self) { + if let Ok(pid) = std::fs::read_to_string(&self.0) { + let _ = std::process::Command::new("/bin/kill").arg(pid.trim()).status(); + } + } + } + let _descendant = Descendant(pid.clone()); + let started = std::time::Instant::now(); + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "sleep 120 & echo $! > \"$1\"; printf /global/root", "wrapper", pid.to_str().unwrap()], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_secs(30), + ); + assert_eq!(out.as_deref(), Some("/global/root")); + // The descendant keeps both pipes open for longer than the query deadline. + assert!(started.elapsed() < std::time::Duration::from_secs(90)); +} + +#[cfg(unix)] +#[test] +fn query_times_out_when_the_child_does_not_exit() { + // exec avoids leaving a descendant behind when the timed-out child is killed. + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "printf /global/root; exec sleep 120"], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_millis(100), + ); + assert_eq!(out, None); +} + #[test] fn max_java_picks_an_older_jdk() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-tools/src/archive.rs b/crates/ide-tools/src/archive.rs index 11a36926..5318c5f7 100644 --- a/crates/ide-tools/src/archive.rs +++ b/crates/ide-tools/src/archive.rs @@ -121,13 +121,16 @@ fn extract_zip(archive: &Path, dest: &Path, cancel: &CancellationToken) -> IdeRe if let Some(parent) = out.parent() { fs::create_dir_all(parent)?; } - #[cfg(unix)] if file.is_symlink() { let mut target = String::new(); io::Read::read_to_string(&mut file, &mut target)?; validate_link(dest, &rel, Path::new(&target))?; let _ = fs::remove_file(&out); - std::os::unix::fs::symlink(target, &out)?; + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &out)?; + // Windows can't create symlinks without SeCreateSymbolicLinkPrivilege + // (os error 1314), so we drop in-archive links there rather than fail + // the whole extraction. Validation above still rejects escapes. continue; } let mut w = File::create(&out)?; @@ -485,6 +488,11 @@ mod tests { assert!(extract(&archive, ArchiveKind::Zip, &dest, "x", &CancellationToken::new()).is_err()); assert!(fs::symlink_metadata(dest.join("link")).is_err()); } + } + + #[cfg(unix)] + #[test] + fn zip_internal_symlink_is_created() { // A link inside the archive (e.g. a framework's Versions/Current) still extracts. let tmp = tempfile::tempdir().unwrap(); let archive = tmp.path().join("ok.zip"); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs index 279df197..b5bcf6db 100644 --- a/crates/ide-tools/src/jdk.rs +++ b/crates/ide-tools/src/jdk.rs @@ -448,7 +448,7 @@ mod tests { _ => None, }; if let Some(platform) = other { - let tools = Tools::with_paths(crate::tools_dir(tmp.path()), crate::default_search_path(), platform); + let tools = Tools::with_paths(crate::tools_dir(tmp.path()), ide_core::tool_path::search_path(), platform); let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); let home = jdk.home.into_path_buf(); eprintln!("{}", home.display()); diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index ccbeb890..e32d1002 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -174,28 +174,6 @@ pub fn path_with_tools(app_data: &Path, path: &std::ffi::OsStr) -> OsString { std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned()) } -/// `PATH` plus the usual per-user tool locations (GUI apps on macOS start with a minimal `PATH`). -pub fn default_search_path() -> OsString { - let mut dirs: Vec = - ide_core::process::host_var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".bun/bin", ".volta/bin", ".npm-global/bin", "Library/pnpm"] { - dirs.push(home.join(rel)); - } - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() -} - static INSTALLING: Mutex>> = Mutex::new(None); struct InstallGuard(String); @@ -331,12 +309,13 @@ pub struct Tools { custom: BTreeMap, /// Executables found outside the search path (app bundles), by command. found: BTreeMap, + version_timeout: Duration, } impl Tools { - /// Tools under `/tools` using [`default_search_path`]. + /// Tools under `/tools` using [`ide_core::tool_path::search_path`]. pub fn new(app_data: &Path) -> Self { - Self::with_paths(tools_dir(app_data), default_search_path(), Platform::current()) + Self::with_paths(tools_dir(app_data), ide_core::tool_path::search_path(), Platform::current()) } pub fn with_paths(root: PathBuf, search_path: OsString, platform: Platform) -> Self { @@ -344,7 +323,15 @@ impl Tools { let system: Vec = std::env::split_paths(&search_path).filter(|d| !ours.contains(d)).collect(); let system_path = std::env::join_paths(&system).unwrap_or_default(); let child_path = std::env::join_paths(ours.iter().chain(system.iter())).unwrap_or_default(); - Self { root, system_path, child_path, platform, custom: BTreeMap::new(), found: BTreeMap::new() } + Self { + root, + system_path, + child_path, + platform, + custom: BTreeMap::new(), + found: BTreeMap::new(), + version_timeout: Duration::from_secs(4), + } } /// Reports the executables chosen in settings (command → path) as the tools' location. @@ -364,7 +351,7 @@ impl Tools { } fn which(&self, cmd: &str) -> Option { - which::which_in(cmd, Some(&self.system_path), &self.root).ok() + ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } fn which_any(&self, cmds: &[&str]) -> Option { @@ -428,7 +415,7 @@ impl Tools { let program = sibling.or_else(|| self.which(cmd))?; let mut c = self.command(program); c.args(args); - let (ok, out) = run_quiet(c, Duration::from_secs(4))?; + let (ok, out) = run_quiet(c, self.version_timeout)?; if !ok { return None; } @@ -548,7 +535,7 @@ impl Tools { if cmd == spec.commands[0] { path.clone() } else { path.parent().and_then(|d| self.exe_in(d, cmd))? }; let mut c = self.command(program); c.args(args); - let (ok, out) = run_quiet(c, Duration::from_secs(4))?; + let (ok, out) = run_quiet(c, self.version_timeout)?; if ok { parse_version(&out) } else { None } }); info.status = ToolStatus::Custom; diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs index 3e3481c4..d62f2af6 100644 --- a/crates/ide-tools/src/server.rs +++ b/crates/ide-tools/src/server.rs @@ -2,7 +2,7 @@ //! version publishes one per supported host platform (see `release.yml`). use crate::process::Http; -use crate::{InstallEvent, default_search_path, fetch_verified, sources}; +use crate::{InstallEvent, fetch_verified, sources}; use ide_core::CancellationToken; use ide_core::{IdeError, IdeResult}; use std::path::{Path, PathBuf}; @@ -46,7 +46,7 @@ pub fn fetch( std::fs::create_dir_all(dir)?; let staging = tempfile_dir(dir)?; let result = (|| { - let http = Http::new(&default_search_path())?; + let http = Http::new(&ide_core::tool_path::search_path())?; // The binary runs on the remote host: never install it unverified. let checksum_url = format!("{url}.sha256"); let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name) diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index b9a2ec48..02226cff 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -24,7 +24,10 @@ impl Env { fn tools(&self) -> Tools { let mut dirs = vec![self.sys.clone()]; dirs.extend(bin_dirs_in(&self.root, Platform::current())); - Tools::with_paths(self.root.clone(), std::env::join_paths(dirs).unwrap(), Platform::current()) + let mut tools = Tools::with_paths(self.root.clone(), std::env::join_paths(dirs).unwrap(), Platform::current()); + // These fixtures test detection and version parsing, not process startup latency. + tools.version_timeout = Duration::from_secs(60); + tools } #[cfg(unix)] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 74edba5f..133e3b70 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -713,14 +713,26 @@ pub fn open_settings_window( .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; apply_zoom(app, &window); // Restore runs during build; fit its size to the parent's current display before showing. + #[cfg(target_os = "macos")] if let Err(e) = fit_settings_window(&window, &parent) { tracing::warn!("cannot fit settings window: {e}"); } + // Elsewhere the remembered size is kept and the position follows the parent. + #[cfg(not(target_os = "macos"))] + if let (Ok(pos), Ok(size), Ok(own), Ok(scale)) = + (parent.outer_position(), parent.outer_size(), window.outer_size(), parent.scale_factor()) + { + let (pos, size, own) = + (pos.to_logical::(scale), size.to_logical::(scale), own.to_logical::(scale)); + let at = centered(pos.x, pos.y, size.width, size.height, own.width, own.height); + let _ = window.set_position(tauri::LogicalPosition::new(at.0, at.1)); + } focus(&window); Ok(()) } /// Fit outer bounds, including native decorations, using one monitor's logical coordinate space. +#[cfg(target_os = "macos")] fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); @@ -758,6 +770,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: )) } +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn fit_bounds(parent: (f64, f64, f64, f64), size: (f64, f64), work: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) { let width = size.0.min(work.2); let height = size.1.min(work.3); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index bcc562f6..c9d26a00 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -44,6 +44,7 @@ export function ConfigPickers({ : undefined, detail: v.description ?? undefined, }))} + searchable={o.category === "model"} onChange={(v) => onChange(o.id, v)} // Effort and other levels say what they are: "Effort Medium". renderValue={ diff --git a/src/features/terminal/imeCommit.test.ts b/src/features/terminal/imeCommit.test.ts new file mode 100644 index 00000000..76753353 --- /dev/null +++ b/src/features/terminal/imeCommit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { IME_COMMIT_TIMEOUT_MS, ImeCommitGate, type ImeScheduler } from "./imeCommit"; + +/** Runs queued callbacks in order, the way setTimeout(0) drains after the current event. */ +function manualScheduler() { + let next = 1; + const tasks = new Map void>(); + const scheduler: ImeScheduler = { + after: (callback) => { + const id = next++; + tasks.set(id, callback); + return id; + }, + cancel: (id) => { + tasks.delete(id); + }, + }; + const run = () => { + for (const [id, task] of [...tasks]) { + tasks.delete(id); + task(); + } + }; + return { scheduler, run, pending: () => tasks.size }; +} + +function gateWithRebase() { + const { scheduler, run, pending } = manualScheduler(); + let rebased = 0; + const gate = new ImeCommitGate(() => rebased++, scheduler); + return { gate, run, pending, rebases: () => rebased }; +} + +describe("ImeCommitGate", () => { + it("takes the first emission of a commit and drops the repeated one", () => { + const { gate } = gateWithRebase(); + gate.begin(); + expect(gate.accept()).toBe(true); + expect(gate.accept()).toBe(false); + }); + + it("keeps the real text of the pi case and drops the stale suffix", () => { + // xterm sends the committed diff first, then a stale `value.substring(start)`. + const { gate, run } = gateWithRebase(); + const sent: string[] = []; + const emit = (data: string) => { + if (gate.accept()) sent.push(data); + }; + gate.begin(); + emit("在吗"); + gate.finish(); + emit("好在吗"); + run(); + expect(sent).toEqual(["在吗"]); + }); + + it("accepts every emission once the commit is over", () => { + const { gate, run } = gateWithRebase(); + gate.begin(); + expect(gate.accept()).toBe(true); + expect(gate.accept()).toBe(false); + gate.finish(); + run(); + expect(gate.accept()).toBe(true); + expect(gate.accept()).toBe(true); + }); + + it("rebases the textarea when the commit closes, not before", () => { + const { gate, run, rebases } = gateWithRebase(); + gate.begin(); + gate.finish(); + expect(rebases()).toBe(0); + run(); + expect(rebases()).toBe(1); + }); + + it("stands down once a real compositionstart arrives", () => { + const { gate, rebases } = gateWithRebase(); + gate.begin(); + gate.compositionStart(); + expect(gate.accept()).toBe(true); + expect(gate.accept()).toBe(true); + expect(rebases()).toBe(0); + // And a later commit is not gated either. + gate.begin(); + expect(gate.accept()).toBe(true); + expect(gate.accept()).toBe(true); + }); + + it("closes a commit whose compositionend never arrives", () => { + const { gate, run, pending, rebases } = gateWithRebase(); + gate.begin(); + expect(pending()).toBe(1); + run(); + expect(IME_COMMIT_TIMEOUT_MS).toBeGreaterThan(0); + expect(rebases()).toBe(1); + expect(gate.accept()).toBe(true); + }); +}); diff --git a/src/features/terminal/imeCommit.ts b/src/features/terminal/imeCommit.ts new file mode 100644 index 00000000..baeca349 --- /dev/null +++ b/src/features/terminal/imeCommit.ts @@ -0,0 +1,84 @@ +/** + * Collapses the repeated data emissions of one IME commit. + * + * WebKitGTK + fcitx5 commit text without firing `compositionstart`, so xterm's `CompositionHelper` + * never advances `_compositionPosition.start` and keeps appending the committed text to its hidden + * textarea. A commit then reaches `onData` twice — once with the committed text, once with a stale + * `value.substring(start)` — and the same text shows up in the terminal twice. + * + * One commit must send one chunk: take the first emission and drop the rest, then rebase the + * textarea so the next commit starts from an empty value. IMEs that do report `compositionstart` + * (macOS, IBus) keep xterm's own bookkeeping, so the gate stands down for them. + */ + +/** How long a commit may stay open when its `compositionend` never arrives. */ +export const IME_COMMIT_TIMEOUT_MS = 250; + +export interface ImeScheduler { + after(callback: () => void, ms: number): number; + cancel(id: number): void; +} + +const WINDOW_SCHEDULER: ImeScheduler = { + after: (callback, ms) => window.setTimeout(callback, ms), + cancel: (id) => window.clearTimeout(id), +}; + +export class ImeCommitGate { + private open = false; + private sent = false; + private timer: number | null = null; + private native = false; + private readonly rebase: () => void; + private readonly scheduler: ImeScheduler; + + constructor(rebase: () => void, scheduler: ImeScheduler = WINDOW_SCHEDULER) { + this.rebase = rebase; + this.scheduler = scheduler; + } + + /** A commit starts: the IME's own keystroke (code 229) or its composition input event. */ + begin(): void { + if (this.native || this.open) return; + this.open = true; + this.sent = false; + this.timer = this.scheduler.after(() => this.close(), IME_COMMIT_TIMEOUT_MS); + } + + /** The commit ended: let the emissions both of xterm's senders queued go out, then rebase. */ + finish(): void { + if (!this.open) return; + this.cancelTimer(); + this.timer = this.scheduler.after(() => this.close(), 0); + } + + /** Whether this emission should reach the PTY. */ + accept(): boolean { + if (!this.open) return true; + if (this.sent) return false; + this.sent = true; + return true; + } + + /** The IME reports composition events, so xterm needs no help — stay out of its way. */ + compositionStart(): void { + this.native = true; + this.cancelTimer(); + this.open = false; + this.sent = false; + } + + /** Ends the commit and rebases the textarea to an empty value. */ + close(): void { + this.cancelTimer(); + if (!this.open) return; + this.open = false; + this.rebase(); + } + + private cancelTimer(): void { + if (this.timer === null) return; + this.scheduler.cancel(this.timer); + this.timer = null; + } +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 9c7c0f8c..cdc92247 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type TerminalId } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { fitScheduler, settledResize } from "./fit"; +import { ImeCommitGate } from "./imeCommit"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; @@ -176,6 +177,10 @@ export class TerminalSession { private lastSize = { cols: 0, rows: 0 }; /** Bumped on restart so late output from a previous PTY is ignored. */ private generation = 0; + /** WebKitGTK delivers one IME commit' worth of text more than once; see `ImeCommitGate`. */ + private readonly ime = new ImeCommitGate(() => { + if (this.term.textarea) this.term.textarea.value = ""; + }); constructor(opts: SessionOptions) { this.opts = opts; @@ -227,7 +232,7 @@ export class TerminalSession { this.searchAddon.onDidChangeResults((r) => { for (const l of this.searchListeners) l({ index: r.resultIndex, count: r.resultCount }); }), - this.term.onData((data) => this.input(data)), + this.term.onData((data) => this.fromTerminal(data)), this.term.onBinary((data) => this.input(data)), this.term.onTitleChange((title) => this.update({ appTitle: title.trim() || null })), // OSC 7: `file://host/path` — shells report their cwd so relative links resolve correctly. @@ -347,6 +352,7 @@ export class TerminalSession { () => this.scheduleFit(), ); this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key)); + this.watchImeCommit(); } this.resizeObserver?.disconnect(); this.resizeObserver = new ResizeObserver(() => this.scheduleFit()); @@ -364,6 +370,44 @@ export class TerminalSession { this.fitter.schedule(); } + /** + * Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the + * composition's own keystroke (code 229) and its input event but never `compositionstart`, + * which is what xterm's own bookkeeping is built on. + */ + private watchImeCommit() { + const textarea = this.term.textarea; + if (!textarea) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.keyCode === 229) this.ime.begin(); + }; + const onInput = (e: InputEvent) => { + if (e.inputType === "insertFromComposition" || e.inputType === "insertCompositionText") this.ime.begin(); + }; + const onCompositionStart = () => this.ime.compositionStart(); + // Bubble phase, i.e. after xterm's own listener: the rebase must land behind the emissions + // that xterm's composition and textarea-diff paths already scheduled. + const onCompositionEnd = () => this.ime.finish(); + this.host.addEventListener("keydown", onKeyDown, true); + textarea.addEventListener("input", onInput, true); + textarea.addEventListener("compositionstart", onCompositionStart, true); + textarea.addEventListener("compositionend", onCompositionEnd); + this.disposables.push({ + dispose: () => { + this.ime.close(); + this.host.removeEventListener("keydown", onKeyDown, true); + textarea.removeEventListener("input", onInput, true); + textarea.removeEventListener("compositionstart", onCompositionStart, true); + textarea.removeEventListener("compositionend", onCompositionEnd); + }, + }); + } + + /** xterm's data event, with one IME commit's repeated emissions collapsed. */ + private fromTerminal(data: string) { + if (this.ime.accept()) this.input(data); + } + private doFit() { if (this.disposed || !this.host.isConnected) return; if (this.host.offsetWidth === 0 || this.host.offsetHeight === 0) return; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8c396967..38824211 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2278,6 +2278,7 @@ "cargo run configurations and rust-analyzer use this toolchain (PATH, RUSTUP_TOOLCHAIN).": "cargo 运行配置和 rust-analyzer 使用此工具链(PATH、RUSTUP_TOOLCHAIN)。", "chord prefix": "组合键前缀", "command line tool. Install it, then run": "命令行工具。请安装它,然后运行", + "compact the conversation": "压缩对话上下文", "configured in": "配置位置:", "const declaration": "const 声明", "current branch": "当前分支", @@ -3430,6 +3431,7 @@ "Chart Settings": "图表设置", "No numeric values to plot": "没有可绘制的数值", "Search": "搜索", + "Search…": "搜索…", "Statements": "语句", "No statements": "没有语句", "Statement": "语句", diff --git a/src/ui/Select.module.css b/src/ui/Select.module.css index 785f7dcc..a784be74 100644 --- a/src/ui/Select.module.css +++ b/src/ui/Select.module.css @@ -123,3 +123,16 @@ border: 1px solid ButtonText; } } + +.searchWrap { + padding: 4px 6px 6px; +} +.search { + width: 100%; + height: var(--h-field, 28px); + padding: 0 8px; + border-radius: var(--radius-control, 4px); + background: var(--bg-field, var(--bg-button)); + box-shadow: inset 0 0 0 1px var(--border-button); + color: var(--fg-strong); +} diff --git a/src/ui/Select.tsx b/src/ui/Select.tsx index 48a55fb2..d6fb9043 100644 --- a/src/ui/Select.tsx +++ b/src/ui/Select.tsx @@ -4,6 +4,7 @@ import { Icon, type IconName } from "./icons"; import { Popup } from "./Popup"; import { List, listNavigate } from "./List"; import { useTx } from "@/platform/i18n"; +import { SearchField } from "./Input"; import styles from "./Select.module.css"; export interface SelectOption { @@ -26,6 +27,8 @@ export interface SelectProps { "aria-label"?: string; /** Custom trigger content. */ renderValue?: (option: SelectOption | undefined) => ReactNode; + /** Show a search box in the popup (useful for long option lists). */ + searchable?: boolean; } const TYPE_AHEAD_MS = 800; @@ -40,6 +43,7 @@ export function Select({ variant = "framed", className, renderValue, + searchable = false, ...aria }: SelectProps) { const tx = useTx(); @@ -47,10 +51,16 @@ export function Select({ const triggerRef = useRef(null); const current = options.find((o) => o.value === value); const [active, setActive] = useState(0); + const [query, setQuery] = useState(""); const typeAhead = useRef({ text: "", at: -Infinity }); const isSection = (o: SelectOption) => !!o.disabled; + const filtered = + searchable && query.trim() + ? options.filter((o) => !o.disabled && tx(o.label).toLowerCase().includes(query.trim().toLowerCase())) + : options; const openPopup = () => { + setQuery(""); setActive( Math.max( 0, @@ -69,7 +79,7 @@ export function Select({ const onPopupKey = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - choose(options[active]); + choose(filtered[active]); return; } const next = listNavigate(e.key, { items: options, active, isSection, wrap: true }); @@ -85,10 +95,10 @@ export function Select({ ahead.at = e.timeStamp; const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]); const prefix = repeat ? ahead.text[0]! : ahead.text; - const n = options.length; + const n = filtered.length; for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { const i = (active + s) % n; - if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) { + if (!filtered[i]!.disabled && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; } @@ -135,6 +145,22 @@ export function Select({ className={styles.popup} >
+ {searchable && ( +
+ { + setQuery(v); + setActive(0); + }} + size="normal" + variant="framed" + className={styles.search} + /> +
+ )} {/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
{options.map((o) => ( @@ -147,7 +173,7 @@ export function Select({ ))}
o.value} isSection={isSection} renderSection={(o) => {tx(o.label)}}