1use crate::{Project, ProjectPath};
2use anyhow::{Context as _, Result};
3use collections::HashMap;
4use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
5use language::LanguageName;
6use remote::RemoteClient;
7use settings::{Settings, SettingsLocation};
8use smol::channel::bounded;
9use std::{
10 env::{self},
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14use task::{Shell, ShellBuilder, SpawnInTerminal};
15use terminal::{
16 TaskState, TaskStatus, Terminal, TerminalBuilder,
17 terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings},
18};
19use util::{ResultExt, paths::RemotePathBuf};
20
21/// The directory inside a Python virtual environment that contains executables
22const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") {
23 "Scripts"
24} else {
25 "bin"
26};
27
28pub struct Terminals {
29 pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
30}
31
32/// Terminals are opened either for the users shell, or to run a task.
33
34#[derive(Debug)]
35pub enum TerminalKind {
36 /// Run a shell at the given path (or $HOME if None)
37 Shell(Option<PathBuf>),
38 /// Run a task.
39 Task(SpawnInTerminal),
40}
41
42impl Project {
43 pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
44 self.active_entry()
45 .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
46 .into_iter()
47 .chain(self.worktrees(cx))
48 .find_map(|tree| tree.read(cx).root_dir())
49 }
50
51 pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
52 let worktree = self.worktrees(cx).next()?;
53 let worktree = worktree.read(cx);
54 if worktree.root_entry()?.is_dir() {
55 Some(worktree.abs_path().to_path_buf())
56 } else {
57 None
58 }
59 }
60
61 pub fn create_terminal(
62 &mut self,
63 kind: TerminalKind,
64 cx: &mut Context<Self>,
65 ) -> Task<Result<Entity<Terminal>>> {
66 let path: Option<Arc<Path>> = match &kind {
67 TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
68 TerminalKind::Task(spawn_task) => {
69 if let Some(cwd) = &spawn_task.cwd {
70 Some(Arc::from(cwd.as_ref()))
71 } else {
72 self.active_project_directory(cx)
73 }
74 }
75 };
76
77 let mut settings_location = None;
78 if let Some(path) = path.as_ref()
79 && let Some((worktree, _)) = self.find_worktree(path, cx)
80 {
81 settings_location = Some(SettingsLocation {
82 worktree_id: worktree.read(cx).id(),
83 path,
84 });
85 }
86 let venv = TerminalSettings::get(settings_location, cx)
87 .detect_venv
88 .clone();
89
90 cx.spawn(async move |project, cx| {
91 let python_venv_directory = if let Some(path) = path {
92 project
93 .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))?
94 .await
95 } else {
96 None
97 };
98 project.update(cx, |project, cx| {
99 project.create_terminal_with_venv(kind, python_venv_directory, cx)
100 })?
101 })
102 }
103
104 pub fn terminal_settings<'a>(
105 &'a self,
106 path: &'a Option<PathBuf>,
107 cx: &'a App,
108 ) -> &'a TerminalSettings {
109 let mut settings_location = None;
110 if let Some(path) = path.as_ref()
111 && let Some((worktree, _)) = self.find_worktree(path, cx)
112 {
113 settings_location = Some(SettingsLocation {
114 worktree_id: worktree.read(cx).id(),
115 path,
116 });
117 }
118 TerminalSettings::get(settings_location, cx)
119 }
120
121 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
122 let path = self.first_project_directory(cx);
123 let remote_client = self.remote_client.as_ref();
124 let settings = self.terminal_settings(&path, cx).clone();
125 let remote_shell = remote_client
126 .as_ref()
127 .and_then(|remote_client| remote_client.read(cx).shell());
128 let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
129 let (command, args) = builder.build(Some(command), &Vec::new());
130
131 let mut env = self
132 .environment
133 .read(cx)
134 .get_cli_environment()
135 .unwrap_or_default();
136 env.extend(settings.env);
137
138 match remote_client {
139 Some(remote_client) => {
140 let command_template =
141 remote_client
142 .read(cx)
143 .build_command(Some(command), &args, &env, None, None)?;
144 let mut command = std::process::Command::new(command_template.program);
145 command.args(command_template.args);
146 command.envs(command_template.env);
147 Ok(command)
148 }
149 None => {
150 let mut command = std::process::Command::new(command);
151 command.args(args);
152 command.envs(env);
153 if let Some(path) = path {
154 command.current_dir(path);
155 }
156 Ok(command)
157 }
158 }
159 }
160
161 pub fn create_terminal_with_venv(
162 &mut self,
163 kind: TerminalKind,
164 python_venv_directory: Option<PathBuf>,
165 cx: &mut Context<Self>,
166 ) -> Result<Entity<Terminal>> {
167 let is_via_remote = self.remote_client.is_some();
168
169 let path: Option<Arc<Path>> = match &kind {
170 TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
171 TerminalKind::Task(spawn_task) => {
172 if let Some(cwd) = &spawn_task.cwd {
173 if is_via_remote {
174 Some(Arc::from(cwd.as_ref()))
175 } else {
176 let cwd = cwd.to_string_lossy();
177 let tilde_substituted = shellexpand::tilde(&cwd);
178 Some(Arc::from(Path::new(tilde_substituted.as_ref())))
179 }
180 } else {
181 self.active_project_directory(cx)
182 }
183 }
184 };
185
186 let mut settings_location = None;
187 if let Some(path) = path.as_ref()
188 && let Some((worktree, _)) = self.find_worktree(path, cx)
189 {
190 settings_location = Some(SettingsLocation {
191 worktree_id: worktree.read(cx).id(),
192 path,
193 });
194 }
195 let settings = TerminalSettings::get(settings_location, cx).clone();
196
197 let (completion_tx, completion_rx) = bounded(1);
198
199 // Start with the environment that we might have inherited from the Zed CLI.
200 let mut env = self
201 .environment
202 .read(cx)
203 .get_cli_environment()
204 .unwrap_or_default();
205 // Then extend it with the explicit env variables from the settings, so they take
206 // precedence.
207 env.extend(settings.env);
208
209 let local_path = if is_via_remote { None } else { path.clone() };
210
211 let mut python_venv_activate_command = Task::ready(None);
212
213 let remote_client = self.remote_client.clone();
214 let spawn_task;
215 let shell;
216 match kind {
217 TerminalKind::Shell(_) => {
218 if let Some(python_venv_directory) = &python_venv_directory {
219 python_venv_activate_command = self.python_activate_command(
220 python_venv_directory,
221 &settings.detect_venv,
222 &settings.shell,
223 cx,
224 );
225 }
226
227 spawn_task = None;
228 shell = match remote_client {
229 Some(remote_client) => {
230 create_remote_shell(None, &mut env, path, remote_client, cx)?
231 }
232 None => settings.shell,
233 };
234 }
235 TerminalKind::Task(task) => {
236 env.extend(task.env);
237
238 if let Some(venv_path) = &python_venv_directory {
239 env.insert(
240 "VIRTUAL_ENV".to_string(),
241 venv_path.to_string_lossy().to_string(),
242 );
243 }
244
245 spawn_task = Some(TaskState {
246 id: task.id,
247 full_label: task.full_label,
248 label: task.label,
249 command_label: task.command_label,
250 hide: task.hide,
251 status: TaskStatus::Running,
252 show_summary: task.show_summary,
253 show_command: task.show_command,
254 show_rerun: task.show_rerun,
255 completion_rx,
256 });
257 shell = match remote_client {
258 Some(remote_client) => {
259 let path_style = remote_client.read(cx).path_style();
260 if let Some(venv_directory) = &python_venv_directory
261 && let Ok(str) =
262 shlex::try_quote(venv_directory.to_string_lossy().as_ref())
263 {
264 let path =
265 RemotePathBuf::new(PathBuf::from(str.to_string()), path_style)
266 .to_string();
267 env.insert("PATH".into(), format!("{}:$PATH ", path));
268 }
269
270 create_remote_shell(
271 task.command.as_ref().map(|command| (command, &task.args)),
272 &mut env,
273 path,
274 remote_client,
275 cx,
276 )?
277 }
278 None => {
279 if let Some(venv_path) = &python_venv_directory {
280 add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR))
281 .log_err();
282 }
283
284 if let Some(program) = task.command {
285 Shell::WithArguments {
286 program,
287 args: task.args,
288 title_override: None,
289 }
290 } else {
291 Shell::System
292 }
293 }
294 };
295 }
296 };
297 TerminalBuilder::new(
298 local_path.map(|path| path.to_path_buf()),
299 python_venv_directory,
300 spawn_task,
301 shell,
302 env,
303 settings.cursor_shape.unwrap_or_default(),
304 settings.alternate_scroll,
305 settings.max_scroll_history_lines,
306 is_via_remote,
307 cx.entity_id().as_u64(),
308 completion_tx,
309 cx,
310 )
311 .map(|builder| {
312 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
313
314 self.terminals
315 .local_handles
316 .push(terminal_handle.downgrade());
317
318 let id = terminal_handle.entity_id();
319 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
320 let handles = &mut project.terminals.local_handles;
321
322 if let Some(index) = handles
323 .iter()
324 .position(|terminal| terminal.entity_id() == id)
325 {
326 handles.remove(index);
327 cx.notify();
328 }
329 })
330 .detach();
331
332 self.activate_python_virtual_environment(
333 python_venv_activate_command,
334 &terminal_handle,
335 cx,
336 );
337
338 terminal_handle
339 })
340 }
341
342 fn python_venv_directory(
343 &self,
344 abs_path: Arc<Path>,
345 venv_settings: VenvSettings,
346 cx: &Context<Project>,
347 ) -> Task<Option<PathBuf>> {
348 cx.spawn(async move |this, cx| {
349 if let Some((worktree, relative_path)) = this
350 .update(cx, |this, cx| this.find_worktree(&abs_path, cx))
351 .ok()?
352 {
353 let toolchain = this
354 .update(cx, |this, cx| {
355 this.active_toolchain(
356 ProjectPath {
357 worktree_id: worktree.read(cx).id(),
358 path: relative_path.into(),
359 },
360 LanguageName::new("Python"),
361 cx,
362 )
363 })
364 .ok()?
365 .await;
366
367 if let Some(toolchain) = toolchain {
368 let toolchain_path = Path::new(toolchain.path.as_ref());
369 return Some(toolchain_path.parent()?.parent()?.to_path_buf());
370 }
371 }
372 let venv_settings = venv_settings.as_option()?;
373 this.update(cx, move |this, cx| {
374 if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
375 return Some(path);
376 }
377 this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
378 })
379 .ok()
380 .flatten()
381 })
382 }
383
384 fn find_venv_in_worktree(
385 &self,
386 abs_path: &Path,
387 venv_settings: &terminal_settings::VenvSettingsContent,
388 cx: &App,
389 ) -> Option<PathBuf> {
390 venv_settings
391 .directories
392 .iter()
393 .map(|name| abs_path.join(name))
394 .find(|venv_path| {
395 let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
396 self.find_worktree(&bin_path, cx)
397 .and_then(|(worktree, relative_path)| {
398 worktree.read(cx).entry_for_path(&relative_path)
399 })
400 .is_some_and(|entry| entry.is_dir())
401 })
402 }
403
404 fn find_venv_on_filesystem(
405 &self,
406 abs_path: &Path,
407 venv_settings: &terminal_settings::VenvSettingsContent,
408 cx: &App,
409 ) -> Option<PathBuf> {
410 let (worktree, _) = self.find_worktree(abs_path, cx)?;
411 let fs = worktree.read(cx).as_local()?.fs();
412 venv_settings
413 .directories
414 .iter()
415 .map(|name| abs_path.join(name))
416 .find(|venv_path| {
417 let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
418 // One-time synchronous check is acceptable for terminal/task initialization
419 smol::block_on(fs.metadata(&bin_path))
420 .ok()
421 .flatten()
422 .is_some_and(|meta| meta.is_dir)
423 })
424 }
425
426 fn activate_script_kind(shell: Option<&str>) -> ActivateScript {
427 let shell_env = std::env::var("SHELL").ok();
428 let shell_path = shell.or_else(|| shell_env.as_deref());
429 let shell = std::path::Path::new(shell_path.unwrap_or(""))
430 .file_name()
431 .and_then(|name| name.to_str())
432 .unwrap_or("");
433 match shell {
434 "fish" => ActivateScript::Fish,
435 "tcsh" => ActivateScript::Csh,
436 "nu" => ActivateScript::Nushell,
437 "powershell" | "pwsh" => ActivateScript::PowerShell,
438 _ => ActivateScript::Default,
439 }
440 }
441
442 fn python_activate_command(
443 &self,
444 venv_base_directory: &Path,
445 venv_settings: &VenvSettings,
446 shell: &Shell,
447 cx: &mut App,
448 ) -> Task<Option<String>> {
449 let Some(venv_settings) = venv_settings.as_option() else {
450 return Task::ready(None);
451 };
452 let activate_keyword = match venv_settings.activate_script {
453 terminal_settings::ActivateScript::Default => match std::env::consts::OS {
454 "windows" => ".",
455 _ => ".",
456 },
457 terminal_settings::ActivateScript::Nushell => "overlay use",
458 terminal_settings::ActivateScript::PowerShell => ".",
459 terminal_settings::ActivateScript::Pyenv => "pyenv",
460 _ => "source",
461 };
462 let script_kind =
463 if venv_settings.activate_script == terminal_settings::ActivateScript::Default {
464 match shell {
465 Shell::Program(program) => Self::activate_script_kind(Some(program)),
466 Shell::WithArguments {
467 program,
468 args: _,
469 title_override: _,
470 } => Self::activate_script_kind(Some(program)),
471 Shell::System => Self::activate_script_kind(None),
472 }
473 } else {
474 venv_settings.activate_script
475 };
476
477 let activate_script_name = match script_kind {
478 terminal_settings::ActivateScript::Default
479 | terminal_settings::ActivateScript::Pyenv => "activate",
480 terminal_settings::ActivateScript::Csh => "activate.csh",
481 terminal_settings::ActivateScript::Fish => "activate.fish",
482 terminal_settings::ActivateScript::Nushell => "activate.nu",
483 terminal_settings::ActivateScript::PowerShell => "activate.ps1",
484 };
485
486 let line_ending = match std::env::consts::OS {
487 "windows" => "\r",
488 _ => "\n",
489 };
490
491 if venv_settings.venv_name.is_empty() {
492 let path = venv_base_directory
493 .join(PYTHON_VENV_BIN_DIR)
494 .join(activate_script_name)
495 .to_string_lossy()
496 .to_string();
497
498 let is_valid_path = self.resolve_abs_path(path.as_ref(), cx);
499 cx.background_spawn(async move {
500 let quoted = shlex::try_quote(&path).ok()?;
501 if is_valid_path.await.is_some_and(|meta| meta.is_file()) {
502 Some(format!(
503 "{} {} ; clear{}",
504 activate_keyword, quoted, line_ending
505 ))
506 } else {
507 None
508 }
509 })
510 } else {
511 Task::ready(Some(format!(
512 "{activate_keyword} {activate_script_name} {name}; clear{line_ending}",
513 name = venv_settings.venv_name
514 )))
515 }
516 }
517
518 fn activate_python_virtual_environment(
519 &self,
520 command: Task<Option<String>>,
521 terminal_handle: &Entity<Terminal>,
522 cx: &mut App,
523 ) {
524 terminal_handle.update(cx, |_, cx| {
525 cx.spawn(async move |this, cx| {
526 if let Some(command) = command.await {
527 this.update(cx, |this, _| {
528 this.input(command.into_bytes());
529 })
530 .ok();
531 }
532 })
533 .detach()
534 });
535 }
536
537 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
538 &self.terminals.local_handles
539 }
540}
541
542fn create_remote_shell(
543 spawn_command: Option<(&String, &Vec<String>)>,
544 env: &mut HashMap<String, String>,
545 working_directory: Option<Arc<Path>>,
546 remote_client: Entity<RemoteClient>,
547 cx: &mut App,
548) -> Result<Shell> {
549 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
550 // to properly display colors.
551 // We do not have the luxury of assuming the host has it installed,
552 // so we set it to a default that does not break the highlighting via ssh.
553 env.entry("TERM".to_string())
554 .or_insert_with(|| "xterm-256color".to_string());
555
556 let (program, args) = match spawn_command {
557 Some((program, args)) => (Some(program.clone()), args),
558 None => (None, &Vec::new()),
559 };
560
561 let command = remote_client.read(cx).build_command(
562 program,
563 args.as_slice(),
564 env,
565 working_directory.map(|path| path.display().to_string()),
566 None,
567 )?;
568 *env = command.env;
569
570 log::debug!("Connecting to a remote server: {:?}", command.program);
571 let host = remote_client.read(cx).connection_options().host;
572
573 Ok(Shell::WithArguments {
574 program: command.program,
575 args: command.args,
576 title_override: Some(format!("{} — Terminal", host).into()),
577 })
578}
579
580fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
581 let mut env_paths = vec![new_path.to_path_buf()];
582 if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
583 let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
584 env_paths.append(&mut paths);
585 }
586
587 let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
588 env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
589
590 Ok(())
591}
592
593#[cfg(test)]
594mod tests {
595 use collections::HashMap;
596
597 #[test]
598 fn test_add_environment_path_with_existing_path() {
599 let tmp_path = std::path::PathBuf::from("/tmp/new");
600 let mut env = HashMap::default();
601 let old_path = if cfg!(windows) {
602 "/usr/bin;/usr/local/bin"
603 } else {
604 "/usr/bin:/usr/local/bin"
605 };
606 env.insert("PATH".to_string(), old_path.to_string());
607 env.insert("OTHER".to_string(), "aaa".to_string());
608
609 super::add_environment_path(&mut env, &tmp_path).unwrap();
610 if cfg!(windows) {
611 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
612 } else {
613 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
614 }
615 assert_eq!(env.get("OTHER").unwrap(), "aaa");
616 }
617
618 #[test]
619 fn test_add_environment_path_with_empty_path() {
620 let tmp_path = std::path::PathBuf::from("/tmp/new");
621 let mut env = HashMap::default();
622 env.insert("OTHER".to_string(), "aaa".to_string());
623 let os_path = std::env::var("PATH").unwrap();
624 super::add_environment_path(&mut env, &tmp_path).unwrap();
625 if cfg!(windows) {
626 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
627 } else {
628 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
629 }
630 assert_eq!(env.get("OTHER").unwrap(), "aaa");
631 }
632}