fix(terminal): activate virtualenvs using the resolved terminal shell (#646)

New terminals typed the virtualenv activation command for the wrong shell: on Windows the default terminal is PowerShell, but the activation line was chosen from $COMSPEC (cmd), so PowerShell just echoed "...\activate.bat" and the project interpreter was not activated.

Root cause: terminal_activation guessed the shell from $SHELL / $COMSPEC instead of the shell the PTY actually starts. Same class on macOS/Linux when the inherited $SHELL differs from the login shell the terminal uses, and for a blank configured shell.

Fix: the host resolves the shell once with the terminal's own resolve_shell and uses it both to start the PTY and to pick the activation command; terminal_activation takes the resolved shell and no longer reads the environment. Tests cover PowerShell/cmd paths, blank settings and a host-level run with an unrelated $SHELL. Verified in the app on macOS (default zsh and configured bash); not run on Windows.

Fixes #631
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 12:40:03 +08:00
1 джерело 1e898367a5
коміт 21925b7f4b
5 files changed
+113 -21

No files matched your search

+9 -5
Переглянути файл
@@ -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"));
+62
Переглянути файл
@@ -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;
});
}
+4 -8
Переглянути файл
@@ -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<String> {
/// (Tools › Terminal › "Activate virtualenv"). `shell` must be the resolved PTY shell.
pub fn terminal_activation(root: &Path, s: &Settings, shell: &str) -> Option<String> {
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())),
+31 -5
Переглянути файл
@@ -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]
+7 -3
Переглянути файл
@@ -497,7 +497,7 @@ pub fn available_shells() -> Vec<String> {
/// 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<String>) {
pub fn resolve_shell(shell: Option<&str>, args: &[String]) -> (String, Vec<String>) {
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");