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