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