diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index 20393ead2..50d8ac8cc 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -16,11 +16,15 @@ pub fn spawn( options.args.clear(); options.env = global.env; } - // Python › "Activate virtualenv": typed into the new shell, like PyCharm. - let activation = - host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| { - ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()) - }); + let (shell, args) = ide_terminal::resolve_shell(options.shell.as_deref(), &options.args); + options.shell = Some(shell.clone()); + options.args = args; + // Python › "Activate virtualenv": typed into the same shell the PTY starts. + let activation = host + .project(window) + .ok() + .filter(|p| p.is_trusted()) + .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); diff --git a/crates/ide-host/tests/terminal_activation.rs b/crates/ide-host/tests/terminal_activation.rs new file mode 100644 index 000000000..9561a23eb --- /dev/null +++ b/crates/ide-host/tests/terminal_activation.rs @@ -0,0 +1,62 @@ +//! Own test process: the inherited shell deliberately differs from the login shell. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +#[test] +fn terminal_activation_uses_the_resolved_default_shell() { + // SAFETY: no runtime or worker threads have been started. + unsafe { std::env::set_var("SHELL", "/not-the-terminal/docker") }; + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + let bin = root.join(".venv/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(root.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap(); + std::fs::write(bin.join("python"), "").unwrap(); + for script in ["activate", "activate.fish", "activate.csh", "activate.nu", "Activate.ps1"] { + std::fs::write(bin.join(script), "echo activated > activation.marker\n").unwrap(); + } + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + for shell in [None, Some("".to_owned()), Some(" \t ".to_owned())] { + let terminal = ops::term::spawn( + &host, + "main", + ide_terminal::TerminalOptions { + cwd: root.clone().into(), + shell, + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + Arc::new(|_| {}), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !std::fs::read_to_string(root.join("activation.marker")).is_ok_and(|s| s.trim() == "activated") { + assert!(Instant::now() < deadline, "default terminal shell must run the activation script"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + host.terminals.kill(terminal.id).unwrap(); + std::fs::remove_file(root.join("activation.marker")).unwrap(); + } + host.shutdown().await; + }); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index f093c8d4c..a397529c2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -735,8 +735,8 @@ fn sh_quote(s: &str) -> String { } /// Command typed into a new terminal to activate the project virtualenv -/// (PyCharm's Tools › Terminal › "Activate virtualenv"), for the given shell. -pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Option { +/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell. +pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option { if !s.languages.python.activate_virtualenv { return None; } @@ -744,12 +744,8 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op if std::env::var_os("VIRTUAL_ENV").is_some_and(|v| Path::new(&v) == venv) { return None; } - let shell = shell - .map(str::to_owned) - .or_else(|| std::env::var("SHELL").ok()) - .or_else(|| std::env::var("COMSPEC").ok()) - .unwrap_or_default(); - let name = Path::new(&shell).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell); + let name = Path::new(name).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); let script = |file: &str| venv.join(bin_dir()).join(file); let line = match name.as_str() { "fish" => format!("source {}", sh_quote(&script("activate.fish").to_string_lossy())), diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 1dcfee226..8ce5a774b 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -319,14 +319,40 @@ fn terminal_activation_for_project_venv() { } let tmp = tempfile::tempdir().unwrap(); let mut s = Settings::default(); - assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None); + assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None); fake_venv(tmp.path(), ".venv"); - let line = terminal_activation(tmp.path(), &s, Some("/bin/zsh")).unwrap(); + let line = terminal_activation(tmp.path(), &s, "/bin/zsh").unwrap(); assert!(line.starts_with("source '") && line.ends_with("/activate'"), "{line}"); - assert_eq!(terminal_activation(tmp.path(), &s, Some("/usr/bin/fish")), None, "no activate.fish"); - assert_eq!(terminal_activation(tmp.path(), &s, Some("docker")), None, "not a shell"); + assert_eq!(terminal_activation(tmp.path(), &s, "/usr/bin/fish"), None, "no activate.fish"); + assert_eq!(terminal_activation(tmp.path(), &s, "docker"), None, "not a shell"); s.languages.python.activate_virtualenv = false; - assert_eq!(terminal_activation(tmp.path(), &s, Some("/bin/zsh")), None); + assert_eq!(terminal_activation(tmp.path(), &s, "/bin/zsh"), None); +} + +#[test] +fn terminal_activation_for_windows_shell_paths() { + let tmp = tempfile::tempdir().unwrap(); + let mut s = Settings::default(); + s.languages.python.interpreter = fake_venv(tmp.path(), ".venv with spaces 'quoted'").to_string_lossy().into_owned(); + let bin = tmp.path().join(".venv with spaces 'quoted'").join(bin_dir()); + for (shell, script, powershell) in [ + (r"C:\Program Files\PowerShell\7\pwsh.exe", "Activate.ps1", true), + (r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "Activate.ps1", true), + (r"C:\Windows\System32\cmd.exe", "activate.bat", false), + ] { + assert_eq!(terminal_activation(tmp.path(), &s, shell), None, "missing {script}"); + let path = bin.join(script); + std::fs::write(&path, "").unwrap(); + let expected = if powershell { + format!("& '{}'", path.to_string_lossy().replace('\'', "''")) + } else { + format!("\"{}\"", path.display()) + }; + assert_eq!(terminal_activation(tmp.path(), &s, shell), Some(expected), "{shell}"); + std::fs::remove_file(path).unwrap(); + } + assert_eq!(terminal_activation(tmp.path(), &s, ""), None); + assert_eq!(terminal_activation(tmp.path(), &s, " "), None); } #[test] diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index d651b23be..9caadf583 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -497,7 +497,7 @@ pub fn available_shells() -> Vec { /// Picks the shell binary and its arguments. A default (or explicitly chosen but /// argument-less) POSIX shell is started as a login shell, like Terminal.app/IDEA. -fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { +pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec) { let shell = shell.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).unwrap_or_else(default_shell); if !args.is_empty() { return (shell, args.to_vec()); @@ -808,8 +808,12 @@ mod tests { assert_eq!((shell.as_str(), args), ("/bin/zsh", vec!["-l".to_owned()])); let (_, args) = resolve_shell(Some("/usr/bin/python3"), &[]); assert!(args.is_empty()); - let (_, args) = resolve_shell(None, &[]); - assert_eq!(args, vec!["-l".to_owned()]); + for configured in [None, Some(""), Some(" \t ")] { + let (shell, args) = resolve_shell(configured, &[]); + assert_eq!(shell, default_shell_path()); + assert_eq!(args, vec!["-l".to_owned()]); + } + assert_eq!(resolve_shell(Some(" /bin/bash "), &["-i".into()]), ("/bin/bash".into(), vec!["-i".into()])); assert_eq!(shell_title("C:\\Program Files\\PowerShell\\7\\pwsh.exe"), "pwsh"); let env = terminal_env(&BTreeMap::from([("TERM".into(), "dumb".into())])); assert_eq!(env["TERM"], "dumb");