terminals.rs

  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}