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);
152        let (command, args) = builder.build(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                            Some((&spawn_task.command, &spawn_task.args)),
301                            path.as_deref(),
302                            env,
303                            python_venv_directory.as_deref(),
304                        );
305                        env = HashMap::default();
306                        (
307                            task_state,
308                            Shell::WithArguments {
309                                program,
310                                args,
311                                title_override: Some(format!("{} — Terminal", host).into()),
312                            },
313                        )
314                    }
315                    None => {
316                        if let Some(venv_path) = &python_venv_directory {
317                            add_environment_path(&mut env, &venv_path.join("bin")).log_err();
318                        }
319
320                        (
321                            task_state,
322                            Shell::WithArguments {
323                                program: spawn_task.command,
324                                args: spawn_task.args,
325                                title_override: None,
326                            },
327                        )
328                    }
329                }
330            }
331        };
332        TerminalBuilder::new(
333            local_path.map(|path| path.to_path_buf()),
334            python_venv_directory,
335            spawn_task,
336            shell,
337            env,
338            settings.cursor_shape.unwrap_or_default(),
339            settings.alternate_scroll,
340            settings.max_scroll_history_lines,
341            ssh_details.is_some(),
342            window,
343            completion_tx,
344            cx,
345        )
346        .map(|builder| {
347            let terminal_handle = cx.new(|cx| builder.subscribe(cx));
348
349            this.terminals
350                .local_handles
351                .push(terminal_handle.downgrade());
352
353            let id = terminal_handle.entity_id();
354            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
355                let handles = &mut project.terminals.local_handles;
356
357                if let Some(index) = handles
358                    .iter()
359                    .position(|terminal| terminal.entity_id() == id)
360                {
361                    handles.remove(index);
362                    cx.notify();
363                }
364            })
365            .detach();
366
367            if let Some(activate_command) = python_venv_activate_command {
368                this.activate_python_virtual_environment(activate_command, &terminal_handle, cx);
369            }
370            terminal_handle
371        })
372    }
373
374    fn python_venv_directory(
375        &self,
376        abs_path: Arc<Path>,
377        venv_settings: VenvSettings,
378        cx: &Context<Project>,
379    ) -> Task<Option<PathBuf>> {
380        cx.spawn(async move |this, cx| {
381            if let Some((worktree, relative_path)) = this
382                .update(cx, |this, cx| this.find_worktree(&abs_path, cx))
383                .ok()?
384            {
385                let toolchain = this
386                    .update(cx, |this, cx| {
387                        this.active_toolchain(
388                            ProjectPath {
389                                worktree_id: worktree.read(cx).id(),
390                                path: relative_path.into(),
391                            },
392                            LanguageName::new("Python"),
393                            cx,
394                        )
395                    })
396                    .ok()?
397                    .await;
398
399                if let Some(toolchain) = toolchain {
400                    let toolchain_path = Path::new(toolchain.path.as_ref());
401                    return Some(toolchain_path.parent()?.parent()?.to_path_buf());
402                }
403            }
404            let venv_settings = venv_settings.as_option()?;
405            this.update(cx, move |this, cx| {
406                if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
407                    return Some(path);
408                }
409                this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
410            })
411            .ok()
412            .flatten()
413        })
414    }
415
416    fn find_venv_in_worktree(
417        &self,
418        abs_path: &Path,
419        venv_settings: &terminal_settings::VenvSettingsContent,
420        cx: &App,
421    ) -> Option<PathBuf> {
422        let bin_dir_name = match std::env::consts::OS {
423            "windows" => "Scripts",
424            _ => "bin",
425        };
426        venv_settings
427            .directories
428            .iter()
429            .map(|name| abs_path.join(name))
430            .find(|venv_path| {
431                let bin_path = venv_path.join(bin_dir_name);
432                self.find_worktree(&bin_path, cx)
433                    .and_then(|(worktree, relative_path)| {
434                        worktree.read(cx).entry_for_path(&relative_path)
435                    })
436                    .is_some_and(|entry| entry.is_dir())
437            })
438    }
439
440    fn find_venv_on_filesystem(
441        &self,
442        abs_path: &Path,
443        venv_settings: &terminal_settings::VenvSettingsContent,
444        cx: &App,
445    ) -> Option<PathBuf> {
446        let (worktree, _) = self.find_worktree(abs_path, cx)?;
447        let fs = worktree.read(cx).as_local()?.fs();
448        let bin_dir_name = match std::env::consts::OS {
449            "windows" => "Scripts",
450            _ => "bin",
451        };
452        venv_settings
453            .directories
454            .iter()
455            .map(|name| abs_path.join(name))
456            .find(|venv_path| {
457                let bin_path = venv_path.join(bin_dir_name);
458                // One-time synchronous check is acceptable for terminal/task initialization
459                smol::block_on(fs.metadata(&bin_path))
460                    .ok()
461                    .flatten()
462                    .map_or(false, |meta| meta.is_dir)
463            })
464    }
465
466    fn python_activate_command(
467        &self,
468        venv_base_directory: &Path,
469        venv_settings: &VenvSettings,
470    ) -> Option<String> {
471        let venv_settings = venv_settings.as_option()?;
472        let activate_keyword = match venv_settings.activate_script {
473            terminal_settings::ActivateScript::Default => match std::env::consts::OS {
474                "windows" => ".",
475                _ => "source",
476            },
477            terminal_settings::ActivateScript::Nushell => "overlay use",
478            terminal_settings::ActivateScript::PowerShell => ".",
479            _ => "source",
480        };
481        let activate_script_name = match venv_settings.activate_script {
482            terminal_settings::ActivateScript::Default => "activate",
483            terminal_settings::ActivateScript::Csh => "activate.csh",
484            terminal_settings::ActivateScript::Fish => "activate.fish",
485            terminal_settings::ActivateScript::Nushell => "activate.nu",
486            terminal_settings::ActivateScript::PowerShell => "activate.ps1",
487        };
488        let path = venv_base_directory
489            .join(match std::env::consts::OS {
490                "windows" => "Scripts",
491                _ => "bin",
492            })
493            .join(activate_script_name)
494            .to_string_lossy()
495            .to_string();
496        let quoted = shlex::try_quote(&path).ok()?;
497        let line_ending = match std::env::consts::OS {
498            "windows" => "\r",
499            _ => "\n",
500        };
501        smol::block_on(self.fs.metadata(path.as_ref()))
502            .ok()
503            .flatten()?;
504
505        Some(format!(
506            "{} {} ; clear{}",
507            activate_keyword, quoted, line_ending
508        ))
509    }
510
511    fn activate_python_virtual_environment(
512        &self,
513        command: String,
514        terminal_handle: &Entity<Terminal>,
515        cx: &mut App,
516    ) {
517        terminal_handle.update(cx, |terminal, _| terminal.input(command.into_bytes()));
518    }
519
520    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
521        &self.terminals.local_handles
522    }
523}
524
525pub fn wrap_for_ssh(
526    ssh_command: &SshCommand,
527    command: Option<(&String, &Vec<String>)>,
528    path: Option<&Path>,
529    env: HashMap<String, String>,
530    venv_directory: Option<&Path>,
531) -> (String, Vec<String>) {
532    let to_run = if let Some((command, args)) = command {
533        // DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped
534        let command: Option<Cow<str>> = if command == DEFAULT_REMOTE_SHELL {
535            Some(command.into())
536        } else {
537            shlex::try_quote(command).ok()
538        };
539        let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
540        command.into_iter().chain(args).join(" ")
541    } else {
542        "exec ${SHELL:-sh} -l".to_string()
543    };
544
545    let mut env_changes = String::new();
546    for (k, v) in env.iter() {
547        if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
548            env_changes.push_str(&format!("{}={} ", k, v));
549        }
550    }
551    if let Some(venv_directory) = venv_directory {
552        if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
553            env_changes.push_str(&format!("PATH={}:$PATH ", str));
554        }
555    }
556
557    let commands = if let Some(path) = path {
558        let path_string = path.to_string_lossy().to_string();
559        // shlex will wrap the command in single quotes (''), disabling ~ expansion,
560        // replace ith with something that works
561        let tilde_prefix = "~/";
562        if path.starts_with(tilde_prefix) {
563            let trimmed_path = path_string
564                .trim_start_matches("/")
565                .trim_start_matches("~")
566                .trim_start_matches("/");
567
568            format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
569        } else {
570            format!("cd {path:?}; {env_changes} {to_run}")
571        }
572    } else {
573        format!("cd; {env_changes} {to_run}")
574    };
575    let shell_invocation = format!("sh -c {}", shlex::try_quote(&commands).unwrap());
576
577    let program = "ssh".to_string();
578    let mut args = ssh_command.arguments.clone();
579
580    args.push("-t".to_string());
581    args.push(shell_invocation);
582    (program, args)
583}
584
585fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
586    let mut env_paths = vec![new_path.to_path_buf()];
587    if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
588        let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
589        env_paths.append(&mut paths);
590    }
591
592    let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
593    env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
594
595    Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600    use collections::HashMap;
601
602    #[test]
603    fn test_add_environment_path_with_existing_path() {
604        let tmp_path = std::path::PathBuf::from("/tmp/new");
605        let mut env = HashMap::default();
606        let old_path = if cfg!(windows) {
607            "/usr/bin;/usr/local/bin"
608        } else {
609            "/usr/bin:/usr/local/bin"
610        };
611        env.insert("PATH".to_string(), old_path.to_string());
612        env.insert("OTHER".to_string(), "aaa".to_string());
613
614        super::add_environment_path(&mut env, &tmp_path).unwrap();
615        if cfg!(windows) {
616            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
617        } else {
618            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
619        }
620        assert_eq!(env.get("OTHER").unwrap(), "aaa");
621    }
622
623    #[test]
624    fn test_add_environment_path_with_empty_path() {
625        let tmp_path = std::path::PathBuf::from("/tmp/new");
626        let mut env = HashMap::default();
627        env.insert("OTHER".to_string(), "aaa".to_string());
628        let os_path = std::env::var("PATH").unwrap();
629        super::add_environment_path(&mut env, &tmp_path).unwrap();
630        if cfg!(windows) {
631            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
632        } else {
633            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
634        }
635        assert_eq!(env.get("OTHER").unwrap(), "aaa");
636    }
637}