diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 1b4596287b..c78089ba23 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -225,9 +225,18 @@ impl App { ); return false; }; + // Resume runs the agent from a freshly spawned shell, so it needs the + // pane's interpreter environment for the same reason restore does. + let extra_env = self + .state + .terminals + .get(&terminal_id) + .and_then(|terminal| terminal.virtual_env.as_ref()) + .map(|activation| activation.launch_env(std::env::var("PATH").ok().as_deref())) + .unwrap_or_default(); let Some(launch_env) = self .find_pane(pane_id) - .and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, Vec::new())) + .and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, extra_env)) else { return false; }; diff --git a/src/pane.rs b/src/pane.rs index 7669fc13cd..d61a101a07 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2880,6 +2880,32 @@ impl PaneRuntime { None } } + + /// Interpreter environment the pane is currently working in, if any. + /// + /// A shell mutates its own environment in place when it activates one, and + /// that mutation is not visible from outside the process. What is visible + /// is the environment a process was launched with, so this reads the + /// foreground process group leader — the command the shell started, which + /// carries any activation that was in effect when it began. + /// + /// Session saves call this for every pane, so it stays at one process + /// lookup plus one environment read. In particular it does not ask the PTY + /// actor for the foreground group: that is a round trip which blocks while + /// the reader is paused for a handoff. + pub fn foreground_virtual_env(&self) -> Option { + let pid = self.child_pid.load(Ordering::Acquire); + if pid == 0 { + return None; + } + let foreground = crate::platform::foreground_process_group_id(pid)?; + // An idle prompt leaves the shell itself in the foreground, and the + // shell's own activation is the part that cannot be read. + if foreground == pid { + return None; + } + crate::platform::process_virtual_env(foreground) + } } #[cfg(test)] diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9e1..f4c3e24e4a 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -517,15 +517,39 @@ fn restore_tab( let public_pane_id = old_pane_id .and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id)) .map(String::as_str); + // The pane comes back from a shell that never activated anything, so + // re-enter the interpreter environment it was working in before. + // + // An environment that has since been removed is dropped rather than + // re-entered. Pointing PATH at a missing directory while also telling + // conda not to fall back to base would leave the pane with no working + // interpreter, which is worse than restoring without one. + let restored_virtual_env = saved_pane + .and_then(|pane| pane.virtual_env.as_ref()) + .and_then(|snapshot| snapshot.to_activation()) + .filter(|activation| { + let exists = activation.prefix.is_dir(); + if !exists { + warn!( + prefix = %activation.prefix.display(), + "saved pane environment is gone; restoring without it" + ); + } + exists + }); + let extra_env = restored_virtual_env + .as_ref() + .map(|activation| activation.launch_env(std::env::var("PATH").ok().as_deref())) + .unwrap_or_default(); let launch_env = public_pane_id .map(|pane_id| { - PaneLaunchEnv::from_extra(Vec::new()).with_identity( + PaneLaunchEnv::from_extra(extra_env.clone()).with_identity( workspace_id.to_string(), crate::workspace::public_tab_id_for_number(workspace_id, number), pane_id.to_string(), ) }) - .unwrap_or_default(); + .unwrap_or_else(|| PaneLaunchEnv::from_extra(extra_env)); let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id)); let was_imported = imported_runtime.is_some(); let pending_native_agent_restore = if was_imported { @@ -537,6 +561,7 @@ fn restore_tab( let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) .with_pending_agent_resume_plan(plan); + terminal.virtual_env = restored_virtual_env; if let Some(label) = saved_label { terminal.set_manual_label(label); } @@ -629,6 +654,7 @@ fn restore_tab( Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); + terminal.virtual_env = restored_virtual_env; if was_imported { if let Some(argv) = saved_launch_argv { terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); @@ -1198,6 +1224,7 @@ mod tests { value: "opencode-session".into(), }), launch_argv: None, + virtual_env: None, }, )]), zoomed: false, @@ -1279,6 +1306,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ), ( @@ -1290,6 +1318,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ), ]), @@ -1343,6 +1372,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ) }; @@ -1358,6 +1388,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + virtual_env: None, }; let snapshot = SessionSnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, @@ -1441,6 +1472,98 @@ mod tests { .all(|detail| detail.pane_id != agent_pane)); } + fn restore_pane_with_virtual_env( + prefix: &std::path::Path, + ) -> Option { + let cwd = std::env::current_dir().unwrap(); + let snapshot = SessionSnapshot { + version: super::super::snapshot::SNAPSHOT_VERSION, + workspaces: vec![WorkspaceSnapshot { + id: Some("w1".into()), + custom_name: None, + identity_cwd: cwd.clone(), + worktree_space: None, + public_pane_numbers: HashMap::from([(10, 1)]), + next_public_pane_number: 2, + public_tab_numbers: vec![1], + next_public_tab_number: 2, + tabs: vec![TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(10), + panes: HashMap::from([( + 10, + super::super::snapshot::PaneSnapshot { + cwd: cwd.clone(), + label: None, + agent_name: None, + managed_agent_kind: None, + agent_session: None, + launch_argv: None, + virtual_env: Some(super::super::snapshot::PaneVirtualEnvSnapshot { + kind: "conda".into(), + prefix: prefix.to_path_buf(), + name: Some("web".into()), + }), + }, + )]), + zoomed: false, + focused: Some(10), + root_pane: Some(10), + }], + active_tab: 0, + }], + active: Some(0), + selected: 0, + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: Default::default(), + }; + let (events, _event_rx) = mpsc::channel(4); + + let (workspaces, terminals, _runtimes) = restore( + &snapshot, + None, + 24, + 80, + 0, + test_restore_shell(), + crate::config::ShellModeConfig::NonLogin, + false, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ); + + let workspace = workspaces.first().expect("workspace should restore"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = &workspace.tabs[0].panes[&pane].attached_terminal_id; + terminals[terminal_id].virtual_env.clone() + } + + #[tokio::test] + async fn cold_restore_carries_the_saved_virtual_env_onto_the_terminal() { + let prefix = std::env::current_dir().unwrap(); + + let activation = restore_pane_with_virtual_env(&prefix) + .expect("restored pane should carry its environment"); + + assert_eq!(activation.kind, crate::platform::VirtualEnvKind::Conda); + assert_eq!(activation.prefix, prefix); + assert_eq!(activation.name.as_deref(), Some("web")); + } + + #[tokio::test] + async fn cold_restore_drops_a_virtual_env_that_no_longer_exists() { + // Re-entering a deleted environment would put a missing directory on + // PATH and suppress conda's fallback to base, leaving the pane with no + // interpreter at all. + let prefix = std::env::current_dir() + .unwrap() + .join("herdr-environment-that-does-not-exist"); + + assert!(restore_pane_with_virtual_env(&prefix).is_none()); + } + #[test] fn legacy_restore_precomputes_missing_public_pane_numbers() { let cwd = std::env::current_dir().unwrap(); @@ -1509,6 +1632,7 @@ mod tests { value: "codex-session".into(), }), launch_argv: None, + virtual_env: None, }, )]), zoomed: false, @@ -1670,6 +1794,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ); let history = SessionHistorySnapshot { diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 39f790ddda..3690581438 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -107,6 +107,40 @@ pub struct PaneSnapshot { pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_env: Option, +} + +/// The interpreter environment a pane was working in. +/// +/// Only the prefix and its name are stored. Rebuilding `PATH` from the prefix +/// on restore keeps a restored pane on the current `PATH` instead of pinning it +/// to whatever the machine looked like when the snapshot was written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneVirtualEnvSnapshot { + /// `conda` or `venv`. + pub kind: String, + pub prefix: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl PaneVirtualEnvSnapshot { + fn from_activation(activation: &crate::platform::VirtualEnvActivation) -> Self { + Self { + kind: activation.kind.as_str().to_string(), + prefix: activation.prefix.clone(), + name: activation.name.clone(), + } + } + + pub(crate) fn to_activation(&self) -> Option { + Some(crate::platform::VirtualEnvActivation { + kind: crate::platform::VirtualEnvKind::from_str(&self.kind)?, + prefix: self.prefix.clone(), + name: self.name.clone(), + }) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -338,6 +372,20 @@ fn capture_tab( }) .unwrap_or_default(); let launch_argv = terminal.and_then(|terminal| terminal.launch_argv.clone()); + // A live pane only reports an activation while it is running + // something, so an idle pane falls back to the one it was restored + // into. Without that, saving a restored session at an idle prompt + // would lose the environment on the next restore. + // + // That fallback is sticky: deactivating and then saving at an idle + // prompt brings the environment back on the next restore. A shell + // deactivates in its own process, and that is not observable from + // outside it, so there is nothing here to notice the change. + let virtual_env = tab + .virtual_env_for_pane(*id, terminal_runtimes) + .or_else(|| terminal.and_then(|terminal| terminal.virtual_env.clone())) + .as_ref() + .map(PaneVirtualEnvSnapshot::from_activation); let agent_session = terminal.and_then(|terminal| { if let Some(authority) = terminal.hook_authority.as_ref() { if let Some(session_ref) = authority.session_ref.as_ref() { @@ -368,6 +416,7 @@ fn capture_tab( managed_agent_kind, agent_session, launch_argv, + virtual_env, }, ); } @@ -648,6 +697,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ); panes.insert( @@ -659,6 +709,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ); @@ -718,6 +769,77 @@ mod tests { assert_eq!(restored.sidebar_section_split, Some(0.5)); } + #[test] + fn pane_virtual_env_survives_a_snapshot_round_trip() { + let pane = PaneSnapshot { + cwd: PathBuf::from("/home/can/Projects/herdr"), + label: None, + agent_name: None, + managed_agent_kind: None, + agent_session: None, + launch_argv: None, + virtual_env: Some(PaneVirtualEnvSnapshot { + kind: "conda".into(), + prefix: PathBuf::from("/opt/conda/envs/web"), + name: Some("web".into()), + }), + }; + + let json = serde_json::to_string(&pane).unwrap(); + let restored: PaneSnapshot = serde_json::from_str(&json).unwrap(); + + let activation = restored + .virtual_env + .as_ref() + .and_then(PaneVirtualEnvSnapshot::to_activation) + .expect("expected an activation"); + assert_eq!(activation.kind, crate::platform::VirtualEnvKind::Conda); + assert_eq!(activation.prefix, PathBuf::from("/opt/conda/envs/web")); + assert_eq!(activation.name.as_deref(), Some("web")); + } + + #[test] + fn pane_without_a_virtual_env_stays_absent_from_the_snapshot() { + let pane = PaneSnapshot { + cwd: PathBuf::from("/home/can/Projects/herdr"), + label: None, + agent_name: None, + managed_agent_kind: None, + agent_session: None, + launch_argv: None, + virtual_env: None, + }; + + let json = serde_json::to_value(&pane).unwrap(); + + assert!(json.get("virtual_env").is_none()); + } + + #[test] + fn snapshot_written_before_virtual_env_still_parses() { + let pane: PaneSnapshot = serde_json::from_value(serde_json::json!({ + "cwd": "/home/can/Projects/herdr", + "label": "api", + })) + .unwrap(); + + assert!(pane.virtual_env.is_none()); + } + + #[test] + fn unknown_virtual_env_kind_restores_as_no_activation() { + // A newer herdr could record a tool this build does not know how to + // re-enter; dropping it leaves the pane on the inherited environment + // instead of building a broken PATH from it. + let snapshot = PaneVirtualEnvSnapshot { + kind: "poetry".into(), + prefix: PathBuf::from("/opt/poetry/envs/web"), + name: None, + }; + + assert!(snapshot.to_activation().is_none()); + } + #[test] fn current_session_fixture_parses() { let snap = parse_snapshot(session_fixture("current-herdr")).unwrap(); @@ -1207,6 +1329,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ); panes.insert( @@ -1220,6 +1343,7 @@ mod tests { managed_agent_kind: None, agent_session: None, launch_argv: None, + virtual_env: None, }, ); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 30bea5ebc9..672fea7b5a 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -71,6 +71,11 @@ pub fn process_cwd(_pid: u32) -> Option { None } +/// Unsupported platform stub. +pub fn process_virtual_env(_pid: u32) -> Option { + None +} + /// Unsupported platform stub. pub fn session_processes(_child_pid: u32) -> Vec { Vec::new() diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 76568ad0b5..371ac126a4 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -383,6 +383,15 @@ pub fn process_agent_hint(pid: u32) -> Option { super::parse_agent_env_hint(&environ) } +/// Read the interpreter environment a process was started in. +pub fn process_virtual_env(pid: u32) -> Option { + if pid == 0 { + return None; + } + let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + super::parse_virtual_env_activation(&environ) +} + pub fn session_processes(child_pid: u32) -> Vec { let Some(session_id) = process_session_id(child_pid) else { return Vec::new(); diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e9c89ede2b..1526ac778d 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -792,6 +792,19 @@ pub fn process_agent_hint(pid: u32) -> Option { super::parse_agent_env_hint(procargs2_env(&buf)?) } +/// Read the interpreter environment a process was started in. +/// +/// The kernel withholds the environment block for platform binaries, so this +/// returns `None` for the pane's own `/bin/zsh`. Agents run from user-installed +/// binaries, which do report it. +pub fn process_virtual_env(pid: u32) -> Option { + if pid == 0 { + return None; + } + let buf = kern_procargs2(pid)?; + super::parse_virtual_env_activation(procargs2_env(&buf)?) +} + fn procargs2_argv_start(rest: &[u8]) -> Option { let exec_end = rest.iter().position(|&byte| byte == 0)?; let mut pos = exec_end; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 38d797d137..8c19429087 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -310,6 +310,172 @@ pub(crate) fn parse_agent_env_hint(environ: &[u8]) -> Option &'static str { + match self { + Self::Conda => "conda", + Self::Venv => "venv", + } + } + + pub(crate) fn from_str(value: &str) -> Option { + match value { + "conda" => Some(Self::Conda), + "venv" => Some(Self::Venv), + _ => None, + } + } +} + +/// An activated interpreter environment observed on a live process. +/// +/// `conda activate` and the `venv`/`virtualenv` activate scripts both work the +/// same way: export a prefix variable and put that prefix's binary directory +/// first on `PATH`. The prefix is therefore the whole activation — everything +/// else is derived from it, which is why only the prefix and its display name +/// are recorded here instead of a snapshot of the process `PATH`. A `PATH` +/// captured on one run goes stale as soon as the user installs, removes, or +/// upgrades anything outside the environment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VirtualEnvActivation { + pub kind: VirtualEnvKind, + pub prefix: std::path::PathBuf, + /// `CONDA_DEFAULT_ENV`, or the prompt label a venv was activated with. + pub name: Option, +} + +impl VirtualEnvActivation { + /// Directories the activation puts in front of `PATH`, nearest first. + /// + /// Conda spreads a Windows environment over several directories and its + /// activate script adds all of them, so a single `Scripts` entry is not + /// enough to make the environment usable there. + pub(crate) fn path_entries(&self) -> Vec { + let prefix = &self.prefix; + match (self.kind, cfg!(windows)) { + (VirtualEnvKind::Conda, true) => vec![ + prefix.clone(), + prefix.join("Library").join("mingw-w64").join("bin"), + prefix.join("Library").join("usr").join("bin"), + prefix.join("Library").join("bin"), + prefix.join("Scripts"), + prefix.join("bin"), + ], + (VirtualEnvKind::Venv, true) => vec![prefix.join("Scripts")], + (_, false) => vec![prefix.join("bin")], + } + } + + /// Environment overrides that re-enter this environment in a fresh process. + /// + /// `base_path` is the `PATH` the new process would otherwise inherit. The + /// activation entries are prepended to it, skipping any that survived, so + /// repeated restores cannot grow `PATH` without bound. + /// + /// `CONDA_SHLVL` is pinned to 1 rather than restored from the observed + /// process: nesting depth belongs to the shell that stacked the + /// activations, and a restored pane starts from an unactivated shell. + /// + /// Conda's automatic base activation is turned off for the restored shell. + /// A pane comes back as an interactive login shell, so it re-runs the + /// user's rc files, and conda's init hook activates base by default. That + /// runs after this environment is handed in and replaces it — it shadows a + /// restored venv's interpreter too, because base lands ahead of the venv on + /// `PATH`. Both spellings are set because conda renamed the setting in 25.x + /// and older releases only understand the previous one. Panes without a + /// recorded environment are untouched and still auto-activate base. + pub(crate) fn launch_env(&self, base_path: Option<&str>) -> Vec<(String, String)> { + let mut env = match self.kind { + VirtualEnvKind::Conda => { + let mut vars = vec![( + "CONDA_PREFIX".to_string(), + self.prefix.to_string_lossy().into_owned(), + )]; + if let Some(name) = &self.name { + vars.push(("CONDA_DEFAULT_ENV".to_string(), name.clone())); + } + vars.push(("CONDA_SHLVL".to_string(), "1".to_string())); + vars + } + VirtualEnvKind::Venv => { + let mut vars = vec![( + "VIRTUAL_ENV".to_string(), + self.prefix.to_string_lossy().into_owned(), + )]; + if let Some(name) = &self.name { + vars.push(("VIRTUAL_ENV_PROMPT".to_string(), name.clone())); + } + vars + } + }; + + env.push(("CONDA_AUTO_ACTIVATE".to_string(), "false".to_string())); + env.push(("CONDA_AUTO_ACTIVATE_BASE".to_string(), "false".to_string())); + + let entries = self.path_entries(); + let inherited = base_path.unwrap_or_default(); + let kept = std::env::split_paths(inherited) + .filter(|dir| !entries.iter().any(|entry| entry == dir)) + .collect::>(); + if let Ok(path) = std::env::join_paths(entries.into_iter().chain(kept)) { + env.push(("PATH".to_string(), path.to_string_lossy().into_owned())); + } + env + } +} + +/// Read an activation out of a NUL-separated environment block. +/// +/// A venv nested inside a conda environment leaves both prefixes exported, and +/// `VIRTUAL_ENV` is the inner one, so it wins when both are present. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn parse_virtual_env_activation(environ: &[u8]) -> Option { + let mut conda_prefix = None; + let mut conda_name = None; + let mut venv_prefix = None; + let mut venv_name = None; + + for record in environ.split(|&byte| byte == 0) { + let Some(index) = record.iter().position(|&byte| byte == b'=') else { + continue; + }; + let (key, value) = record.split_at(index); + let Ok(value) = std::str::from_utf8(&value[1..]) else { + continue; + }; + if value.is_empty() { + continue; + } + match key { + b"CONDA_PREFIX" => conda_prefix = Some(value.to_string()), + b"CONDA_DEFAULT_ENV" => conda_name = Some(value.to_string()), + b"VIRTUAL_ENV" => venv_prefix = Some(value.to_string()), + b"VIRTUAL_ENV_PROMPT" => venv_name = Some(value.to_string()), + _ => {} + } + } + + if let Some(prefix) = venv_prefix { + return Some(VirtualEnvActivation { + kind: VirtualEnvKind::Venv, + prefix: prefix.into(), + name: venv_name, + }); + } + conda_prefix.map(|prefix| VirtualEnvActivation { + kind: VirtualEnvKind::Conda, + prefix: prefix.into(), + name: conda_name, + }) +} + #[cfg(not(any(target_os = "macos", target_os = "windows")))] #[derive(Debug)] pub(crate) struct InputSourceRestore; @@ -363,6 +529,147 @@ impl PrefixInputSource for RealPrefixInputSource { mod tests { use super::*; + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn environ(records: &[&str]) -> Vec { + let mut block = Vec::new(); + for record in records { + block.extend_from_slice(record.as_bytes()); + block.push(0); + } + block + } + + fn path_value(env: &[(String, String)]) -> Vec { + let path = env + .iter() + .find(|(key, _)| key == "PATH") + .map(|(_, value)| value.clone()) + .expect("expected PATH"); + std::env::split_paths(&path).collect() + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn parse_virtual_env_activation_reads_conda_prefix_and_name() { + let activation = parse_virtual_env_activation(&environ(&[ + "PATH=/opt/conda/envs/web/bin:/usr/bin", + "CONDA_PREFIX=/opt/conda/envs/web", + "CONDA_DEFAULT_ENV=web", + "TERM=xterm-256color", + ])) + .expect("expected an activation"); + + assert_eq!(activation.kind, VirtualEnvKind::Conda); + assert_eq!( + activation.prefix, + std::path::PathBuf::from("/opt/conda/envs/web") + ); + assert_eq!(activation.name.as_deref(), Some("web")); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn parse_virtual_env_activation_prefers_venv_nested_in_conda() { + let activation = parse_virtual_env_activation(&environ(&[ + "CONDA_PREFIX=/opt/conda", + "CONDA_DEFAULT_ENV=base", + "VIRTUAL_ENV=/work/api/.venv", + "VIRTUAL_ENV_PROMPT=api", + ])) + .expect("expected an activation"); + + assert_eq!(activation.kind, VirtualEnvKind::Venv); + assert_eq!( + activation.prefix, + std::path::PathBuf::from("/work/api/.venv") + ); + assert_eq!(activation.name.as_deref(), Some("api")); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn parse_virtual_env_activation_ignores_unactivated_and_empty_environments() { + assert!(parse_virtual_env_activation(&environ(&["PATH=/usr/bin", "TERM=xterm"])).is_none()); + // conda exports an empty CONDA_PREFIX after the last `conda deactivate`. + assert!( + parse_virtual_env_activation(&environ(&["CONDA_PREFIX=", "VIRTUAL_ENV="])).is_none() + ); + } + + #[test] + fn launch_env_puts_the_environment_first_on_the_inherited_path() { + let activation = VirtualEnvActivation { + kind: VirtualEnvKind::Venv, + prefix: "/work/api/.venv".into(), + name: None, + }; + + let env = activation.launch_env(Some("/usr/local/bin:/usr/bin")); + + assert!(env.contains(&("VIRTUAL_ENV".to_string(), "/work/api/.venv".to_string()))); + assert_eq!( + path_value(&env), + [ + std::path::PathBuf::from("/work/api/.venv/bin"), + std::path::PathBuf::from("/usr/local/bin"), + std::path::PathBuf::from("/usr/bin"), + ] + ); + } + + #[test] + fn launch_env_does_not_repeat_entries_already_on_the_inherited_path() { + let activation = VirtualEnvActivation { + kind: VirtualEnvKind::Conda, + prefix: "/opt/conda/envs/web".into(), + name: Some("web".to_string()), + }; + + let env = activation.launch_env(Some("/opt/conda/envs/web/bin:/usr/bin")); + + assert_eq!( + path_value(&env), + [ + std::path::PathBuf::from("/opt/conda/envs/web/bin"), + std::path::PathBuf::from("/usr/bin"), + ] + ); + } + + #[test] + fn launch_env_stops_conda_from_auto_activating_base_over_the_restored_env() { + // The restored shell re-runs the user's rc files, and conda's init hook + // activates base by default. Without this the pane comes back on base + // no matter what was handed in — including for a venv, which base + // shadows on PATH. + for kind in [VirtualEnvKind::Conda, VirtualEnvKind::Venv] { + let activation = VirtualEnvActivation { + kind, + prefix: "/work/api/.venv".into(), + name: None, + }; + + let env = activation.launch_env(Some("/usr/bin")); + + assert!(env.contains(&("CONDA_AUTO_ACTIVATE".to_string(), "false".to_string()))); + assert!(env.contains(&("CONDA_AUTO_ACTIVATE_BASE".to_string(), "false".to_string()))); + } + } + + #[test] + fn launch_env_pins_conda_nesting_depth_to_a_single_activation() { + let activation = VirtualEnvActivation { + kind: VirtualEnvKind::Conda, + prefix: "/opt/conda/envs/web".into(), + name: Some("web".to_string()), + }; + + let env = activation.launch_env(None); + + assert!(env.contains(&("CONDA_SHLVL".to_string(), "1".to_string()))); + assert!(env.contains(&("CONDA_DEFAULT_ENV".to_string(), "web".to_string()))); + } + #[test] fn terminal_resize_signal_is_recorded_once_per_delivery() { watch_terminal_resize_signal(); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 55e0fd0b52..55562288fc 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -1034,6 +1034,38 @@ fn read_process_environment(process: HANDLE, address: *const c_void) -> Option Option { + if pid == 0 { + return None; + } + let process = ProcessHandle::open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?; + let parameters = read_process_parameters(process.0)?; + let environment = read_process_environment(process.0, parameters.environment)?; + virtual_env_from_utf16(&environment) +} + +/// A venv nested inside a conda environment leaves both prefixes exported, and +/// `VIRTUAL_ENV` is the inner one, so it wins when both are present. +fn virtual_env_from_utf16(environment: &[u16]) -> Option { + let read = |name: &str| { + environment_variable_from_utf16(environment, name).filter(|value| !value.is_empty()) + }; + + if let Some(prefix) = read("VIRTUAL_ENV") { + return Some(super::VirtualEnvActivation { + kind: super::VirtualEnvKind::Venv, + prefix: prefix.into(), + name: read("VIRTUAL_ENV_PROMPT"), + }); + } + read("CONDA_PREFIX").map(|prefix| super::VirtualEnvActivation { + kind: super::VirtualEnvKind::Conda, + prefix: prefix.into(), + name: read("CONDA_DEFAULT_ENV"), + }) +} + fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option { for variable in environment.split(|unit| *unit == 0) { if variable.is_empty() { diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 8a3da876e0..7fafaff048 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -511,6 +511,10 @@ impl TerminalRuntime { self.0.foreground_cwd() } + pub fn foreground_virtual_env(&self) -> Option { + self.0.foreground_virtual_env() + } + pub fn child_pid(&self) -> Option { self.0.child_pid() } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 5ed5adc48f..605fb37583 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -127,6 +127,13 @@ pub struct TerminalState { pub agent_metadata: HashMap, pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option, + /// Interpreter environment this pane was restored into. + /// + /// A live pane only reports one while it is running a command, so the + /// restored value is kept here for the moments it cannot be observed: a + /// deferred agent resume needs it after the pane is already back, and a + /// save taken at an idle prompt would otherwise drop it. + pub virtual_env: Option, pub terminal_title: Option, pub manual_label: Option, pub agent_name: Option, @@ -160,6 +167,7 @@ impl TerminalState { agent_metadata: HashMap::new(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + virtual_env: None, terminal_title: None, manual_label: None, agent_name: None, diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index 6f0fa75e17..aafade6fbb 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -586,4 +586,15 @@ impl Tab { .get(terminal_id) .and_then(|rt| rt.foreground_cwd()) } + + pub fn virtual_env_for_pane( + &self, + pane_id: PaneId, + terminal_runtimes: &TerminalRuntimeRegistry, + ) -> Option { + let terminal_id = self.terminal_id(pane_id)?; + terminal_runtimes + .get(terminal_id) + .and_then(|rt| rt.foreground_virtual_env()) + } }