From 1f9be73174347d0858c6fead15eead06ea6ad16a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:28:18 +0800 Subject: [PATCH] feat(remote): Reload the environment and apply devcontainer settings on the remote server (#183) The remote server read the login shell's PATH once and cached it, had no project-level environment, and changed environment variables only took effect after reinstalling or a manual restart. - Every openidea-server start re-reads the remote login shell's full environment (`$SHELL -ilc env -0`), with the cached PATH only as a fallback when the shell doesn't answer. - A project's `.devcontainer/devcontainer.json` (or `.devcontainer.json`) applies on remote hosts: `remoteEnv` (with `${localEnv|containerEnv|env:X[:default]}`, workspace variables and `null` to unset) reaches every child process through one environment overlay (terminals, run configurations, language servers, debuggers, build tools, git), and `postStartCommand` (string, array or parallel object) runs once per server start in the project root as a cancellable background task; a failure raises a notification with the exit code and the last output lines without blocking the project. - Reload Environment (`remote.reloadEnvironment`, File menu and Find Action) re-resolves the login shell and `remoteEnv` without restarting the server, starting from the process's original environment so prepended PATH entries don't pile up; it lists the changed variables, notes that running processes keep their environment, and offers to restart language servers. - The JSONC reader moves to `ide_core::jsonc` and is shared with ide-run. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA --- crates/ide-core/src/jsonc.rs | 90 ++++ crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/process.rs | 132 +++++- crates/ide-host/src/bin/server.rs | 10 +- crates/ide-host/src/environment.rs | 484 ++++++++++++++++++++ crates/ide-host/src/lib.rs | 14 +- crates/ide-host/src/ops/env.rs | 20 + crates/ide-host/src/ops/mod.rs | 2 + crates/ide-host/src/project.rs | 5 + crates/ide-host/src/shell_env.rs | 162 ++++++- crates/ide-host/tests/light_edit.rs | 1 + crates/ide-host/tests/remote_env.rs | 178 +++++++ crates/ide-host/tests/rpc.rs | 1 + crates/ide-run/src/detect.rs | 78 +--- crates/ide-tools/src/lib.rs | 20 +- crates/ide-workspace/src/devcontainer.rs | 264 +++++++++++ crates/ide-workspace/src/lib.rs | 1 + src-tauri/src/commands/env.rs | 14 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/menu.rs | 1 + src-tauri/src/state.rs | 1 + src/bindings.ts | 15 + src/features/remote-dev/environment.test.ts | 66 +++ src/features/remote-dev/environment.ts | 64 +++ src/features/remote-dev/index.ts | 11 + src/platform/locales/en.json | 1 + src/platform/locales/text/zh.json | 11 + src/platform/locales/zh.json | 1 + 28 files changed, 1527 insertions(+), 123 deletions(-) create mode 100644 crates/ide-core/src/jsonc.rs create mode 100644 crates/ide-host/src/environment.rs create mode 100644 crates/ide-host/src/ops/env.rs create mode 100644 crates/ide-host/tests/remote_env.rs create mode 100644 crates/ide-workspace/src/devcontainer.rs create mode 100644 src-tauri/src/commands/env.rs create mode 100644 src/features/remote-dev/environment.test.ts create mode 100644 src/features/remote-dev/environment.ts diff --git a/crates/ide-core/src/jsonc.rs b/crates/ide-core/src/jsonc.rs new file mode 100644 index 00000000..5525126c --- /dev/null +++ b/crates/ide-core/src/jsonc.rs @@ -0,0 +1,90 @@ +//! JSON with comments (`devcontainer.json`, `deno.jsonc`, `tsconfig.json`): turned +//! into plain JSON for `serde_json`. + +/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON. +pub fn strip(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let mut chars = src.chars().peekable(); + let mut in_str = false; + while let Some(c) = chars.next() { + if in_str { + out.push(c); + if c == '\\' { + if let Some(n) = chars.next() { + out.push(n); + } + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => { + in_str = true; + out.push(c); + } + '/' if chars.peek() == Some(&'/') => { + for n in chars.by_ref() { + if n == '\n' { + out.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut prev = '\0'; + for n in chars.by_ref() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + } + _ => out.push(c), + } + } + // Remove trailing commas before `}` / `]` (outside strings). + let mut cleaned = String::with_capacity(out.len()); + let bytes: Vec = out.chars().collect(); + let mut in_str = false; + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if in_str { + cleaned.push(c); + if c == '\\' && i + 1 < bytes.len() { + cleaned.push(bytes[i + 1]); + i += 1; + } else if c == '"' { + in_str = false; + } + } else if c == '"' { + in_str = true; + cleaned.push(c); + } else if c == ',' { + let next = bytes[i + 1..].iter().find(|ch| !ch.is_whitespace()); + if !matches!(next, Some('}') | Some(']')) { + cleaned.push(c); + } + } else { + cleaned.push(c); + } + i += 1; + } + cleaned +} + +#[cfg(test)] +mod tests { + use super::strip; + + #[test] + fn drops_comments_and_trailing_commas_outside_strings() { + let src = "{\n // line\n \"a\": [1, 2,], /* block */\n \"url\": \"https://x.dev//y /* no */\",\n \"q\": \"say \\\"hi\\\",\",\n}\n"; + let v: serde_json::Value = serde_json::from_str(&strip(src)).unwrap(); + assert_eq!(v["a"], serde_json::json!([1, 2])); + assert_eq!(v["url"], "https://x.dev//y /* no */"); + assert_eq!(v["q"], "say \"hi\","); + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 682dc452..905a5cb7 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod ids; pub mod ignored; pub mod inspection_settings; pub mod ipc_path; +pub mod jsonc; pub mod process; pub mod project_dir; pub mod remote_settings; diff --git a/crates/ide-core/src/process.rs b/crates/ide-core/src/process.rs index dd5a06cb..2d5d35dd 100644 --- a/crates/ide-core/src/process.rs +++ b/crates/ide-core/src/process.rs @@ -2,7 +2,7 @@ use std::ffi::{OsStr, OsString}; use std::process::Command; -use std::sync::OnceLock; +use std::sync::{Arc, RwLock}; /// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it /// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise. @@ -13,7 +13,7 @@ pub const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// /// - Windows: release builds use the GUI subsystem, so without `CREATE_NO_WINDOW` every /// console child flashes a `cmd` window. -/// - Linux AppImage: see [`host_env`]. +/// - Linux AppImage, reloaded and project variables: see [`host_env`]. /// /// Variables the call site has already set are kept; call it before `env_clear`. pub fn prepare(cmd: &mut Command) -> &mut Command { @@ -30,10 +30,11 @@ pub fn prepare(cmd: &mut Command) -> &mut Command { /// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that /// inherits them loads the image's (older) libraries and plugins next to its own, e.g. /// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment -/// (WebKit and GTK still need it). Variables the call site has already set are kept. -/// A no-op outside an AppImage. +/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables. +/// Variables the call site has already set are kept. A no-op outside an AppImage +/// without an overlay. pub fn host_env(cmd: &mut Command) -> &mut Command { - let fixes = appimage_fixes(); + let fixes = child_env_changes(); if fixes.is_empty() { return cmd; } @@ -49,30 +50,54 @@ pub fn host_env(cmd: &mut Command) -> &mut Command { /// The [`host_env`] changes as `(name, value)` pairs, `None` meaning "remove", for spawners /// that are not a `std::process::Command` (a PTY `CommandBuilder`). Apply before the call -/// site's own variables. Empty outside an AppImage. +/// site's own variables. Empty outside an AppImage without an overlay. pub fn host_env_fixes() -> Vec<(OsString, Option)> { - appimage_fixes() + child_env_changes() } /// This process's environment as a child should inherit it (see [`host_env`]), for call /// sites that build a child's environment from the IDE's own instead of inheriting it. pub fn host_vars() -> impl Iterator { - let fixes = appimage_fixes(); - std::env::vars_os().filter_map(move |(k, v)| match fixes.iter().find(|(f, _)| *f == k) { - Some((_, fixed)) => fixed.clone().map(|v| (k, v)), - None => Some((k, v)), - }) + apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter() } /// [`std::env::var_os`] as a child should see it (see [`host_env`]). pub fn host_var_os(key: impl AsRef) -> Option { let key = key.as_ref(); - match appimage_fixes().into_iter().find(|(k, _)| k == key) { + match child_env_changes().into_iter().find(|(k, _)| k == key) { Some((_, fixed)) => fixed, None => std::env::var_os(key), } } +/// `vars` with `changes` applied (`None` removes), new names appended. +fn apply_changes( + mut vars: Vec<(OsString, OsString)>, + changes: &[(OsString, Option)], +) -> Vec<(OsString, OsString)> { + for (key, value) in changes { + let at = vars.iter().position(|(k, _)| k == key); + match (at, value) { + (Some(i), Some(v)) => vars[i].1 = v.clone(), + (Some(i), None) => { + vars.remove(i); + } + (None, Some(v)) => vars.push((key.clone(), v.clone())), + (None, None) => {} + } + } + vars +} + +/// The AppImage fixes, then the overlay (which wins for a name both change); one entry per name. +fn child_env_changes() -> Vec<(OsString, Option)> { + let mut changes = appimage_fixes(); + let overlay = env_overlay(); + changes.retain(|(k, _)| !overlay.iter().any(|(o, _)| o == k)); + changes.extend(overlay.iter().cloned()); + changes +} + /// Read on every call, not cached: startup rewrites `PATH` (login shell, managed tools). fn appimage_fixes() -> Vec<(OsString, Option)> { if !cfg!(target_os = "linux") || std::env::var_os("APPDIR").is_none() { @@ -120,23 +145,49 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option> = OnceLock::new(); +type Vars = Arc>; +type Changes = Arc)>>; -/// Records the login shell's environment, read once at startup off the main thread. +static SHELL_ENV: RwLock> = RwLock::new(None); +static OVERLAY: RwLock> = RwLock::new(None); + +/// Records the login shell's environment: read at startup off the main thread, and again +/// by Reload Environment. pub fn set_shell_env(vars: Vec<(OsString, OsString)>) { - let _ = SHELL_ENV.set(vars); + *SHELL_ENV.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(vars)); +} + +/// The login shell's environment as last read, all of it (`None` until it has been read). +pub fn login_env() -> Option { + SHELL_ENV.read().unwrap_or_else(|e| e.into_inner()).clone() } /// The login shell's variables this process lacks: an app started from Finder/Dock gets only /// PATH from the shell, not what rc files export (API keys and provider switches, SDK homes...). /// Apply before a call site's own variables so those win. Empty until the shell has been read. -pub fn shell_env() -> impl Iterator { - SHELL_ENV - .get() - .into_iter() - .flatten() - .filter(|(k, _)| std::env::var_os(k).is_none()) - .map(|(k, v)| (k.as_os_str(), v.as_os_str())) +/// Names the overlay sets are left out: [`host_env`] gives them their value. +pub fn shell_env() -> impl Iterator { + let overlay = env_overlay(); + let vars = login_env().unwrap_or_default(); + let lacking: Vec<_> = vars + .iter() + .filter(|(k, _)| std::env::var_os(k).is_none() && !overlay.iter().any(|(o, _)| o == k)) + .cloned() + .collect(); + lacking.into_iter() +} + +/// Variables every child process gets on top of this process's environment (`None`: removed) +/// unless its call site sets them: an environment re-read after startup (this process's own +/// can't change safely once threads run) and the project's (`remoteEnv`). Replaces the +/// previous overlay; children already running keep what they started with. +pub fn set_env_overlay(changes: Vec<(OsString, Option)>) { + *OVERLAY.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(changes)); +} + +/// The current [`set_env_overlay`] variables. +pub fn env_overlay() -> Changes { + OVERLAY.read().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default() } #[cfg(test)] @@ -144,10 +195,41 @@ mod tests { use super::*; #[test] - fn shell_env_adds_only_what_the_process_lacks() { + fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() { set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]); let vars: Vec<_> = shell_env().collect(); - assert_eq!(vars, [(OsStr::new("OPENIDEA_SHELL_ONLY_VAR"), OsStr::new("1"))]); + assert_eq!(vars, [(OsString::from("OPENIDEA_SHELL_ONLY_VAR"), OsString::from("1"))]); + + // The overlay (process-wide too, hence the same test). + set_shell_env(vec![("OPENIDEA_OVERLAY_A".into(), "shell".into()), ("OPENIDEA_OVERLAY_B".into(), "b".into())]); + set_env_overlay(vec![ + ("OPENIDEA_OVERLAY_A".into(), Some("project".into())), + ("PATH".into(), Some("/reloaded".into())), + ("HOME".into(), None), + ]); + let mut cmd = Command::new("true"); + cmd.env("PATH", "/custom"); + host_env(&mut cmd); + let get = |k: &str| cmd.get_envs().find(|(n, _)| *n == k).map(|(_, v)| v.map(OsStr::to_owned)); + assert_eq!(get("OPENIDEA_OVERLAY_A"), Some(Some("project".into()))); + assert_eq!(get("PATH"), Some(Some("/custom".into())), "the call site's value wins"); + assert_eq!(get("HOME"), Some(None)); + let shell: Vec<_> = shell_env().map(|(k, _)| k).collect(); + assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]); + assert_eq!(host_var_os("PATH"), Some("/reloaded".into())); + assert!(host_vars().all(|(k, _)| k != "HOME")); + assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project")); + assert_eq!(host_env_fixes().len(), env_overlay().len()); + set_env_overlay(Vec::new()); + } + + #[test] + fn changes_apply_in_order() { + let vars = apply_changes( + env(&[("A", "1"), ("B", "2")]), + &[("A".into(), Some("3".into())), ("B".into(), None), ("C".into(), Some("4".into())), ("D".into(), None)], + ); + assert_eq!(vars, env(&[("A", "3"), ("C", "4")])); } fn env(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> { diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index a647ba69..6f405fcb 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -24,16 +24,13 @@ fn main() { .init(); let home = dirs::home_dir().unwrap_or_default(); let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea")); - // An SSH exec channel gets a minimal PATH: take the login shell's, as the - // desktop app does, plus the IDE-managed tools. Before any other thread starts. - let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server"); + // An SSH exec channel gets a minimal environment: take the login shell's, read + // afresh on every start, plus the IDE-managed tools. Before any other thread starts. + ide_host::shell_env::inherit_login_shell_env("openidea-server"); let data_dir = base.join("data"); ide_tools::extend_process_path(&data_dir); ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir)); ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir)); - if let Some(refresh) = refresh_shell_path { - refresh(); - } let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime"); let code = runtime.block_on(run(base)); std::process::exit(code); @@ -64,6 +61,7 @@ async fn run(base: PathBuf) -> i32 { sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))), owner: |w| w, runtime: tokio::runtime::Handle::current(), + project_environment: true, })); tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; diff --git a/crates/ide-host/src/environment.rs b/crates/ide-host/src/environment.rs new file mode 100644 index 00000000..cfb4bb72 --- /dev/null +++ b/crates/ide-host/src/environment.rs @@ -0,0 +1,484 @@ +//! The environment child processes get beyond this process's own: the login shell's as +//! re-read by Reload Environment, and (remote server) the project's +//! `devcontainer.json` `remoteEnv`, plus its `postStartCommand` once per server start. +//! +//! Both reach every child through `ide_core::process::set_env_overlay`: terminals, runs, +//! language servers, debug adapters, build tools and git all spawn through +//! `ide_core::process::prepare` / `host_env`. + +use ide_core::event::NotifyLevel; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink, TaskId}; +use ide_workspace::devcontainer::{self, LifecycleCommand}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::{BTreeSet, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +type Changes = Vec<(OsString, Option)>; + +/// What Reload Environment changed. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnvReloaded { + /// Names of the variables new child processes now get differently (added, changed or removed). + pub changed: Vec, + /// The `devcontainer.json` whose `remoteEnv` applies, if any. + pub devcontainer: Option, + /// Whether the login shell was read (it isn't on Windows). + pub shell_read: bool, +} + +#[derive(Default)] +struct State { + /// The login shell's environment when this process started (before the first reload). + baseline: Option>, + /// The login shell's changes since this process started (Reload Environment). + login: Changes, + /// The open project's `remoteEnv`. + project: Changes, + /// Project roots whose `postStartCommand` ran in this process. + post_started: HashSet, +} + +pub struct Environment { + /// Whether the open project's `devcontainer.json` applies (the remote server: one project per process). + project_env: bool, + data_dir: PathBuf, + state: Mutex, + /// Stops running `postStartCommand`s when the host shuts down. + cancel: CancellationToken, +} + +impl Environment { + pub(crate) fn new(project_env: bool, data_dir: PathBuf) -> Self { + Self { project_env, data_dir, state: Mutex::default(), cancel: CancellationToken::new() } + } + + pub(crate) fn shutdown(&self) { + self.cancel.cancel(); + } + + /// A project opened at `root`: applies its `remoteEnv` and returns its `postStartCommand` + /// when it hasn't run in this process yet. Problems with the file are notified, not errors. + pub(crate) fn project_opened(&self, root: &Path, sink: &SharedSink) -> Option { + if !self.project_env { + return None; + } + let config = self.load_project(root, sink); + let post_start = config.and_then(|c| c.post_start); + let first = self.state.lock().post_started.insert(root.to_path_buf()); + post_start.filter(|_| first) + } + + pub(crate) fn project_closed(&self) { + if self.project_env { + self.state.lock().project.clear(); + self.publish(); + } + } + + /// Reads `root`'s `devcontainer.json` and applies its `remoteEnv` (none: clears it). + fn load_project(&self, root: &Path, sink: &SharedSink) -> Option { + let config = match devcontainer::load(root) { + Ok(c) => c, + Err(e) => { + sink.emit(IdeEvent::Notify { + level: NotifyLevel::Warning, + title: "devcontainer.json was not applied".into(), + body: Some(e), + }); + None + } + }; + let login = self.state.lock().login.clone(); + let project = config.as_ref().map(|c| project_changes(c, root, &login)).unwrap_or_default(); + self.state.lock().project = project; + self.publish(); + config + } + + fn publish(&self) { + let state = self.state.lock(); + ide_core::process::set_env_overlay(merge(&state.login, &state.project)); + } + + /// Reload Environment: reads the login shell again and re-applies the project's + /// `remoteEnv` (`root`). Blocking (it runs the shell); children already running keep + /// their environment. + pub fn reload(&self, root: Option<&Path>, sink: &SharedSink) -> IdeResult { + let before: Vec<_> = ide_core::process::host_vars().collect(); + let fresh = crate::shell_env::read_login_env_now().map_err(IdeError::unavailable)?; + let shell_read = fresh.is_some(); + if let Some(fresh) = fresh { + let old = { + let mut state = self.state.lock(); + let baseline = state + .baseline + .get_or_insert_with(|| ide_core::process::login_env().map(|v| v.to_vec()).unwrap_or_default()); + baseline.clone() + }; + let login = login_changes(&|k| std::env::var_os(k), &old, &fresh, &|path| { + ide_tools::path_with_tools(&self.data_dir, path) + }); + ide_core::process::set_shell_env(fresh); + self.state.lock().login = login; + } + let devcontainer = match root.filter(|_| self.project_env) { + Some(root) => self.load_project(root, sink).map(|c| c.path.to_string_lossy().into_owned()), + None => { + self.publish(); + None + } + }; + let after: Vec<_> = ide_core::process::host_vars().collect(); + Ok(EnvReloaded { changed: changed_names(&before, &after), devcontainer, shell_read }) + } + + /// Runs `cmd` (a `postStartCommand`) in `root` on its own thread, as a background task; + /// a failure is a notification. + pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) { + let cancel = self.cancel.child_token(); + let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || { + run_post_start(&cmd, &root, &sink, &cancel); + }); + if let Err(e) = spawned { + tracing::warn!("postStartCommand: {e}"); + } + } +} + +/// `project` after `login`; one entry per name, the later one winning. +fn merge(login: &Changes, project: &Changes) -> Changes { + let mut out: Changes = login.iter().filter(|(k, _)| !project.iter().any(|(p, _)| p == k)).cloned().collect(); + out.extend(project.iter().cloned()); + out +} + +/// What children need on top of this process's environment (`process`) to see the login +/// shell's `fresh` one: changed and new variables, and those `old` (the shell at startup) +/// had that it no longer sets, when this process took them from it. `PATH` gets the +/// managed tool folders (`with_tools`) as at startup. +fn login_changes( + process: &dyn Fn(&OsStr) -> Option, + old: &[(OsString, OsString)], + fresh: &[(OsString, OsString)], + with_tools: &dyn Fn(&OsStr) -> OsString, +) -> Changes { + let effective = |k: &OsStr, v: &OsStr| if k == "PATH" { with_tools(v) } else { v.to_owned() }; + let mut out = Changes::new(); + for (k, v) in fresh { + let v = effective(k, v); + if process(k).as_ref() != Some(&v) { + out.push((k.clone(), Some(v))); + } + } + for (k, v) in old { + if !fresh.iter().any(|(f, _)| f == k) && process(k) == Some(effective(k, v)) { + out.push((k.clone(), None)); + } + } + out +} + +/// `remoteEnv` resolved against the environment before it (this process's plus `login`). +fn project_changes(config: &devcontainer::DevContainer, root: &Path, login: &Changes) -> Changes { + let lookup = |name: &str| -> Option { + match login.iter().find(|(k, _)| k == name) { + Some((_, v)) => v.as_ref().map(|v| v.to_string_lossy().into_owned()), + None => std::env::var_os(name).map(|v| v.to_string_lossy().into_owned()), + } + }; + devcontainer::resolve_env(config, root, &lookup).into_iter().map(|(k, v)| (k.into(), v.map(Into::into))).collect() +} + +fn changed_names(before: &[(OsString, OsString)], after: &[(OsString, OsString)]) -> Vec { + let get = |vars: &[(OsString, OsString)], k: &OsString| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + let names: BTreeSet<&OsString> = before.iter().chain(after).map(|(k, _)| k).collect(); + names.into_iter().filter(|k| get(before, k) != get(after, k)).map(|k| k.to_string_lossy().into_owned()).collect() +} + +/// Output lines kept for the failure notification. +const TAIL_LINES: usize = 20; +const TITLE: &str = "postStartCommand"; + +/// Runs a `postStartCommand` to the end (each of an object's commands in parallel), +/// reporting progress as a background task and a failure as an error notification. +fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel: &CancellationToken) { + let task = TaskId::next(); + let _cancel_handle = ide_core::task::on_cancel(task, { + let cancel = cancel.clone(); + move || cancel.cancel() + }); + let progress = |message: Option, done: bool| { + sink.emit(IdeEvent::Task { id: task, title: TITLE.into(), message, fraction: None, done, cancellable: !done }); + }; + progress(Some("Starting…".into()), false); + let tail = Arc::new(Mutex::new(Vec::::new())); + let on_line = |name: Option<&str>, line: String| { + tracing::info!(target: "post_start", "{}{line}", name.map(|n| format!("[{n}] ")).unwrap_or_default()); + progress(Some(line.clone()), false); + let mut tail = tail.lock(); + tail.push(match name { + Some(n) => format!("[{n}] {line}"), + None => line, + }); + let excess = tail.len().saturating_sub(TAIL_LINES); + tail.drain(..excess); + }; + let failures: Vec = std::thread::scope(|scope| { + let runs: Vec<_> = cmd + .commands() + .into_iter() + .map(|(name, c)| { + let on_line = &on_line; + scope.spawn(move || { + let argv = c.argv().unwrap_or_default(); + run_one(&argv, root, cancel, &|line| on_line(name, line)) + .err() + .map(|e| name.map_or_else(|| e.clone(), |n| format!("{n}: {e}"))) + }) + }) + .collect(); + runs.into_iter().filter_map(|r| r.join().unwrap_or_else(|_| Some("panicked".into()))).collect() + }); + progress(None, true); + if failures.is_empty() || cancel.is_cancelled() { + return; + } + let tail = tail.lock().join("\n"); + let body = if tail.is_empty() { failures.join("\n") } else { format!("{}\n\n{tail}", failures.join("\n")) }; + sink.emit(IdeEvent::Notify { + level: NotifyLevel::Error, + title: "postStartCommand failed".into(), + body: Some(body), + }); +} + +/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line +/// to `on_line`; `Err` says how it failed. +fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> { + let (program, args) = argv.split_first().ok_or("empty command")?; + let mut child = ide_core::process::prepare(&mut Command::new(program)) + .args(args) + .current_dir(root) + .envs(ide_core::process::shell_env()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("cannot start {program}: {e}"))?; + let (tx, rx) = std::sync::mpsc::channel::(); + let pipes: [Option>; 2] = [ + child.stdout.take().map(|p| Box::new(p) as Box), + child.stderr.take().map(|p| Box::new(p) as Box), + ]; + for pipe in pipes.into_iter().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + } + drop(tx); + let status = loop { + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(line) => on_line(line), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break child.wait(), + } + if cancel.is_cancelled() { + let _ = child.kill(); + let _ = child.wait(); + return Err("cancelled".into()); + } + // A process it started in the background may keep the pipes open: the command is done. + if let Ok(Some(status)) = child.try_wait() { + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while let Ok(line) = rx.recv_timeout(Duration::from_millis(100)) { + on_line(line); + if std::time::Instant::now() > deadline { + break; + } + } + break Ok(status); + } + } + .map_err(|e| e.to_string())?; + match status.code() { + Some(0) => Ok(()), + Some(code) => Err(format!("exited with code {code}")), + None => Err("terminated by a signal".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::EventSink; + + fn vars(pairs: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + pairs.iter().map(|(k, v)| (OsString::from(k), OsString::from(v))).collect() + } + + fn change(k: &str, v: Option<&str>) -> (OsString, Option) { + (k.into(), v.map(Into::into)) + } + + #[test] + fn reload_changes_are_relative_to_this_process() { + // This process took the login shell's environment at startup (the server), PATH with tools. + let process = vars(&[("PATH", "/tools:/a"), ("KEEP", "1"), ("EDITED", "old"), ("DROPPED", "x"), ("SSH", "s")]); + let old = vars(&[("PATH", "/a"), ("KEEP", "1"), ("EDITED", "old"), ("DROPPED", "x")]); + let fresh = vars(&[("PATH", "/b:/a"), ("KEEP", "1"), ("EDITED", "new"), ("ADDED", "y")]); + let lookup = |k: &OsStr| process.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + let with_tools = |p: &OsStr| { + let mut s = OsString::from("/tools:"); + s.push(p); + s + }; + let got = login_changes(&lookup, &old, &fresh, &with_tools); + assert_eq!( + got, + [ + change("PATH", Some("/tools:/b:/a")), + change("EDITED", Some("new")), + change("ADDED", Some("y")), + change("DROPPED", None), + ] + ); + // Nothing changed: nothing to apply. + assert!(login_changes(&lookup, &old, &old, &with_tools).is_empty()); + } + + #[test] + fn variables_the_process_had_before_the_shell_stay() { + // The desktop app: the shell had `FROM_LAUNCHD` with another value than the process. + let process = vars(&[("FROM_LAUNCHD", "process")]); + let lookup = |k: &OsStr| process.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + let got = login_changes(&lookup, &vars(&[("FROM_LAUNCHD", "shell")]), &[], &|p| p.to_owned()); + assert!(got.is_empty(), "{got:?}"); + } + + #[test] + fn project_variables_win_and_see_the_reloaded_login_values() { + let login = vec![change("OPENIDEA_TEST_TOOLS", Some("/reloaded")), change("A", Some("login"))]; + let config = devcontainer::parse( + r#"{ "remoteEnv": { "A": "project", "B": "${containerEnv:OPENIDEA_TEST_TOOLS}/bin" } }"#, + ) + .unwrap(); + let project = project_changes(&config, Path::new("/p"), &login); + assert_eq!(project, [change("A", Some("project")), change("B", Some("/reloaded/bin"))]); + let merged = merge(&login, &project); + assert_eq!( + merged, + [ + change("OPENIDEA_TEST_TOOLS", Some("/reloaded")), + change("A", Some("project")), + change("B", Some("/reloaded/bin")) + ] + ); + } + + #[test] + fn changed_names_cover_added_edited_and_removed() { + let before = vars(&[("A", "1"), ("B", "2"), ("C", "3")]); + let after = vars(&[("A", "1"), ("B", "two"), ("D", "4")]); + assert_eq!(changed_names(&before, &after), ["B", "C", "D"]); + } + + #[derive(Default)] + struct Events(Mutex>); + impl EventSink for Events { + fn emit(&self, e: IdeEvent) { + self.0.lock().push(e); + } + } + + fn run(cmd: LifecycleCommand, root: &Path) -> Vec { + let events = Arc::new(Events::default()); + let sink: SharedSink = events.clone(); + run_post_start(&cmd, root, &sink, &CancellationToken::new()); + events.0.lock().clone() + } + + fn failure(events: &[IdeEvent]) -> Option { + events.iter().find_map(|e| match e { + IdeEvent::Notify { level: NotifyLevel::Error, body, .. } => body.clone(), + _ => None, + }) + } + + #[cfg(unix)] + #[test] + fn post_start_forms_run_in_the_project_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let events = run(LifecycleCommand::Shell("pwd > shell.txt; echo done".into()), root); + assert!(failure(&events).is_none(), "{events:?}"); + let pwd = std::fs::read_to_string(root.join("shell.txt")).unwrap(); + assert_eq!(dunce::canonicalize(pwd.trim()).unwrap(), dunce::canonicalize(root).unwrap()); + let messages: Vec<_> = events + .iter() + .filter_map(|e| match e { + IdeEvent::Task { message, done, .. } => Some((message.clone(), *done)), + _ => None, + }) + .collect(); + assert!(messages.contains(&(Some("done".into()), false)), "{messages:?}"); + assert_eq!(messages.last(), Some(&(None, true))); + + let exec = LifecycleCommand::Exec(vec!["touch".into(), "exec file.txt".into()]); + assert!(failure(&run(exec, root)).is_none()); + assert!(root.join("exec file.txt").is_file(), "array form: no shell splitting"); + + let parallel = LifecycleCommand::Parallel(vec![ + ("a".into(), LifecycleCommand::Shell("touch a.txt".into())), + ("b".into(), LifecycleCommand::Exec(vec!["touch".into(), "b.txt".into()])), + ]); + assert!(failure(&run(parallel, root)).is_none()); + assert!(root.join("a.txt").is_file() && root.join("b.txt").is_file()); + } + + #[cfg(unix)] + #[test] + fn a_failing_post_start_is_notified_with_its_output() { + let tmp = tempfile::tempdir().unwrap(); + let events = run(LifecycleCommand::Shell("echo starting; echo broken >&2; exit 3".into()), tmp.path()); + let body = failure(&events).expect("a failure notification"); + assert!(body.contains("exited with code 3"), "{body}"); + assert!(body.contains("starting") && body.contains("broken"), "{body}"); + assert!(matches!(events.last(), Some(IdeEvent::Notify { .. }))); + + let parallel = LifecycleCommand::Parallel(vec![ + ("ok".into(), LifecycleCommand::Shell("true".into())), + ("missing".into(), LifecycleCommand::Exec(vec!["openidea-no-such-program".into()])), + ]); + let body = failure(&run(parallel, tmp.path())).unwrap(); + assert!(body.starts_with("missing: cannot start openidea-no-such-program"), "{body}"); + } + + #[cfg(unix)] + #[test] + fn cancelling_stops_a_running_post_start() { + let tmp = tempfile::tempdir().unwrap(); + let cancel = CancellationToken::new(); + let started = std::time::Instant::now(); + let c = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + c.cancel(); + }); + let result = run_one(&["sleep".into(), "30".into()], tmp.path(), &cancel, &|_| {}); + assert_eq!(result, Err("cancelled".into())); + assert!(started.elapsed() < Duration::from_secs(10)); + } +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index a78db66b..6459d2d9 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -3,6 +3,7 @@ //! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server` //! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]). +pub mod environment; pub mod ops; mod project; mod requests; @@ -34,6 +35,9 @@ pub struct HostConfig { /// The window that owns what `window` starts (e.g. a settings window's parent). pub owner: fn(&str) -> &str, pub runtime: tokio::runtime::Handle, + /// Apply the open project's `devcontainer.json` (`remoteEnv`, `postStartCommand`) to + /// this process's children: the remote server, which has one project. + pub project_environment: bool, } pub struct Host { @@ -50,6 +54,8 @@ pub struct Host { pub ssh: Arc, /// SFTP deployment, remote file editing and runs on run targets. pub remote: Arc, + /// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`). + pub environment: environment::Environment, pub(crate) sink_for: SinkFactory, owner: fn(&str) -> &str, pub runtime: tokio::runtime::Handle, @@ -60,13 +66,15 @@ pub struct Host { impl Host { pub fn new(config: HostConfig) -> Self { - let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config; + let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime, project_environment } = + config; let global_settings = SettingsStore::load(&config_dir, None); let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); Self { remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, + environment: environment::Environment::new(project_environment, data_dir.clone()), global_settings, // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), @@ -119,6 +127,9 @@ impl Host { pub fn close_project(&self, window: &str) { if let Some(project) = self.projects.write().remove(window) { + if !project.light { + self.environment.project_closed(); + } project.agents.shutdown(); let lsp = project.lsp.clone(); self.runtime.spawn(async move { lsp.shutdown_all().await }); @@ -128,6 +139,7 @@ impl Host { /// Stops what this host started: language servers, agents, runs and terminals. /// For a process that exits without dropping the host (the remote server). pub async fn shutdown(&self) { + self.environment.shutdown(); self.processes.kill_all(); self.terminals.kill_all(); let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect(); diff --git a/crates/ide-host/src/ops/env.rs b/crates/ide-host/src/ops/env.rs new file mode 100644 index 00000000..31772c0c --- /dev/null +++ b/crates/ide-host/src/ops/env.rs @@ -0,0 +1,20 @@ +//! Reload Environment: new child processes get the login shell's current environment +//! (and, on the remote server, the project's `remoteEnv`) without a restart. +use crate::Host; +use crate::environment::EnvReloaded; +use crate::rpc::Dispatcher; +use ide_core::IdeResult; +use std::sync::Arc; + +/// Re-reads the login shell (and the window's project `remoteEnv` where it applies); +/// running terminals and processes keep their environment. +pub async fn reload(host: &Arc, window: &str) -> IdeResult { + let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone()); + let sink = host.window_sink(window); + let h = host.clone(); + host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await +} + +pub(crate) fn register(d: &mut Dispatcher) { + d.add("env_reload", |c| async move { reload(&c.host, &c.window).await }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs index 4f558754..6f39bf88 100644 --- a/crates/ide-host/src/ops/mod.rs +++ b/crates/ide-host/src/ops/mod.rs @@ -9,6 +9,7 @@ pub mod bytes; pub mod coverage; pub mod debug; pub mod doc; +pub mod env; pub mod fs; pub mod history; pub mod java; @@ -75,6 +76,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) { doc::register(d); search::register(d); run::register(d); + env::register(d); } impl Host { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 63d1189b..12d7c242 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -75,6 +75,8 @@ impl Host { } self.close_project(window); let sink: SharedSink = (self.sink_for)(window); + // Before anything the project starts: its children get the project's environment. + let post_start = self.environment.project_opened(&root, &sink); let settings = SettingsStore::load(&self.config_dir, Some(&root)); let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); let excludes = exclude_globs(&settings.effective(), &detected_roots.read()); @@ -311,6 +313,9 @@ impl Host { })); } self.insert_project(window, project.clone()); + if let Some(cmd) = post_start { + self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); + } Ok(project) } diff --git a/crates/ide-host/src/shell_env.rs b/crates/ide-host/src/shell_env.rs index 6d0e2dee..6d2aa62c 100644 --- a/crates/ide-host/src/shell_env.rs +++ b/crates/ide-host/src/shell_env.rs @@ -3,12 +3,16 @@ //! on macOS/Linux we take PATH from the login shell at startup. //! //! Spawning an interactive login shell costs ~1 s with typical rc files, so the -//! value is cached: a warm start uses the cached PATH immediately and refreshes -//! the cache in the background for the next launch. +//! desktop app caches the value: a warm start uses the cached PATH immediately +//! and refreshes the cache in the background for the next launch. //! //! The rest of the shell's environment (API keys, provider switches, SDK homes) //! goes to child processes through `ide_core::process::shell_env`. It stays in //! memory, never in the cache: it may hold secrets. +//! +//! The remote server reads the whole environment afresh on every start +//! ([`inherit_login_shell_env`]), and Reload Environment reads it again while +//! running ([`read_login_env_now`], applied by [`crate::environment`]). #[cfg(unix)] use std::ffi::{OsStr, OsString}; @@ -22,14 +26,15 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option path, None => { // Slow shell rc files must not delay the first start indefinitely. - let env = read_login_env(Duration::from_secs(3))?; + let env = read_login_env(&login_shell(), Duration::from_secs(3))?; let fresh = login_path(&env)?; ide_core::process::set_shell_env(env); if let Some(cache) = &cache { @@ -43,7 +48,7 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option Option> = std::sync::OnceLock::new(); + +#[cfg(unix)] +fn record_startup_env() { + let _ = STARTUP_ENV.set(std::env::vars_os().collect()); +} + +/// How long the server waits for the login shell before it falls back to the cached PATH. +#[cfg(unix)] +const SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The remote server's start: this process takes the login shell's whole environment, +/// read now (an SSH exec channel gets a minimal one and no rc files), so every child +/// inherits it and a changed rc file applies on the next start. Falls back to the PATH +/// cached by the last start when the shell can't be read in time. +/// +/// Call at startup before any other thread reads the environment. +#[cfg(unix)] +pub fn inherit_login_shell_env(identifier: &str) { + if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() { + return; + } + record_startup_env(); + let cache = path_cache(identifier); + match read_login_env(&login_shell(), SERVER_READ_TIMEOUT) { + Some(env) => { + for (k, v) in &env { + // SAFETY: documented to run at startup before any other thread reads the environment. + unsafe { std::env::set_var(k, v) }; + } + if let (Some(cache), Some(path)) = (&cache, login_path(&env)) { + write_cache(cache, &path); + } + ide_core::process::set_shell_env(env); + } + None => { + let cached = cache.and_then(|c| std::fs::read_to_string(c).ok()).filter(|p| !p.trim().is_empty()); + if let Some(path) = cached { + // SAFETY: as above. + unsafe { std::env::set_var("PATH", path.trim()) }; + } + } + } +} + +/// The login shell's environment, read now (Reload Environment). `Ok(None)` where the +/// IDE doesn't read it (Windows, `OPENIDEA_SKIP_SHELL_ENV`). +#[cfg(unix)] +pub fn read_login_env_now() -> Result>, String> { + if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() { + return Ok(None); + } + let shell = login_shell(); + read_login_env(&shell, SERVER_READ_TIMEOUT) + .map(Some) + .ok_or_else(|| format!("could not read the environment of the login shell ({shell})")) +} + +#[cfg(not(unix))] +pub fn read_login_env_now() -> Result>, String> { + Ok(None) +} + +#[cfg(unix)] +fn login_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()) +} + +#[cfg(unix)] +fn path_cache(identifier: &str) -> Option { + dirs::cache_dir().map(|d| d.join(identifier).join("login-path")) +} + #[cfg(unix)] fn write_cache(cache: &std::path::Path, path: &str) { if let Some(dir) = cache.parent() { @@ -69,17 +150,35 @@ fn login_path(env: &[(OsString, OsString)]) -> Option { #[cfg(unix)] const SHELL_OWN: &[&str] = &["PWD", "OLDPWD", "SHLVL", "_"]; -/// The login shell's environment; PATH included, the shell's own bookkeeping left out. +/// The environment of `shell` started as an interactive login shell; PATH included, the +/// shell's own bookkeeping left out. #[cfg(unix)] -fn read_login_env(timeout: std::time::Duration) -> Option> { +pub(crate) fn read_login_env(shell: &str, timeout: std::time::Duration) -> Option> { use std::io::Read; use std::os::unix::ffi::OsStrExt; use std::process::{Command, Stdio}; use std::time::Instant; - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into()); const MARK: &str = "__OPENIDEA_ENV__"; - let mut child = ide_core::process::prepare(&mut Command::new(&shell)) + let mut cmd = Command::new(shell); + // As at startup: what this process took from the shell (and what a reload added) must + // not go in again, or rc files that append to PATH would append twice and a variable + // they no longer export would come back. + if let Some(startup) = STARTUP_ENV.get() { + let now: Vec = std::env::vars_os().map(|(k, _)| k).collect(); + let overlay = ide_core::process::env_overlay(); + for k in now.iter().chain(overlay.iter().map(|(k, _)| k)) { + let before = startup.iter().find(|(n, _)| n == k).map(|(_, v)| v); + if overlay.iter().any(|(o, _)| o == k) || std::env::var_os(k).as_ref() != before { + match before { + Some(v) => cmd.env(k, v), + None => cmd.env_remove(k), + }; + } + } + } + ide_core::process::prepare(&mut cmd); + let mut child = cmd .args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -123,3 +222,48 @@ fn read_login_env(timeout: std::time::Duration) -> Option Option { None } + +#[cfg(not(unix))] +pub fn inherit_login_shell_env(_identifier: &str) {} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::time::Duration; + + /// A stand-in login shell: runs the IDE's `-ilc` script after exporting what `rc` holds. + fn fake_shell(dir: &std::path::Path) -> (String, std::path::PathBuf) { + let rc = dir.join("rc"); + let shell = dir.join("shell"); + std::fs::write(&shell, format!("#!/bin/sh\n. {}\nshift\nexec /bin/sh -c \"$1\"\n", rc.display())).unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + (shell.to_string_lossy().into_owned(), rc) + } + + #[test] + fn every_read_sees_the_current_rc_file() { + let tmp = tempfile::tempdir().unwrap(); + let (shell, rc) = fake_shell(tmp.path()); + let get = |env: &[(OsString, OsString)], k: &str| { + env.iter().find(|(n, _)| n == k).map(|(_, v)| v.to_string_lossy().into_owned()) + }; + std::fs::write(&rc, "export OPENIDEA_TEST_RC=one\nexport PATH=/one/bin:$PATH\n").unwrap(); + let first = read_login_env(&shell, Duration::from_secs(10)).unwrap(); + assert_eq!(get(&first, "OPENIDEA_TEST_RC").as_deref(), Some("one")); + assert!(get(&first, "PATH").unwrap().starts_with("/one/bin:")); + assert!(get(&first, "PWD").is_none() && get(&first, "SHLVL").is_none()); + + std::fs::write(&rc, "export OPENIDEA_TEST_RC='two words'\n").unwrap(); + let second = read_login_env(&shell, Duration::from_secs(10)).unwrap(); + assert_eq!(get(&second, "OPENIDEA_TEST_RC").as_deref(), Some("two words")); + } + + #[test] + fn a_shell_that_hangs_times_out() { + let tmp = tempfile::tempdir().unwrap(); + let (shell, rc) = fake_shell(tmp.path()); + std::fs::write(&rc, "sleep 5\n").unwrap(); + assert!(read_login_env(&shell, Duration::from_millis(200)).is_none()); + } +} diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 919f05d6..d3732431 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -14,6 +14,7 @@ fn host(base: &std::path::Path) -> Arc { sink_for: Box::new(|_| Arc::new(NullSink)), owner: |w| w, runtime: tokio::runtime::Handle::current(), + project_environment: false, })) } diff --git a/crates/ide-host/tests/remote_env.rs b/crates/ide-host/tests/remote_env.rs new file mode 100644 index 00000000..13fdb754 --- /dev/null +++ b/crates/ide-host/tests/remote_env.rs @@ -0,0 +1,178 @@ +//! The remote server's environment: the login shell read afresh on every start, the +//! project's `devcontainer.json` (`remoteEnv`, `postStartCommand`) and Reload Environment. +//! One test in its own binary: it changes this process's environment. +#![cfg(unix)] + +use ide_core::event::NotifyLevel; +use ide_core::{EventSink, IdeEvent, SharedSink}; +use ide_host::{Host, HostConfig}; +use parking_lot::Mutex; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[derive(Default)] +struct Events(Mutex>); + +impl EventSink for Events { + fn emit(&self, e: IdeEvent) { + self.0.lock().push(e); + } +} + +/// What a child process started now sees for `name`. +fn child_var(name: &str) -> String { + let out = ide_core::process::prepare(&mut Command::new("/bin/sh")) + .args(["-c", &format!("printf %s \"${name}\"")]) + .output() + .unwrap(); + String::from_utf8(out.stdout).unwrap() +} + +fn wait_for(what: &str, mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(20); + while !done() { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +#[test] +fn server_environment_from_login_shell_and_devcontainer() { + let tmp = tempfile::tempdir().unwrap(); + let tmp = dunce::canonicalize(tmp.path()).unwrap(); + // A stand-in login shell that sources `rc`, as an rc file would be. + let rc = tmp.join("rc"); + let shell = tmp.join("shell"); + write(&shell, &format!("#!/bin/sh\n. {}\nshift\nexec /bin/sh -c \"$1\"\n", rc.display())); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + // SAFETY: no other thread runs yet. + unsafe { + std::env::set_var("SHELL", &shell); + // The PATH cache goes under the temporary home. + std::env::set_var("HOME", tmp.join("home")); + std::env::set_var("XDG_CACHE_HOME", tmp.join("home/.cache")); + std::env::remove_var("OPENIDEA_SKIP_SHELL_ENV"); + } + + // Two server starts: each reads the login shell again (not the cached PATH). + write(&rc, "export OPENIDEA_RC_VAR=one\nexport PATH=/start-one/bin:/usr/bin:/bin\n"); + ide_host::shell_env::inherit_login_shell_env("openidea-server-test"); + assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "one"); + write(&rc, "export OPENIDEA_RC_VAR=two\nexport PATH=/start-two/bin:/usr/bin:/bin\n"); + ide_host::shell_env::inherit_login_shell_env("openidea-server-test"); + assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "two"); + assert!(std::env::var("PATH").unwrap().starts_with("/start-two/bin:"), "{:?}", std::env::var("PATH")); + let base = tmp.join("server"); + ide_tools::extend_process_path(&base.join("data")); + + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + let events = Arc::new(Events::default()); + let sink_events = events.clone(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| sink_events.clone() as SharedSink), + owner: |w| w, + runtime: runtime.handle().clone(), + project_environment: true, + })); + assert!(ide_host::rpc::Dispatcher::standard().commands().contains(&"env_reload")); + + let project = tmp.join("demo"); + write( + &project.join(".devcontainer/devcontainer.json"), + r#"{ + // remoteEnv sees the login shell's variables + "remoteEnv": { + "FROM_PROJECT": "${containerEnv:OPENIDEA_RC_VAR}-${localWorkspaceFolderBasename}", + "PATH": "/project/bin:${containerEnv:PATH}", + }, + "postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"], + }"#, + ); + runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap(); + assert_eq!(child_var("FROM_PROJECT"), "two-demo"); + assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH")); + assert_eq!(child_var("OPENIDEA_RC_VAR"), "two"); + // PTY spawners (terminals, runs in a terminal) take the same variables. + assert!(ide_core::process::host_env_fixes().iter().any(|(k, v)| k == "FROM_PROJECT" && v.is_some())); + let started = project.join("started.txt"); + wait_for("postStartCommand", || std::fs::read_to_string(&started).is_ok_and(|s| s.ends_with('\n'))); + assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n"); + wait_for("the task to end", || events.0.lock().iter().any(|e| matches!(e, IdeEvent::Task { done: true, .. }))); + assert!(events.0.lock().iter().any( + |e| matches!(e, IdeEvent::Task { message: Some(m), title, .. } if m == "post-start-output" && title == "postStartCommand") + )); + + // Reload Environment: the edited rc file and the project file apply to new children. + write(&rc, "export OPENIDEA_RC_VAR=three\nexport OPENIDEA_RC_NEW=new\nexport PATH=/start-two/bin:/usr/bin:/bin\n"); + let reloaded = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap(); + assert!(reloaded.shell_read); + for name in ["FROM_PROJECT", "OPENIDEA_RC_NEW", "OPENIDEA_RC_VAR"] { + assert!(reloaded.changed.iter().any(|c| c == name), "{name} in {:?}", reloaded.changed); + } + assert!(!reloaded.changed.iter().any(|c| c == "PATH"), "{:?}", reloaded.changed); + assert!(reloaded.devcontainer.as_deref().is_some_and(|p| p.ends_with("devcontainer.json"))); + assert_eq!(child_var("OPENIDEA_RC_VAR"), "three"); + assert_eq!(child_var("OPENIDEA_RC_NEW"), "new"); + assert_eq!(child_var("FROM_PROJECT"), "three-demo"); + // This process keeps its own environment; only children change. + assert_eq!(std::env::var("OPENIDEA_RC_VAR").unwrap(), "two"); + + // A variable the rc file stops exporting goes away for new children, whether this + // process started with it or a reload added it. + write(&rc, "export PATH=/start-two/bin:/usr/bin:/bin\n"); + let reloaded = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap(); + for name in ["FROM_PROJECT", "OPENIDEA_RC_NEW", "OPENIDEA_RC_VAR"] { + assert!(reloaded.changed.iter().any(|c| c == name), "{name} in {:?}", reloaded.changed); + } + assert_eq!(child_var("OPENIDEA_RC_NEW"), ""); + assert_eq!(child_var("OPENIDEA_RC_VAR"), ""); + assert_eq!(child_var("FROM_PROJECT"), "-demo"); + + // The shell starts from the environment this process started with: an rc file that + // prepends to PATH does so once, however often it is reloaded. + write(&rc, "export PATH=/prepended/bin:$PATH\n"); + let first = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap(); + assert!(first.changed.iter().any(|c| c == "PATH"), "{:?}", first.changed); + let again = runtime.block_on(ide_host::ops::env::reload(&host, "main")).unwrap(); + assert!(again.changed.is_empty(), "{:?}", again.changed); + assert_eq!(child_var("PATH").matches("/prepended/bin").count(), 1, "{}", child_var("PATH")); + + // Reopening the project doesn't run postStartCommand again (once per server start). + host.close_project("main"); + assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project"); + runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap(); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n"); + + // A failing postStartCommand is a notification; the project opens regardless. + let failing = tmp.join("failing"); + write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#); + runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap(); + wait_for("the failure notification", || { + events.0.lock().iter().any(|e| { + matches!(e, IdeEvent::Notify { level: NotifyLevel::Error, title, body: Some(b) } + if title == "postStartCommand failed" && b.contains("exited with code 7") && b.contains("oops")) + }) + }); + + // A broken file is a warning, not an error. + let broken = tmp.join("broken"); + write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": "); + runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap(); + assert!(events.0.lock().iter().any(|e| matches!(e, + IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied"))); + + runtime.block_on(host.shutdown()); +} diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index bfb6abf4..dbc92d0d 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -61,6 +61,7 @@ async fn serves_projects_files_documents_search_and_runs() { sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))), owner: |w| w, runtime: tokio::runtime::Handle::current(), + project_environment: false, })); let (client_io, server_io) = tokio::io::duplex(1 << 20); let (sr, sw) = tokio::io::split(server_io); diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index c02e066b..4207e0c1 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -227,83 +227,11 @@ fn detect_node(root: &Path, out: &mut Vec) { // ---------------------------------------------------------------- deno -/// Strips `//` and `/* */` comments and trailing commas so JSONC parses as JSON. -pub(crate) fn strip_jsonc(src: &str) -> String { - let mut out = String::with_capacity(src.len()); - let mut chars = src.chars().peekable(); - let mut in_str = false; - while let Some(c) = chars.next() { - if in_str { - out.push(c); - if c == '\\' { - if let Some(n) = chars.next() { - out.push(n); - } - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => { - in_str = true; - out.push(c); - } - '/' if chars.peek() == Some(&'/') => { - for n in chars.by_ref() { - if n == '\n' { - out.push('\n'); - break; - } - } - } - '/' if chars.peek() == Some(&'*') => { - chars.next(); - let mut prev = '\0'; - for n in chars.by_ref() { - if prev == '*' && n == '/' { - break; - } - prev = n; - } - } - _ => out.push(c), - } - } - // Remove trailing commas before `}` / `]` (outside strings). - let mut cleaned = String::with_capacity(out.len()); - let bytes: Vec = out.chars().collect(); - let mut in_str = false; - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if in_str { - cleaned.push(c); - if c == '\\' && i + 1 < bytes.len() { - cleaned.push(bytes[i + 1]); - i += 1; - } else if c == '"' { - in_str = false; - } - } else if c == '"' { - in_str = true; - cleaned.push(c); - } else if c == ',' { - let next = bytes[i + 1..].iter().find(|ch| !ch.is_whitespace()); - if !matches!(next, Some('}') | Some(']')) { - cleaned.push(c); - } - } else { - cleaned.push(c); - } - i += 1; - } - cleaned -} - fn detect_deno(root: &Path, out: &mut Vec) { let Some(path) = first_existing(root, &["deno.json", "deno.jsonc"]) else { return }; - let Some(json) = read(&path).and_then(|s| serde_json::from_str::(&strip_jsonc(&s)).ok()) else { return }; + let Some(json) = read(&path).and_then(|s| serde_json::from_str::(&ide_core::jsonc::strip(&s)).ok()) else { + return; + }; let Some(tasks) = json.get("tasks").and_then(Json::as_object) else { return }; let mut names: Vec<&String> = tasks.keys().collect(); names.sort(); diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 9ecb6c7d..2c68f04d 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -149,18 +149,24 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

OsString { let mut dirs = tools_bin_dirs(app_data); - let current: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + let current: Vec = if path.is_empty() { Vec::new() } else { std::env::split_paths(path).collect() }; dirs.retain(|d| !current.contains(d)); if dirs.is_empty() { - return; + return path.to_owned(); } dirs.extend(current); - if let Ok(joined) = std::env::join_paths(dirs) { - // SAFETY: documented to run at startup before any other thread reads the environment. - unsafe { std::env::set_var("PATH", joined) }; - } + 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`). diff --git a/crates/ide-workspace/src/devcontainer.rs b/crates/ide-workspace/src/devcontainer.rs new file mode 100644 index 00000000..aba7901c --- /dev/null +++ b/crates/ide-workspace/src/devcontainer.rs @@ -0,0 +1,264 @@ +//! The project's `.devcontainer/devcontainer.json` (or `.devcontainer.json`) as a +//! remote host reads it: `remoteEnv` for every child process of the IDE backend and +//! `postStartCommand` once per backend start. No container is built. + +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Where the configuration may be, first match wins. +const LOCATIONS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct DevContainer { + pub path: PathBuf, + /// Values as written (`None`: unset the variable), substitutions not applied. + pub remote_env: BTreeMap>, + pub post_start: Option, +} + +/// A lifecycle command in its three forms. +#[derive(Debug, Clone, PartialEq)] +pub enum LifecycleCommand { + /// Run through `/bin/sh -c`. + Shell(String), + /// Run directly: program and arguments, no shell. + Exec(Vec), + /// Named commands (each a `Shell` or an `Exec`) run in parallel; all must succeed. + Parallel(Vec<(String, LifecycleCommand)>), +} + +impl LifecycleCommand { + fn parse(v: &Value) -> Result, String> { + Ok(match v { + Value::Null => None, + Value::String(s) if s.trim().is_empty() => None, + Value::String(s) => Some(Self::Shell(s.clone())), + Value::Array(items) => { + let args: Vec = items + .iter() + .map(|i| i.as_str().map(str::to_owned).ok_or("array items must be strings")) + .collect::>()?; + (!args.is_empty()).then_some(Self::Exec(args)) + } + Value::Object(map) => { + let mut named = Vec::new(); + for (name, v) in map { + match Self::parse(v)? { + Some(Self::Parallel(_)) => return Err(format!("{name}: expected a string or an array")), + Some(c) => named.push((name.clone(), c)), + None => {} + } + } + (!named.is_empty()).then_some(Self::Parallel(named)) + } + _ => return Err("expected a string, an array or an object".into()), + }) + } + + /// The single commands to run (one, or each of a `Parallel`), with their names. + pub fn commands(&self) -> Vec<(Option<&str>, &LifecycleCommand)> { + match self { + Self::Parallel(named) => named.iter().map(|(n, c)| (Some(n.as_str()), c)).collect(), + c => vec![(None, c)], + } + } + + /// Program and arguments of a `Shell` or `Exec` command (`Parallel`: `None`). + pub fn argv(&self) -> Option> { + match self { + Self::Shell(s) => Some(vec!["/bin/sh".into(), "-c".into(), s.clone()]), + Self::Exec(args) => Some(args.clone()), + Self::Parallel(_) => None, + } + } +} + +/// The configuration file of the project at `root`, if it has one. +pub fn find(root: &Path) -> Option { + LOCATIONS.iter().map(|l| root.join(l)).find(|p| p.is_file()) +} + +/// Reads the project's configuration: `Ok(None)` without one, `Err` with a message when +/// it can't be read or parsed. +pub fn load(root: &Path) -> Result, String> { + let Some(path) = find(root) else { return Ok(None) }; + let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + parse(&text).map(|mut c| { + c.path = path; + Some(c) + }) +} + +/// Parses `devcontainer.json` text (JSON with comments and trailing commas). +pub fn parse(text: &str) -> Result { + let json: Value = serde_json::from_str(&ide_core::jsonc::strip(text)).map_err(|e| e.to_string())?; + let Value::Object(top) = json else { return Err("expected an object".into()) }; + let mut config = DevContainer::default(); + if let Some(env) = top.get("remoteEnv") { + let Value::Object(env) = env else { return Err("remoteEnv: expected an object".into()) }; + for (k, v) in env { + let value = match v { + Value::Null => None, + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => return Err(format!("remoteEnv.{k}: expected a string")), + }; + config.remote_env.insert(k.clone(), value); + } + } + if let Some(cmd) = top.get("postStartCommand") { + config.post_start = LifecycleCommand::parse(cmd).map_err(|e| format!("postStartCommand: {e}"))?; + } + Ok(config) +} + +/// Expands `${localEnv:NAME}`, `${containerEnv:NAME}` and `${env:NAME}` (all the remote +/// host's environment, through `env`; `${…:NAME:default}` when unset), and +/// `${localWorkspaceFolder}` / `${containerWorkspaceFolder}` (and their `…Basename`) to +/// the project root. Unknown references stay as written. +pub fn substitute(value: &str, root: &Path, env: &dyn Fn(&str) -> Option) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + out.push_str(&rest[start..]); + return out; + }; + let inner = &after[..end]; + match resolve(inner, root, env) { + Some(v) => out.push_str(&v), + None => out.push_str(&rest[start..start + 2 + end + 1]), + } + rest = &after[end + 1..]; + } + out.push_str(rest); + out +} + +fn resolve(inner: &str, root: &Path, env: &dyn Fn(&str) -> Option) -> Option { + let basename = || root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + match inner { + "localWorkspaceFolder" | "containerWorkspaceFolder" => return Some(root.to_string_lossy().into_owned()), + "localWorkspaceFolderBasename" | "containerWorkspaceFolderBasename" => return Some(basename()), + _ => {} + } + let (scope, spec) = inner.split_once(':')?; + if !matches!(scope, "localEnv" | "containerEnv" | "env") { + return None; + } + let (name, default) = match spec.split_once(':') { + Some((name, default)) => (name, Some(default)), + None => (spec, None), + }; + Some(env(name).or_else(|| default.map(str::to_owned)).unwrap_or_default()) +} + +/// `remoteEnv` with substitutions applied against `env` (the environment before it), in +/// order: `None` unsets the variable. +pub fn resolve_env( + config: &DevContainer, + root: &Path, + env: &dyn Fn(&str) -> Option, +) -> Vec<(String, Option)> { + config.remote_env.iter().map(|(k, v)| (k.clone(), v.as_deref().map(|v| substitute(v, root, env)))).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(name: &str) -> Option { + match name { + "PATH" => Some("/usr/bin:/bin".into()), + "HOME" => Some("/home/me".into()), + _ => None, + } + } + + #[test] + fn parses_jsonc_with_remote_env_and_post_start() { + let c = parse( + r#"{ + // A comment + "name": "demo", + "remoteEnv": { + "PATH": "${containerEnv:PATH}:/opt/tools/bin", + "EMPTY": null, + "PORT": 8080, /* numbers are text */ + }, + "postStartCommand": "make dev", + }"#, + ) + .unwrap(); + assert_eq!(c.remote_env.get("PATH").unwrap().as_deref(), Some("${containerEnv:PATH}:/opt/tools/bin")); + assert_eq!(c.remote_env.get("EMPTY"), Some(&None)); + assert_eq!(c.remote_env.get("PORT").unwrap().as_deref(), Some("8080")); + assert_eq!(c.post_start, Some(LifecycleCommand::Shell("make dev".into()))); + } + + #[test] + fn post_start_command_forms() { + let parse_cmd = |json: &str| parse(&format!(r#"{{ "postStartCommand": {json} }}"#)).map(|c| c.post_start); + assert_eq!(parse_cmd(r#""echo hi""#).unwrap().unwrap().argv().unwrap(), ["/bin/sh", "-c", "echo hi"]); + let exec = parse_cmd(r#"["npm", "run", "watch"]"#).unwrap().unwrap(); + assert_eq!(exec, LifecycleCommand::Exec(vec!["npm".into(), "run".into(), "watch".into()])); + assert_eq!(exec.argv().unwrap(), ["npm", "run", "watch"]); + let parallel = + parse_cmd(r#"{ "server": "npm start", "db": ["pg_ctl", "start"], "none": "" }"#).unwrap().unwrap(); + let names: Vec<_> = parallel.commands().into_iter().map(|(n, _)| n.unwrap()).collect(); + assert_eq!(names, ["db", "server"]); + assert!(parallel.argv().is_none()); + assert_eq!(parse_cmd(r#""""#).unwrap(), None); + assert_eq!(parse_cmd("[]").unwrap(), None); + assert!(parse_cmd("42").unwrap_err().contains("postStartCommand")); + assert!(parse_cmd("[1]").is_err()); + assert!(parse_cmd(r#"{ "nested": { "a": "b" } }"#).is_err()); + } + + #[test] + fn invalid_files_are_errors() { + assert!(parse("{ \"remoteEnv\": [] }").unwrap_err().contains("remoteEnv")); + assert!(parse("{ not json").is_err()); + assert!(parse("[]").is_err()); + } + + #[test] + fn substitutes_remote_env_references() { + let root = Path::new("/work/demo"); + let s = |v: &str| substitute(v, root, &env); + assert_eq!(s("${containerEnv:PATH}:/opt/bin"), "/usr/bin:/bin:/opt/bin"); + assert_eq!(s("${localEnv:HOME}/.cache"), "/home/me/.cache"); + assert_eq!(s("${env:HOME}"), "/home/me"); + assert_eq!(s("${localEnv:MISSING}"), ""); + assert_eq!(s("${localEnv:MISSING:fallback}"), "fallback"); + assert_eq!(s("${containerWorkspaceFolder}/bin:${localWorkspaceFolderBasename}"), "/work/demo/bin:demo"); + assert_eq!(s("${unknown} ${other:X} $HOME ${open"), "${unknown} ${other:X} $HOME ${open"); + } + + #[test] + fn resolves_against_the_environment_before_it() { + let c = parse(r#"{ "remoteEnv": { "PATH": "/opt/bin:${containerEnv:PATH}", "GONE": null } }"#).unwrap(); + let resolved = resolve_env(&c, Path::new("/p"), &env); + assert_eq!( + resolved, + [("GONE".to_owned(), None), ("PATH".to_owned(), Some("/opt/bin:/usr/bin:/bin".to_owned()))] + ); + } + + #[test] + fn finds_either_location() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(load(tmp.path()).unwrap(), None); + std::fs::write(tmp.path().join(".devcontainer.json"), r#"{ "postStartCommand": "a" }"#).unwrap(); + assert_eq!(load(tmp.path()).unwrap().unwrap().post_start, Some(LifecycleCommand::Shell("a".into()))); + std::fs::create_dir(tmp.path().join(".devcontainer")).unwrap(); + std::fs::write(tmp.path().join(".devcontainer/devcontainer.json"), r#"{ "postStartCommand": "b" }"#).unwrap(); + let c = load(tmp.path()).unwrap().unwrap(); + assert_eq!(c.post_start, Some(LifecycleCommand::Shell("b".into()))); + assert!(c.path.ends_with(".devcontainer/devcontainer.json")); + } +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index ba006750..41c05bc9 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -1,6 +1,7 @@ //! The project model that ties documents, settings and history together. pub mod caches; +pub mod devcontainer; pub mod documents; pub mod editorconfig; pub mod file_defaults; diff --git a/src-tauri/src/commands/env.rs b/src-tauri/src/commands/env.rs new file mode 100644 index 00000000..36c40e7d --- /dev/null +++ b/src-tauri/src/commands/env.rs @@ -0,0 +1,14 @@ +//! IPC commands of `ide_host::ops::env` (the shared implementation). + +use crate::state::AppState; +use ide_core::IdeResult; +use ide_host::environment::EnvReloaded; +use tauri::{State, Window}; + +/// Reload Environment: new child processes get the login shell's current environment +/// (and the project's `remoteEnv` on a remote host); running ones keep theirs. +#[tauri::command] +#[specta::specta] +pub async fn env_reload(window: Window, state: State<'_, AppState>) -> IdeResult { + ide_host::ops::env::reload(&state.host, window.label()).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7cf4abe3..9f4ea554 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ pub mod debug; pub mod deploy; pub mod doc; pub mod docker; +pub mod env; pub mod fs; pub mod github; pub mod history; @@ -423,6 +424,7 @@ pub fn builder() -> tauri_specta::Builder { crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, crate::remote_dev::remote_invoke, + env::env_reload, db::db_test, db::db_connected, db::db_connect, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 676a8664..d9754a0d 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -219,6 +219,7 @@ pub fn spec(os: Os) -> Vec { SEP, act("remote.connect", "Remote Development…"), act("remote.disconnect", "Disconnect from Remote Host"), + act("remote.reloadEnvironment", "Reload Environment"), SEP, act("file.save", "Save All"), act("file.reloadAllFromDisk", "Reload All from Disk"), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index ffbd909e..29f5ffb3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -84,6 +84,7 @@ impl AppState { }), owner: Self::owner_window, runtime: tauri::async_runtime::handle().inner().clone(), + project_environment: false, })); Ok(Self { recent: RecentProjects::load(&config_dir), diff --git a/src/bindings.ts b/src/bindings.ts index 6c9ddf95..b827d537 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -928,6 +928,11 @@ export const commands = { * `on_channel` tagged with the id. */ remoteInvoke: (cmd: string, args: string, onChannel: Channel) => typedError(__TAURI_INVOKE("remote_invoke", { cmd, args, onChannel })), + /** + * Reload Environment: new child processes get the login shell's current environment + * (and the project's `remoteEnv` on a remote host); running ones keep theirs. + */ + envReload: () => typedError(__TAURI_INVOKE("env_reload")), /** Test Connection: connects with unsaved settings and returns the server version. */ dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError(__TAURI_INVOKE("db_test", { source, password, sshSecret })), dbConnected: () => typedError(__TAURI_INVOKE("db_connected")), @@ -2739,6 +2744,16 @@ export type EncodingSettings = { export type EntryKind = "file" | "dir" | "symlink"; +/** What Reload Environment changed. */ +export type EnvReloaded = { + /** Names of the variables new child processes now get differently (added, changed or removed). */ + changed: string[], + /** The `devcontainer.json` whose `remoteEnv` applies, if any. */ + devcontainer: string | null, + /** Whether the login shell was read (it isn't on Windows). */ + shellRead: boolean, +}; + export type ErrorKind = "notFound" | "alreadyExists" | "permissionDenied" | "invalidInput" | /** Document version mismatch; the UI must resync the full text. */ "outOfSync" | "cancelled" | diff --git a/src/features/remote-dev/environment.test.ts b/src/features/remote-dev/environment.test.ts new file mode 100644 index 00000000..f0cbb503 --- /dev/null +++ b/src/features/remote-dev/environment.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { EnvReloaded, IdeError } from "@/bindings"; +import type { NotificationService } from "@/workbench/contributions"; + +type Outcome = { status: "ok"; data: EnvReloaded } | { status: "error"; error: IdeError }; +let outcome: Outcome; +const envReload = vi.fn(async () => outcome); +const lspServers = vi.fn(async () => ({ status: "ok" as const, data: [{ id: 1 }, { id: 2 }] })); +const lspRestart = vi.fn(async (_id: number) => ({ status: "ok" as const, data: null })); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { ...actual, commands: { ...actual.commands, envReload, lspServers, lspRestart } }; +}); + +const { reloadEnvironment, reloadSummary } = await import("./environment"); +const { provideNotificationService } = await import("@/workbench/contributions"); + +type Note = Parameters[0]; +let notes: Note[]; + +beforeEach(() => { + notes = []; + provideNotificationService({ notify: (n) => void notes.push(n) }); + vi.clearAllMocks(); +}); + +const reloaded = (changed: string[]): EnvReloaded => ({ changed, devcontainer: null, shellRead: true }); + +describe("Reload Environment", () => { + it("says what changed and that running processes keep their environment", () => { + expect(reloadSummary(reloaded([]))).toBe( + "No variables changed. Terminals and processes already running keep their old environment; new ones get the new one.", + ); + expect(reloadSummary(reloaded(["JAVA_HOME", "PATH"]))).toMatch(/^Changed: JAVA_HOME, PATH\. Terminals/); + const many = Array.from({ length: 10 }, (_, i) => `V${i}`); + expect(reloadSummary(reloaded(many))).toMatch(/^Changed: V0, V1, V2, V3, V4, V5, V6, V7 and 2 more\./); + }); + + it("offers to restart the language servers when something changed", async () => { + outcome = { status: "ok", data: reloaded(["PATH"]) }; + await reloadEnvironment(); + expect(envReload).toHaveBeenCalledOnce(); + expect(notes).toHaveLength(1); + expect(notes[0]).toMatchObject({ level: "info", title: "Environment reloaded" }); + const restart = notes[0]!.actions!.find((a) => a.label === "Restart Language Servers")!; + restart.run(); + await vi.waitFor(() => expect(lspRestart).toHaveBeenCalledTimes(2)); + expect(lspRestart.mock.calls.map((c) => c[0])).toEqual([1, 2]); + }); + + it("has nothing to restart when nothing changed", async () => { + outcome = { status: "ok", data: reloaded([]) }; + await reloadEnvironment(); + expect(notes[0]!.actions).toEqual([]); + }); + + it("reports a failure as an error", async () => { + outcome = { + status: "error", + error: { kind: "unavailable", message: "could not read the environment of the login shell (/bin/zsh)" }, + } as Outcome; + await reloadEnvironment(); + expect(notes[0]).toMatchObject({ level: "error", title: "Could not reload the environment" }); + expect(String(notes[0]!.body)).toContain("login shell"); + }); +}); diff --git a/src/features/remote-dev/environment.ts b/src/features/remote-dev/environment.ts new file mode 100644 index 00000000..34355108 --- /dev/null +++ b/src/features/remote-dev/environment.ts @@ -0,0 +1,64 @@ +/** + * Reload Environment: new terminals, runs, language servers and tools get the login + * shell's current environment (and, on a remote host, the project's `devcontainer.json` + * `remoteEnv`) without restarting the backend. What is already running keeps its own. + */ +import { tx } from "@/platform/i18n"; +import { call, commands, type EnvReloaded } from "@/platform/ipc"; +import { notifications } from "@/workbench/contributions"; + +const KEY = "remote.reloadEnvironment"; +/** Variable names listed in the notification before "and N more". */ +const LISTED = 8; + +/** The notification text for a reload result. */ +export function reloadSummary(r: EnvReloaded): string { + const names = r.changed.slice(0, LISTED).join(", "); + const more = r.changed.length - LISTED; + const changed = + r.changed.length === 0 + ? tx("No variables changed.") + : more > 0 + ? tx`Changed: ${names} and ${more} more.` + : tx`Changed: ${names}.`; + const keep = tx("Terminals and processes already running keep their old environment; new ones get the new one."); + return [changed, keep].join(" "); +} + +async function restartLanguageServers() { + try { + const servers = await call(commands.lspServers()); + await Promise.all(servers.map((s) => call(commands.lspRestart(s.id)))); + } catch (e) { + notifications().notify({ + group: "lsp", + level: "error", + title: "Failed to restart language server", + body: e instanceof Error ? e.message : String(e), + }); + } +} + +export async function reloadEnvironment() { + let result: EnvReloaded; + try { + result = await call(commands.envReload()); + } catch (e) { + notifications().notify({ + key: KEY, + level: "error", + title: "Could not reload the environment", + body: e instanceof Error ? e.message : String(e), + }); + return; + } + notifications().notify({ + key: KEY, + level: "info", + title: "Environment reloaded", + body: reloadSummary(result), + actions: result.changed.length + ? [{ label: "Restart Language Servers", run: () => void restartLanguageServers() }] + : [], + }); +} diff --git a/src/features/remote-dev/index.ts b/src/features/remote-dev/index.ts index 1ba6bea2..f5890596 100644 --- a/src/features/remote-dev/index.ts +++ b/src/features/remote-dev/index.ts @@ -7,6 +7,7 @@ import { registerActions } from "@/platform/actions"; import { disconnectRemote } from "@/platform/remote"; import { registerOverlay } from "@/workbench/contributions"; import { ConnectHost } from "./ConnectDialog"; +import { reloadEnvironment } from "./environment"; import { closeConnectDialog, openConnectDialog } from "./model"; export function activate(): () => void { @@ -30,6 +31,16 @@ export function activate(): () => void { when: "projectOpen", run: () => disconnectRemote(), }, + { + id: "remote.reloadEnvironment", + title: "Reload Environment", + description: + "Reads the login shell's environment (and devcontainer.json remoteEnv on a remote host) again for new terminals and processes.", + category: "File", + keywords: ["env", "environment", "variables", "path", "shell", "remoteEnv", "devcontainer", "reload"], + when: "projectOpen", + run: () => reloadEnvironment(), + }, ]), ]; return () => { diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index deea7931..26285f06 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -165,6 +165,7 @@ "menu.file.newProjectsSettings": "Settings for New Projects…", "menu.file.toggleReadOnly": "Make File Read-only / Writable", "menu.file.closeProject": "Close Project", + "menu.remote.reloadEnvironment": "Reload Environment", "menu.file.new": "New…", "menu.file.newScratchFile": "New Scratch File…", "menu.file.openInNewWindow": "Open in New Window…", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ea3582ae..57d441b0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4165,6 +4165,17 @@ "Remote Development…": "远程开发…", "Opens a project folder on an SSH host; the IDE backend runs on that host.": "打开 SSH 主机上的项目文件夹;IDE 后端在该主机上运行。", "Disconnect from Remote Host": "断开与远程主机的连接", + "Reload Environment": "重新加载环境", + "Reads the login shell's environment (and devcontainer.json remoteEnv on a remote host) again for new terminals and processes.": "为新的终端和进程重新读取登录 shell 的环境(远程主机上还包括 devcontainer.json 的 remoteEnv)。", + "No variables changed.": "没有变量发生变化。", + "Changed: {names} and {more} more.": "已更改:{names},以及另外 {more} 个。", + "Changed: {names}.": "已更改:{names}。", + "Terminals and processes already running keep their old environment; new ones get the new one.": "已在运行的终端和进程保留原有环境;新启动的将使用新环境。", + "Restart Language Servers": "重启语言服务器", + "Could not reload the environment": "无法重新加载环境", + "Environment reloaded": "环境已重新加载", + "devcontainer.json was not applied": "未应用 devcontainer.json", + "postStartCommand failed": "postStartCommand 失败", "Opens a terminal tab connected to an SSH configuration (`arg`: its id; otherwise a chooser).": "打开连接到 SSH 配置的终端标签页(`arg`:配置 id;否则显示选择器)。", "Connecting to the host…": "正在连接主机…", "Checking the IDE backend on the host…": "正在检查主机上的 IDE 后端…", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index d19fb764..3191894f 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -165,6 +165,7 @@ "menu.file.newProjectsSettings": "新项目的设置…", "menu.file.toggleReadOnly": "将文件设为只读/可写", "menu.file.closeProject": "关闭项目", + "menu.remote.reloadEnvironment": "重新加载环境", "menu.file.new": "新建…", "menu.file.newScratchFile": "新建临时文件…", "menu.file.openInNewWindow": "在新窗口中打开…",