terminals.rs

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