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