mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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
This commit is contained in:
@@ -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<char> = 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\",");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+107
-25
@@ -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<OsString>)> {
|
||||
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<Item = (OsString, OsString)> {
|
||||
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<OsStr>) -> Option<OsString> {
|
||||
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<OsString>)],
|
||||
) -> 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<OsString>)> {
|
||||
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<OsString>)> {
|
||||
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<Os
|
||||
fixes
|
||||
}
|
||||
|
||||
static SHELL_ENV: OnceLock<Vec<(OsString, OsString)>> = OnceLock::new();
|
||||
type Vars = Arc<Vec<(OsString, OsString)>>;
|
||||
type Changes = Arc<Vec<(OsString, Option<OsString>)>>;
|
||||
|
||||
/// Records the login shell's environment, read once at startup off the main thread.
|
||||
static SHELL_ENV: RwLock<Option<Vars>> = RwLock::new(None);
|
||||
static OVERLAY: RwLock<Option<Changes>> = 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<Vars> {
|
||||
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<Item = (&'static OsStr, &'static OsStr)> {
|
||||
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<Item = (OsString, OsString)> {
|
||||
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<OsString>)>) {
|
||||
*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)> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OsString>)>;
|
||||
|
||||
/// 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<String>,
|
||||
/// The `devcontainer.json` whose `remoteEnv` applies, if any.
|
||||
pub devcontainer: Option<String>,
|
||||
/// 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<Vec<(OsString, OsString)>>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<State>,
|
||||
/// 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<LifecycleCommand> {
|
||||
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<devcontainer::DevContainer> {
|
||||
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<EnvReloaded> {
|
||||
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<OsString>,
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>, 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::<String>::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<String> = 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::<String>();
|
||||
let pipes: [Option<Box<dyn Read + Send>>; 2] = [
|
||||
child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
|
||||
child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>),
|
||||
];
|
||||
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<OsString>) {
|
||||
(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<Vec<IdeEvent>>);
|
||||
impl EventSink for Events {
|
||||
fn emit(&self, e: IdeEvent) {
|
||||
self.0.lock().push(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cmd: LifecycleCommand, root: &Path) -> Vec<IdeEvent> {
|
||||
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<String> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<ide_ssh::SshManager>,
|
||||
/// SFTP deployment, remote file editing and runs on run targets.
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// 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();
|
||||
|
||||
@@ -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<Host>, window: &str) -> IdeResult<EnvReloaded> {
|
||||
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 });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<impl FnOnce() + use<
|
||||
if std::env::var_os("OPENIDEA_SKIP_SHELL_ENV").is_some() {
|
||||
return None;
|
||||
}
|
||||
let cache = dirs::cache_dir().map(|d| d.join(identifier).join("login-path"));
|
||||
record_startup_env();
|
||||
let cache = path_cache(identifier);
|
||||
let cached = cache.as_ref().and_then(|c| std::fs::read_to_string(c).ok()).filter(|p| !p.trim().is_empty());
|
||||
let refresh = cached.is_some();
|
||||
let path = match cached {
|
||||
Some(path) => 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<impl FnOnce() + use<
|
||||
let cache = cache.filter(|_| refresh)?;
|
||||
Some(move || {
|
||||
std::thread::spawn(move || {
|
||||
let Some(env) = read_login_env(Duration::from_secs(10)) else { return };
|
||||
let Some(env) = read_login_env(&login_shell(), Duration::from_secs(10)) else { return };
|
||||
if let Some(fresh) = login_path(&env) {
|
||||
write_cache(&cache, &fresh);
|
||||
}
|
||||
@@ -52,6 +57,82 @@ pub fn inherit_login_shell_path(identifier: &str) -> Option<impl FnOnce() + use<
|
||||
})
|
||||
}
|
||||
|
||||
/// This process's environment before it took anything from the login shell: what a
|
||||
/// shell read later starts from.
|
||||
#[cfg(unix)]
|
||||
static STARTUP_ENV: std::sync::OnceLock<Vec<(OsString, OsString)>> = 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<Option<Vec<(OsString, OsString)>>, 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<Option<Vec<(std::ffi::OsString, std::ffi::OsString)>>, 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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
#[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<Vec<(OsString, OsString)>> {
|
||||
pub(crate) fn read_login_env(shell: &str, timeout: std::time::Duration) -> Option<Vec<(OsString, OsString)>> {
|
||||
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<OsString> = 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<Vec<(OsString, OsStrin
|
||||
pub fn inherit_login_shell_path(_identifier: &str) -> Option<fn()> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ fn host(base: &std::path::Path) -> Arc<Host> {
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<IdeEvent>>);
|
||||
|
||||
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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -227,83 +227,11 @@ fn detect_node(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
|
||||
// ---------------------------------------------------------------- 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<char> = 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<RunConfig>) {
|
||||
let Some(path) = first_existing(root, &["deno.json", "deno.jsonc"]) else { return };
|
||||
let Some(json) = read(&path).and_then(|s| serde_json::from_str::<Json>(&strip_jsonc(&s)).ok()) else { return };
|
||||
let Some(json) = read(&path).and_then(|s| serde_json::from_str::<Json>(&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();
|
||||
|
||||
@@ -149,18 +149,24 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
/// debug adapters and terminals find managed tools. Call once at startup,
|
||||
/// before other threads start (it mutates the process environment).
|
||||
pub fn extend_process_path(app_data: &Path) {
|
||||
let current = std::env::var_os("PATH").unwrap_or_default();
|
||||
let extended = path_with_tools(app_data, ¤t);
|
||||
if extended != current {
|
||||
// SAFETY: documented to run at startup before any other thread reads the environment.
|
||||
unsafe { std::env::set_var("PATH", extended) };
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` (a `PATH` value) with [`tools_bin_dirs`] in front, those it lacks.
|
||||
pub fn path_with_tools(app_data: &Path, path: &std::ffi::OsStr) -> OsString {
|
||||
let mut dirs = tools_bin_dirs(app_data);
|
||||
let current: Vec<PathBuf> =
|
||||
std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default();
|
||||
let current: Vec<PathBuf> = 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`).
|
||||
|
||||
@@ -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<String, Option<String>>,
|
||||
pub post_start: Option<LifecycleCommand>,
|
||||
}
|
||||
|
||||
/// 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<String>),
|
||||
/// 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<Option<Self>, 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<String> = items
|
||||
.iter()
|
||||
.map(|i| i.as_str().map(str::to_owned).ok_or("array items must be strings"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
(!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<Vec<String>> {
|
||||
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<PathBuf> {
|
||||
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<Option<DevContainer>, 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<DevContainer, String> {
|
||||
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>) -> 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<String>) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<EnvReloaded> {
|
||||
ide_host::ops::env::reload(&state.host, window.label()).await
|
||||
}
|
||||
@@ -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<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -219,6 +219,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -928,6 +928,11 @@ export const commands = {
|
||||
* `on_channel` tagged with the id.
|
||||
*/
|
||||
remoteInvoke: (cmd: string, args: string, onChannel: Channel<ChannelFrame>) => typedError<string, IdeError>(__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<EnvReloaded, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })),
|
||||
dbConnected: () => typedError<string[], IdeError>(__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" |
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, envReload, lspServers, lspRestart } };
|
||||
});
|
||||
|
||||
const { reloadEnvironment, reloadSummary } = await import("./environment");
|
||||
const { provideNotificationService } = await import("@/workbench/contributions");
|
||||
|
||||
type Note = Parameters<NotificationService["notify"]>[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");
|
||||
});
|
||||
});
|
||||
@@ -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() }]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 后端…",
|
||||
|
||||
@@ -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": "在新窗口中打开…",
|
||||
|
||||
Reference in New Issue
Block a user