terminals.rs

  1use crate::Project;
  2use anyhow::Context as _;
  3use collections::HashMap;
  4use gpui::{
  5    AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, SharedString, WeakModel,
  6};
  7use itertools::Itertools;
  8use settings::{Settings, SettingsLocation};
  9use smol::channel::bounded;
 10use std::{
 11    env,
 12    fs::File,
 13    io::Write,
 14    path::{Path, PathBuf},
 15};
 16use task::{Shell, SpawnInTerminal, TerminalWorkDir};
 17use terminal::{
 18    terminal_settings::{self, TerminalSettings, VenvSettingsContent},
 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#[derive(Debug, Clone)]
 31pub struct ConnectRemoteTerminal {
 32    pub ssh_connection_string: SharedString,
 33    pub project_path: SharedString,
 34}
 35
 36impl Project {
 37    pub fn terminal_work_dir_for(
 38        &self,
 39        pathbuf: Option<&Path>,
 40        cx: &AppContext,
 41    ) -> Option<TerminalWorkDir> {
 42        if self.is_local() {
 43            return Some(TerminalWorkDir::Local(pathbuf?.to_owned()));
 44        }
 45        let dev_server_project_id = self.dev_server_project_id()?;
 46        let projects_store = dev_server_projects::Store::global(cx).read(cx);
 47        let ssh_command = projects_store
 48            .dev_server_for_project(dev_server_project_id)?
 49            .ssh_connection_string
 50            .as_ref()?
 51            .to_string();
 52
 53        let path = if let Some(pathbuf) = pathbuf {
 54            pathbuf.to_string_lossy().to_string()
 55        } else {
 56            projects_store
 57                .dev_server_project(dev_server_project_id)?
 58                .paths
 59                .get(0)
 60                .unwrap()
 61                .to_string()
 62        };
 63
 64        Some(TerminalWorkDir::Ssh {
 65            ssh_command,
 66            path: Some(path),
 67        })
 68    }
 69
 70    pub fn create_terminal(
 71        &mut self,
 72        working_directory: Option<TerminalWorkDir>,
 73        spawn_task: Option<SpawnInTerminal>,
 74        window: AnyWindowHandle,
 75        cx: &mut ModelContext<Self>,
 76    ) -> anyhow::Result<Model<Terminal>> {
 77        // used only for TerminalSettings::get
 78        let worktree = {
 79            let terminal_cwd = working_directory.as_ref().and_then(|cwd| cwd.local_path());
 80            let task_cwd = spawn_task
 81                .as_ref()
 82                .and_then(|spawn_task| spawn_task.cwd.as_ref())
 83                .and_then(|cwd| cwd.local_path());
 84
 85            terminal_cwd
 86                .and_then(|terminal_cwd| self.find_worktree(&terminal_cwd, cx))
 87                .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_worktree(&spawn_cwd, cx)))
 88        };
 89
 90        let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
 91            worktree_id: worktree.read(cx).id().to_usize(),
 92            path,
 93        });
 94
 95        let is_terminal = spawn_task.is_none()
 96            && working_directory
 97                .as_ref()
 98                .map_or(true, |work_dir| work_dir.is_local());
 99        let settings = TerminalSettings::get(settings_location, cx);
