дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
1e898367a5
коміт
21925b7f4b
5 files changed
+113
-21
No files matched your search
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Заблокувати користувача