terminals.rs

  1use crate::Project;
  2use anyhow::Context as _;
  3use collections::HashMap;
  4use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, WeakModel};
  5use itertools::Itertools;
  6use settings::{Settings, SettingsLocation};
  7use smol::channel::bounded;
  8use std::{
  9    env::{self},
 10    iter,
 11    path::{Path, PathBuf},
 12};
 13use task::{Shell, SpawnInTerminal};
 14use terminal::{
 15    terminal_settings::{self, TerminalSettings},
 16    TaskState, TaskStatus, Terminal, TerminalBuilder,
 17};
 18use util::ResultExt;
 19
 20// #[cfg(target_os = "macos")]
 21// use std::os::unix::ffi::OsStrExt;
 22
 23pub struct Terminals {
 24    pub(crate) local_handles: Vec<WeakModel<terminal::Terminal>>,
 25}
 26
 27/// Terminals are opened either for the users shell, or to run a task.
 28#[allow(clippy::large_enum_variant)]
 29#[derive(Debug)]
 30pub enum TerminalKind {
 31    /// Run a shell at the given path (or $HOME if None)
 32    Shell(Option<PathBuf>),
 33    /// Run a task.
 34    Task(SpawnInTerminal),
 35}
 36
 37/// SshCommand describes how to connect to a remote server
 38#[derive(Debug, Clone, PartialEq, Eq)]
 39pub enum SshCommand {
 40    /// DevServers give a string from the user
 41    DevServer(String),
 42    /// Direct ssh has a list of arguments to pass to ssh
 43    Direct(Vec<String>),
 44}
 45
 46impl Project {
 47    pub fn active_project_directory(&self, cx: &AppContext) -> Option<PathBuf> {
 48        let worktree = self
 49            .active_entry()
 50            .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
 51            .or_else(|| self.worktrees(cx).next())?;
 52        let worktree = worktree.read(cx);
 53        if !worktree.root_entry()?.is_dir() {
 54            return None;
 55        }
 56        Some(worktree.abs_path().to_path_buf())
 57    }
 58
 59    pub fn first_project_directory(&self, cx: &AppContext) -> Option<PathBuf> {
 60        let worktree = self.worktrees(cx).next()?;
 61        let worktree = worktree.read(cx);
 62        if worktree.root_entry()?.is_dir() {
 63            Some(worktree.abs_path().to_path_buf())
 64        } else {
 65            None
 66        }
 67    }
 68
 69    fn ssh_command(&self, cx: &AppContext) -> Option<SshCommand> {
 70        if let Some(args) = self
 71            .ssh_client
 72            .as_ref()
 73            .and_then(|session| session.ssh_args())
 74        {
 75            return Some(SshCommand::Direct(args));
 76        }
 77
 78        let dev_server_project_id = self.dev_server_project_id()?;
 79        let projects_store = dev_server_projects::Store::global(cx).read(cx);
 80        let ssh_command = projects_store
 81            .dev_server_for_project(dev_server_project_id)?
 82            .ssh_connection_string
 83            .as_ref()?
 84            .to_string();
 85        Some(SshCommand::DevServer(ssh_command))
 86    }
 87
 88    pub fn create_terminal(
 89        &mut self,
 90        kind: TerminalKind,
 91        window: AnyWindowHandle,
 92        cx: &mut ModelContext<Self>,
 93    ) -> anyhow::Result<Model<Terminal>> {
 94        let path = match &kind {
 95            TerminalKind::Shell(path) => path.as_ref().map(|path| path.to_path_buf()),
 96            TerminalKind::Task(spawn_task) => {
 97                if let Some(cwd) = &spawn_task.cwd {
 98                    Some(cwd.clone())
 99                } else {
100                    self.active_project_directory(cx)
101                }
102            }
103        };
104        let ssh_command = self.ssh_command(cx);
105
106        let mut settings_location = None;
107        if let Some(path) = path.as_ref() {
108            if let Some((worktree, _)) = self.find_worktree(path, cx) {
109                settings_location = Some(SettingsLocation {
110                    worktree_id: worktree.read(cx).id(),
111                    path,
112                });
113            }
114        }
115        let settings = TerminalSettings::get(settings_location, cx);
116
117        let (completion_tx, completion_rx) = bounded(1);
118
119        // Start with the environment that we might have inherited from the Zed CLI.
120        let mut env = self
121            .environment
122            .read(cx)
123            .get_cli_environment()
124            .unwrap_or_default();
125        // Then extend it with the explicit env variables from the settings, so they take
126        // precedence.
127        env.extend(settings.env.clone());
128
129        let local_path = if ssh_command.is_none() {
130            path.clone()
131        } else {
132            None
133        };
134        let python_venv_directory = path
135            .as_ref()
136            .and_then(|path| self.python_venv_directory(path, settings, cx));
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 =
143                        self.python_activate_command(&python_venv_directory, settings);
144                }
145
146                match &ssh_command {
147                    Some(ssh_command) => {
148                        log::debug!("Connecting to a remote server: {ssh_command:?}");
149
150                        // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
151                        // to properly display colors.
152                        // We do not have the luxury of assuming the host has it installed,
153                        // so we set it to a default that does not break the highlighting via ssh.
154                        env.entry("TERM".to_string())
155                            .or_insert_with(|| "xterm-256color".to_string());
156
157                        let (program, args) =
158                            wrap_for_ssh(ssh_command, None, path.as_deref(), env, None);
159                        env = HashMap::default();
160                        (None, Shell::WithArguments { program, args })
161                    }
162                    None => (None, settings.shell.clone()),
163                }
164            }
165            TerminalKind::Task(spawn_task) => {
166                let task_state = Some(TaskState {
167                    id: spawn_task.id,
168                    full_label: spawn_task.full_label,
169                    label: spawn_task.label,
170                    command_label: spawn_task.command_label,
171                    hide: spawn_task.hide,
172                    status: TaskStatus::Running,
173                    completion_rx,
174                });
175
176                env.extend(spawn_task.env);
177
178                if let Some(venv_path) = &python_venv_directory {
179                    env.insert(
180                        "VIRTUAL_ENV".to_string(),
181                        venv_path.to_string_lossy().to_string(),
182                    );
183                }
184
185                match &ssh_command {
186                    Some(ssh_command) => {
187                        log::debug!("Connecting to a remote server: {ssh_command:?}");
188                        env.entry("TERM".to_string())
189                            .or_insert_with(|| "xterm-256color".to_string());
190                        let (program, args) = wrap_for_ssh(
191                            ssh_command,
192                            Some((&spawn_task.command, &spawn_task.args)),
193                            path.as_deref(),
194                            env,
195                            python_venv_directory,
196                        );
197                        env = HashMap::default();
198                        (task_state, Shell::WithArguments { program, args })
199                    }
200                    None => {
201                        if let Some(venv_path) = &python_venv_directory {
202                            add_environment_path(&mut env, &venv_path.join("bin")).log_err();
203                        }
204
205                        (
206                            task_state,
207                            Shell::WithArguments {
208                                program: spawn_task.command,
209                                args: spawn_task.args,
210                            },
211                        )
212                    }
213                }
214            }
215        };
216
217        let terminal = TerminalBuilder::new(
218            local_path,
219            spawn_task,
220            shell,
221            env,
222            settings.cursor_shape.unwrap_or_default(),
223            settings.alternate_scroll,
224            settings.max_scroll_history_lines,
225            window,
226            completion_tx,
227            cx,
228        )
229        .map(|builder| {
230            let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
231
232            self.terminals
233                .local_handles
234                .push(terminal_handle.downgrade());
235
236            let id = terminal_handle.entity_id();
237            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
238                let handles = &mut project.terminals.local_handles;
239
240                if let Some(index) = handles
241                    .iter()
242                    .position(|terminal| terminal.entity_id() == id)
243                {
244                    handles.remove(index);
245                    cx.notify();
246                }
247            })
248            .detach();
249
250            if let Some(activate_command) = python_venv_activate_command {
251                self.activate_python_virtual_environment(activate_command, &terminal_handle, cx);
252            }
253            terminal_handle
254        });
255
256        terminal
257    }
258
259    pub fn python_venv_directory(
260        &self,
261        abs_path: &Path,
262        settings: &TerminalSettings,
263        cx: &AppContext,
264    ) -> Option<PathBuf> {
265        let venv_settings = settings.detect_venv.as_option()?;
266        let bin_dir_name = match std::env::consts::OS {
267            "windows" => "Scripts",
268            _ => "bin",
269        };
270        venv_settings
271            .directories
272            .iter()
273            .map(|virtual_environment_name| abs_path.join(virtual_environment_name))
274            .find(|venv_path| {
275                let bin_path = venv_path.join(bin_dir_name);
276                self.find_worktree(&bin_path, cx)
277                    .and_then(|(worktree, relative_path)| {
278                        worktree.read(cx).entry_for_path(&relative_path)
279                    })
280                    .is_some_and(|entry| entry.is_dir())
281            })
282    }
283
284    fn python_activate_command(
285        &self,
286        venv_base_directory: &Path,
287        settings: &TerminalSettings,
288    ) -> Option<String> {
289        let venv_settings = settings.detect_venv.as_option()?;
290        let activate_keyword = match venv_settings.activate_script {
291            terminal_settings::ActivateScript::Default => match std::env::consts::OS {
292                "windows" => ".",
293                _ => "source",
294            },
295            terminal_settings::ActivateScript::Nushell => "overlay use",
296            terminal_settings::ActivateScript::PowerShell => ".",
297            _ => "source",
298        };
299        let activate_script_name = match venv_settings.activate_script {
300            terminal_settings::ActivateScript::Default => "activate",
301            terminal_settings::ActivateScript::Csh => "activate.csh",
302            terminal_settings::ActivateScript::Fish => "activate.fish",
303            terminal_settings::ActivateScript::Nushell => "activate.nu",
304            terminal_settings::ActivateScript::PowerShell => "activate.ps1",
305        };
306        let path = venv_base_directory
307            .join(match std::env::consts::OS {
308                "windows" => "Scripts",
309                _ => "bin",
310            })
311            .join(activate_script_name)
312            .to_string_lossy()
313            .to_string();
314        let quoted = shlex::try_quote(&path).ok()?;
315        let line_ending = match std::env::consts::OS {
316            "windows" => "\r",
317            _ => "\n",
318        };
319        Some(format!("{} {}{}", activate_keyword, quoted, line_ending))
320    }
321
322    fn activate_python_virtual_environment(
323        &self,
324        command: String,
325        terminal_handle: &Model<Terminal>,
326        cx: &mut ModelContext<Project>,
327    ) {
328        terminal_handle.update(cx, |this, _| this.input_bytes(command.into_bytes()));
329    }
330
331    pub fn local_terminal_handles(&self) -> &Vec<WeakModel<terminal::Terminal>> {
332        &self.terminals.local_handles
333    }
334}
335
336pub fn wrap_for_ssh(
337    ssh_command: &SshCommand,
338    command: Option<(&String, &Vec<String>)>,
339    path: Option<&Path>,
340    env: HashMap<String, String>,
341    venv_directory: Option<PathBuf>,
342) -> (String, Vec<String>) {
343    let to_run = if let Some((command, args)) = command {
344        iter::once(command)
345            .chain(args)
346            .filter_map(|arg| shlex::try_quote(arg).ok())
347            .join(" ")
348    } else {
349        "exec ${SHELL:-sh} -l".to_string()
350    };
351
352    let mut env_changes = String::new();
353    for (k, v) in env.iter() {
354        if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
355            env_changes.push_str(&format!("{}={} ", k, v));
356        }
357    }
358    if let Some(venv_directory) = venv_directory {
359        if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
360            env_changes.push_str(&format!("PATH={}:$PATH ", str));
361        }
362    }
363
364    let commands = if let Some(path) = path {
365        format!("cd {:?}; {} {}", path, env_changes, to_run)
366    } else {
367        format!("cd; {env_changes} {to_run}")
368    };
369    let shell_invocation = format!("sh -c {}", shlex::try_quote(&commands).unwrap());
370
371    let (program, mut args) = match ssh_command {
372        SshCommand::DevServer(ssh_command) => {
373            let mut args = shlex::split(ssh_command).unwrap_or_default();
374            let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
375            (program, args)
376        }
377        SshCommand::Direct(ssh_args) => ("ssh".to_string(), ssh_args.clone()),
378    };
379
380    if command.is_none() {
381        args.push("-t".to_string())
382    }
383    args.push(shell_invocation);
384    (program, args)
385}
386
387fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> anyhow::Result<()> {
388    let mut env_paths = vec![new_path.to_path_buf()];
389    if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
390        let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
391        env_paths.append(&mut paths);
392    }
393
394    let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
395    env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
396
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use collections::HashMap;
403
404    #[test]
405    fn test_add_environment_path_with_existing_path() {
406        let tmp_path = std::path::PathBuf::from("/tmp/new");
407        let mut env = HashMap::default();
408        let old_path = if cfg!(windows) {
409            "/usr/bin;/usr/local/bin"
410        } else {
411            "/usr/bin:/usr/local/bin"
412        };
413        env.insert("PATH".to_string(), old_path.to_string());
414        env.insert("OTHER".to_string(), "aaa".to_string());
415
416        super::add_environment_path(&mut env, &tmp_path).unwrap();
417        if cfg!(windows) {
418            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
419        } else {
420            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
421        }
422        assert_eq!(env.get("OTHER").unwrap(), "aaa");
423    }
424
425    #[test]
426    fn test_add_environment_path_with_empty_path() {
427        let tmp_path = std::path::PathBuf::from("/tmp/new");
428        let mut env = HashMap::default();
429        env.insert("OTHER".to_string(), "aaa".to_string());
430        let os_path = std::env::var("PATH").unwrap();
431        super::add_environment_path(&mut env, &tmp_path).unwrap();
432        if cfg!(windows) {
433            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
434        } else {
435            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
436        }
437        assert_eq!(env.get("OTHER").unwrap(), "aaa");
438    }
439}