100        let python_settings = settings.detect_venv.clone();
101        let (completion_tx, completion_rx) = bounded(1);
102
103        let mut env = settings.env.clone();
104        // Alacritty uses parent project's working directory when no working directory is provided
105        // https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52
106
107        let mut retained_script = None;
108
109        let venv_base_directory = working_directory
110            .as_ref()
111            .and_then(|cwd| cwd.local_path())
112            .unwrap_or_else(|| Path::new(""));
113
114        let (spawn_task, shell) = match working_directory.as_ref() {
115            Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
116                log::debug!("Connecting to a remote server: {ssh_command:?}");
117                let tmp_dir = tempfile::tempdir()?;
118                let ssh_shell_result = prepare_ssh_shell(
119                    &mut env,
120                    tmp_dir.path(),
121                    spawn_task.as_ref(),
122                    ssh_command,
123                    path.as_deref(),
124                );
125                retained_script = Some(tmp_dir);
126                let ssh_shell = ssh_shell_result?;
127
128                (
129                    spawn_task.map(|spawn_task| TaskState {
130                        id: spawn_task.id,
131                        full_label: spawn_task.full_label,
132                        label: spawn_task.label,
133                        command_label: spawn_task.command_label,
134                        hide: spawn_task.hide,
135                        status: TaskStatus::Running,
136                        completion_rx,
137                    }),
138                    ssh_shell,
139                )
140            }
141            _ => {
142                if let Some(spawn_task) = spawn_task {
143                    log::debug!("Spawning task: {spawn_task:?}");
144                    env.extend(spawn_task.env);
145                    // Activate minimal Python virtual environment
146                    if let Some(python_settings) = &python_settings.as_option() {
147                        self.set_python_venv_path_for_tasks(
148                            python_settings,
149                            &venv_base_directory,
150                            &mut env,
151                        );
152                    }
153                    (
154                        Some(TaskState {
155                            id: spawn_task.id,
156                            full_label: spawn_task.full_label,
157                            label: spawn_task.label,
158                            command_label: spawn_task.command_label,
159                            hide: spawn_task.hide,
160                            status: TaskStatus::Running,
161                            completion_rx,
162                        }),
163                        Shell::WithArguments {
164                            program: spawn_task.command,
165                            args: spawn_task.args,
166                        },
167                    )
168                } else {
169                    (None, settings.shell.clone())
170                }
171            }
172        };
173
174        let terminal = TerminalBuilder::new(
175            working_directory
176                .as_ref()
177                .and_then(|cwd| cwd.local_path())
178                .map(ToOwned::to_owned),
179            spawn_task,
180            shell,
181            env,
182            Some(settings.blinking),
183            settings.alternate_scroll,
184            settings.max_scroll_history_lines,
185            window,
186            completion_tx,
187            cx,
188        )
189        .map(|builder| {
190            let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
191
192            self.terminals
193                .local_handles
194                .push(terminal_handle.downgrade());
195
196            let id = terminal_handle.entity_id();
197            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
198                drop(retained_script);
199                let handles = &mut project.terminals.local_handles;
200
201                if let Some(index) = handles
202                    .iter()
203                    .position(|terminal| terminal.entity_id() == id)
204                {
205                    handles.remove(index);
206                    cx.notify();
207                }
208            })
209            .detach();
210
211            // if the terminal is not a task, activate full Python virtual environment
212            if is_terminal {
213                if let Some(python_settings) = &python_settings.as_option() {
214                    if let Some(activate_script_path) =
215                        self.find_activate_script_path(python_settings, &venv_base_directory)
216                    {
217                        self.activate_python_virtual_environment(
218                            Project::get_activate_command(python_settings),
219                            activate_script_path,
220                            &terminal_handle,
221                            cx,
222                        );
223                    }
224                }
225            }
226            terminal_handle
227        });
228
229        terminal
230    }
231
232    pub fn find_activate_script_path(
233        &mut self,
234        settings: &VenvSettingsContent,
235        venv_base_directory: &Path,
236    ) -> Option<PathBuf> {
237        let activate_script_name = match settings.activate_script {
238            terminal_settings::ActivateScript::Default => "activate",
239            terminal_settings::ActivateScript::Csh => "activate.csh",
240            terminal_settings::ActivateScript::Fish => "activate.fish",
241            terminal_settings::ActivateScript::Nushell => "activate.nu",
242        };
243
244        settings
245            .directories
246            .into_iter()
247            .find_map(|virtual_environment_name| {
248                let path = venv_base_directory
249                    .join(virtual_environment_name)
250                    .join("bin")
251                    .join(activate_script_name);
252                path.exists().then_some(path)
253            })
254    }
255
256    pub fn set_python_venv_path_for_tasks(
257        &mut self,
258        settings: &VenvSettingsContent,
259        venv_base_directory: &Path,
260        env: &mut HashMap<String, String>,
261    ) {
262        let activate_path = settings
263            .directories
264            .into_iter()
265            .find_map(|virtual_environment_name| {
266                let path = venv_base_directory.join(virtual_environment_name);
267                path.exists().then_some(path)
268            });
269
270        if let Some(path) = activate_path {
271            // Some tools use VIRTUAL_ENV to detect the virtual environment
272            env.insert(
273                "VIRTUAL_ENV".to_string(),
274                path.to_string_lossy().to_string(),
275            );
276
277            // We need to set the PATH to include the virtual environment's bin directory
278            add_environment_path(env, &path.join("bin")).log_err();
279        }
280    }
281
282    fn get_activate_command(settings: &VenvSettingsContent) -> &'static str {
283        match settings.activate_script {
284            terminal_settings::ActivateScript::Nushell => "overlay use",
285            _ => "source",
286        }
287    }
288
289    fn activate_python_virtual_environment(
290        &mut self,
291        activate_command: &'static str,
292        activate_script: PathBuf,
293        terminal_handle: &Model<Terminal>,
294        cx: &mut ModelContext<Project>,
295    ) {
296        // Paths are not strings so we need to jump through some hoops to format the command without `format!`
297        let mut command = Vec::from(activate_command.as_bytes());
298        command.push(b' ');
299        // Wrapping path in double quotes to catch spaces in folder name
300        command.extend_from_slice(b"\"");
301        command.extend_from_slice(activate_script.as_os_str().as_encoded_bytes());
302        command.extend_from_slice(b"\"");
303        command.push(b'\n');
304
305        terminal_handle.update(cx, |this, _| this.input_bytes(command));
306    }
307
308    pub fn local_terminal_handles(&self) -> &Vec<WeakModel<terminal::Terminal>> {
309        &self.terminals.local_handles
310    }
311}
312
313fn prepare_ssh_shell(
314    env: &mut HashMap<String, String>,
315    tmp_dir: &Path,
316    spawn_task: Option<&SpawnInTerminal>,
317    ssh_command: &str,
318    path: Option<&str>,
319) -> anyhow::Result<Shell> {
320    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
321    // to properly display colors.
322    // We do not have the luxury of assuming the host has it installed,
323    // so we set it to a default that does not break the highlighting via ssh.
324    env.entry("TERM".to_string())
325        .or_insert_with(|| "xterm-256color".to_string());
326
327    let real_ssh = which::which("ssh")?;
328    let ssh_path = tmp_dir.join("ssh");
329    let mut ssh_file = File::create(&ssh_path)?;
330
331    let to_run = if let Some(spawn_task) = spawn_task {
332        Some(shlex::try_quote(&spawn_task.command)?)
333            .into_iter()
334            .chain(
335                spawn_task
336                    .args
337                    .iter()
338                    .filter_map(|arg| shlex::try_quote(arg).ok()),
339            )
340            .join(" ")
341    } else {
342        "exec $SHELL -l".to_string()
343    };
344
345    let commands = if let Some(path) = path {
346        format!("cd {path}; {to_run}")
347    } else {
348        format!("cd; {to_run}")
349    };
350    let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
351
352    // To support things like `gh cs ssh`/`coder ssh`, we run whatever command
353    // you have configured, but place our custom script on the path so that it will
354    // be run instead.
355    write!(
356        &mut ssh_file,
357        "#!/bin/sh\nexec {} \"$@\" {} {}",
358        real_ssh.to_string_lossy(),
359        if spawn_task.is_none() { "-t" } else { "" },
360        shlex::try_quote(shell_invocation)?,
361    )?;
362
363    // todo(windows)
364    #[cfg(not(target_os = "windows"))]
365    std::fs::set_permissions(ssh_path, smol::fs::unix::PermissionsExt::from_mode(0o755))?;
366
367    add_environment_path(env, tmp_dir)?;
368
369    let mut args = shlex::split(&ssh_command).unwrap_or_default();
370    let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
371    Ok(Shell::WithArguments { program, args })
372}
373
374fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> anyhow::Result<()> {
375    let mut env_paths = vec![new_path.to_path_buf()];
376    if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
377        let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
378        env_paths.append(&mut paths);
379    }
380
381    let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
382    env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
383
384    Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389    use collections::HashMap;
390
391    #[test]
392    fn test_add_environment_path_with_existing_path() {
393        let tmp_path = std::path::PathBuf::from("/tmp/new");
394        let mut env = HashMap::default();
395        let old_path = if cfg!(windows) {
396            "/usr/bin;/usr/local/bin"
397        } else {
398            "/usr/bin:/usr/local/bin"
399        };
400        env.insert("PATH".to_string(), old_path.to_string());
401        env.insert("OTHER".to_string(), "aaa".to_string());
402
403        super::add_environment_path(&mut env, &tmp_path).unwrap();
404        if cfg!(windows) {
405            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
406        } else {
407            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
408        }
409        assert_eq!(env.get("OTHER").unwrap(), "aaa");
410    }
411
412    #[test]
413    fn test_add_environment_path_with_empty_path() {
414        let tmp_path = std::path::PathBuf::from("/tmp/new");
415        let mut env = HashMap::default();
416        env.insert("OTHER".to_string(), "aaa".to_string());
417        let os_path = std::env::var("PATH").unwrap();
418        super::add_environment_path(&mut env, &tmp_path).unwrap();
419        if cfg!(windows) {
420            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
421        } else {
422            assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
423        }
424        assert_eq!(env.get("OTHER").unwrap(), "aaa");
425    }
426}