terminals.rs

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