terminals.rs

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