terminals.rs

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