terminals.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
  4
  5use itertools::Itertools as _;
  6use language::LanguageName;
  7use remote::RemoteClient;
  8use settings::{Settings, SettingsLocation};
  9use smol::channel::bounded;
 10use std::{
 11    borrow::Cow,
 12    path::{Path, PathBuf},
 13    sync::Arc,
 14};
 15use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
 16use terminal::{
 17    TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
 18};
 19use util::{get_default_system_shell, maybe, rel_path::RelPath};
 20
 21use crate::{Project, ProjectPath};
 22
 23pub struct Terminals {
 24    pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
 25}
 26
 27impl Project {
 28    pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
 29        self.active_entry()
 30            .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
 31            .into_iter()
 32            .chain(self.worktrees(cx))
 33            .find_map(|tree| tree.read(cx).root_dir())
 34    }
 35
 36    pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
 37        let worktree = self.worktrees(cx).next()?;
 38        let worktree = worktree.read(cx);
 39        if worktree.root_entry()?.is_dir() {
 40            Some(worktree.abs_path().to_path_buf())
 41        } else {
 42            None
 43        }
 44    }
 45
 46    pub fn create_terminal_task(
 47        &mut self,
 48        spawn_task: SpawnInTerminal,
 49        cx: &mut Context<Self>,
 50    ) -> Task<Result<Entity<Terminal>>> {
 51        let is_via_remote = self.remote_client.is_some();
 52
 53        let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
 54            if is_via_remote {
 55                Some(Arc::from(cwd.as_ref()))
 56            } else {
 57                let cwd = cwd.to_string_lossy();
 58                let tilde_substituted = shellexpand::tilde(&cwd);
 59                Some(Arc::from(Path::new(tilde_substituted.as_ref())))
 60            }
 61        } else {
 62            self.active_project_directory(cx)
 63        };
 64
 65        let mut settings_location = None;
 66        if let Some(path) = path.as_ref()
 67            && let Some((worktree, _)) = self.find_worktree(path, cx)
 68        {
 69            settings_location = Some(SettingsLocation {
 70                worktree_id: worktree.read(cx).id(),
 71                path: RelPath::empty(),
 72            });
 73        }
 74        let settings = TerminalSettings::get(settings_location, cx).clone();
 75        let detect_venv = settings.detect_venv.as_option().is_some();
 76
 77        let (completion_tx, completion_rx) = bounded(1);
 78
 79        // Start with the environment that we might have inherited from the Zed CLI.
 80        let mut env = self
 81            .environment
 82            .read(cx)
 83            .get_cli_environment()
 84            .unwrap_or_default();
 85        // Then extend it with the explicit env variables from the settings, so they take
 86        // precedence.
 87        env.extend(settings.env);
 88
 89        let local_path = if is_via_remote { None } else { path.clone() };
 90        let task_state = Some(TaskState {
 91            spawned_task: spawn_task.clone(),
 92            status: TaskStatus::Running,
 93            completion_rx,
 94        });
 95        let remote_client = self.remote_client.clone();
 96        let shell = match &remote_client {
 97            Some(remote_client) => remote_client
 98                .read(cx)
 99                .shell()
100                .unwrap_or_else(get_default_system_shell),
101            None => settings.shell.program(),
102        };
103
104        let project_path_contexts = self
105            .active_entry()
106            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
107            .into_iter()
108            .chain(
109                self.visible_worktrees(cx)
110                    .map(|wt| wt.read(cx).id())
111                    .map(|worktree_id| ProjectPath {
112                        worktree_id,
113                        path: Arc::from(RelPath::empty()),
114                    }),
115            );
116        let toolchains = project_path_contexts
117            .filter(|_| detect_venv)
118            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
119            .collect::<Vec<_>>();
120        let lang_registry = self.languages.clone();
121        let fs = self.fs.clone();
122        cx.spawn(async move |project, cx| {
123            let shell_kind = ShellKind::new(&shell);
124            let activation_script = maybe!(async {
125                for toolchain in toolchains {
126                    let Some(toolchain) = toolchain.await else {
127                        continue;
128                    };
129                    let language = lang_registry
130                        .language_for_name(&toolchain.language_name.0)
131                        .await
132                        .ok();
133                    let lister = language?.toolchain_lister();
134                    return Some(
135                        lister?
136                            .activation_script(&toolchain, shell_kind, fs.as_ref())
137                            .await,
138                    );
139                }
140                None
141            })
142            .await
143            .unwrap_or_default();
144
145            project.update(cx, move |this, cx| {
146                let format_to_run = || {
147                    if let Some(command) = &spawn_task.command {
148                        let mut command: Option<Cow<str>> = shlex::try_quote(command).ok();
149                        if let Some(command) = &mut command
150                            && command.starts_with('"')
151                            && let Some(prefix) = shell_kind.command_prefix()
152                        {
153                            *command = Cow::Owned(format!("{prefix}{command}"));
154                        }
155
156                        let args = spawn_task
157                            .args
158                            .iter()
159                            .filter_map(|arg| shlex::try_quote(arg).ok());
160                        command.into_iter().chain(args).join(" ")
161                    } else {
162                        // todo: this breaks for remotes to windows
163                        format!("exec {shell} -l")
164                    }
165                };
166
167                let (shell, env) = {
168                    env.extend(spawn_task.env);
169                    match remote_client {
170                        Some(remote_client) => match activation_script.clone() {
171                            activation_script if !activation_script.is_empty() => {
172                                let activation_script = activation_script.join("; ");
173                                let to_run = format_to_run();
174                                let args =
175                                    vec!["-c".to_owned(), format!("{activation_script}; {to_run}")];
176                                create_remote_shell(
177                                    Some((
178                                        &remote_client
179                                            .read(cx)
180                                            .shell()
181                                            .unwrap_or_else(get_default_system_shell),
182                                        &args,
183                                    )),
184                                    env,
185                                    path,
186                                    remote_client,
187                                    cx,
188                                )?
189                            }
190                            _ => create_remote_shell(
191                                spawn_task
192                                    .command
193                                    .as_ref()
194                                    .map(|command| (command, &spawn_task.args)),
195                                env,
196                                path,
197                                remote_client,
198                                cx,
199                            )?,
200                        },
201                        None => match activation_script.clone() {
202                            activation_script if !activation_script.is_empty() => {
203                                let activation_script = activation_script.join("; ");
204                                let to_run = format_to_run();
205
206                                let arg = format!("{activation_script}; {to_run}");
207
208                                (
209                                    Shell::WithArguments {
210                                        program: shell,
211                                        args: vec!["-c".to_owned(), arg],
212                                        title_override: None,
213                                    },
214                                    env,
215                                )
216                            }
217                            _ => (
218                                if let Some(program) = spawn_task.command {
219                                    Shell::WithArguments {
220                                        program,
221                                        args: spawn_task.args,
222                                        title_override: None,
223                                    }
224                                } else {
225                                    Shell::System
226                                },
227                                env,
228                            ),
229                        },
230                    }
231                };
232                TerminalBuilder::new(
233                    local_path.map(|path| path.to_path_buf()),
234                    task_state,
235                    shell,
236                    env,
237                    settings.cursor_shape.unwrap_or_default(),
238                    settings.alternate_scroll,
239                    settings.max_scroll_history_lines,
240                    is_via_remote,
241                    cx.entity_id().as_u64(),
242                    Some(completion_tx),
243                    cx,
244                    activation_script,
245                )
246                .map(|builder| {
247                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
248
249                    this.terminals
250                        .local_handles
251                        .push(terminal_handle.downgrade());
252
253                    let id = terminal_handle.entity_id();
254                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
255                        let handles = &mut project.terminals.local_handles;
256
257                        if let Some(index) = handles
258                            .iter()
259                            .position(|terminal| terminal.entity_id() == id)
260                        {
261                            handles.remove(index);
262                            cx.notify();
263                        }
264                    })
265                    .detach();
266
267                    terminal_handle
268                })
269            })?
270        })
271    }
272
273    pub fn create_terminal_shell(
274        &mut self,
275        cwd: Option<PathBuf>,
276        cx: &mut Context<Self>,
277    ) -> Task<Result<Entity<Terminal>>> {
278        let path = cwd.map(|p| Arc::from(&*p));
279        let is_via_remote = self.remote_client.is_some();
280
281        let mut settings_location = None;
282        if let Some(path) = path.as_ref()
283            && let Some((worktree, _)) = self.find_worktree(path, cx)
284        {
285            settings_location = Some(SettingsLocation {
286                worktree_id: worktree.read(cx).id(),
287                path: RelPath::empty(),
288            });
289        }
290        let settings = TerminalSettings::get(settings_location, cx).clone();
291        let detect_venv = settings.detect_venv.as_option().is_some();
292
293        // Start with the environment that we might have inherited from the Zed CLI.
294        let mut env = self
295            .environment
296            .read(cx)
297            .get_cli_environment()
298            .unwrap_or_default();
299        // Then extend it with the explicit env variables from the settings, so they take
300        // precedence.
301        env.extend(settings.env);
302
303        let local_path = if is_via_remote { None } else { path.clone() };
304
305        let project_path_contexts = self
306            .active_entry()
307            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
308            .into_iter()
309            .chain(
310                self.visible_worktrees(cx)
311                    .map(|wt| wt.read(cx).id())
312                    .map(|worktree_id| ProjectPath {
313                        worktree_id,
314                        path: RelPath::empty().into(),
315                    }),
316            );
317        let toolchains = project_path_contexts
318            .filter(|_| detect_venv)
319            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
320            .collect::<Vec<_>>();
321        let remote_client = self.remote_client.clone();
322        let shell_kind = ShellKind::new(&match &remote_client {
323            Some(remote_client) => remote_client
324                .read(cx)
325                .shell()
326                .unwrap_or_else(get_default_system_shell),
327            None => settings.shell.program(),
328        });
329
330        let lang_registry = self.languages.clone();
331        let fs = self.fs.clone();
332        cx.spawn(async move |project, cx| {
333            let activation_script = maybe!(async {
334                for toolchain in toolchains {
335                    let Some(toolchain) = toolchain.await else {
336                        continue;
337                    };
338                    let language = lang_registry
339                        .language_for_name(&toolchain.language_name.0)
340                        .await
341                        .ok();
342                    let lister = language?.toolchain_lister();
343                    return Some(
344                        lister?
345                            .activation_script(&toolchain, shell_kind, fs.as_ref())
346                            .await,
347                    );
348                }
349                None
350            })
351            .await
352            .unwrap_or_default();
353            project.update(cx, move |this, cx| {
354                let (shell, env) = {
355                    match remote_client {
356                        Some(remote_client) => {
357                            create_remote_shell(None, env, path, remote_client, cx)?
358                        }
359                        None => (settings.shell, env),
360                    }
361                };
362                TerminalBuilder::new(
363                    local_path.map(|path| path.to_path_buf()),
364                    None,
365                    shell,
366                    env,
367                    settings.cursor_shape.unwrap_or_default(),
368                    settings.alternate_scroll,
369                    settings.max_scroll_history_lines,
370                    is_via_remote,
371                    cx.entity_id().as_u64(),
372                    None,
373                    cx,
374                    activation_script,
375                )
376                .map(|builder| {
377                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
378
379                    this.terminals
380                        .local_handles
381                        .push(terminal_handle.downgrade());
382
383                    let id = terminal_handle.entity_id();
384                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
385                        let handles = &mut project.terminals.local_handles;
386
387                        if let Some(index) = handles
388                            .iter()
389                            .position(|terminal| terminal.entity_id() == id)
390                        {
391                            handles.remove(index);
392                            cx.notify();
393                        }
394                    })
395                    .detach();
396
397                    terminal_handle
398                })
399            })?
400        })
401    }
402
403    pub fn clone_terminal(
404        &mut self,
405        terminal: &Entity<Terminal>,
406        cx: &mut Context<'_, Project>,
407        cwd: impl FnOnce() -> Option<PathBuf>,
408    ) -> Result<Entity<Terminal>> {
409        terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
410            let terminal_handle = cx.new(|cx| builder.subscribe(cx));
411
412            self.terminals
413                .local_handles
414                .push(terminal_handle.downgrade());
415
416            let id = terminal_handle.entity_id();
417            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
418                let handles = &mut project.terminals.local_handles;
419
420                if let Some(index) = handles
421                    .iter()
422                    .position(|terminal| terminal.entity_id() == id)
423                {
424                    handles.remove(index);
425                    cx.notify();
426                }
427            })
428            .detach();
429
430            terminal_handle
431        })
432    }
433
434    pub fn terminal_settings<'a>(
435        &'a self,
436        path: &'a Option<PathBuf>,
437        cx: &'a App,
438    ) -> &'a TerminalSettings {
439        let mut settings_location = None;
440        if let Some(path) = path.as_ref()
441            && let Some((worktree, _)) = self.find_worktree(path, cx)
442        {
443            settings_location = Some(SettingsLocation {
444                worktree_id: worktree.read(cx).id(),
445                path: RelPath::empty(),
446            });
447        }
448        TerminalSettings::get(settings_location, cx)
449    }
450
451    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<smol::process::Command> {
452        let path = self.first_project_directory(cx);
453        let remote_client = self.remote_client.as_ref();
454        let settings = self.terminal_settings(&path, cx).clone();
455        let shell = remote_client
456            .as_ref()
457            .and_then(|remote_client| remote_client.read(cx).shell())
458            .map(Shell::Program)
459            .unwrap_or_else(|| settings.shell.clone());
460        let builder = ShellBuilder::new(&shell).non_interactive();
461        let (command, args) = builder.build(Some(command), &Vec::new());
462
463        let mut env = self
464            .environment
465            .read(cx)
466            .get_cli_environment()
467            .unwrap_or_default();
468        env.extend(settings.env);
469
470        match remote_client {
471            Some(remote_client) => {
472                let command_template =
473                    remote_client
474                        .read(cx)
475                        .build_command(Some(command), &args, &env, None, None)?;
476                let mut command = std::process::Command::new(command_template.program);
477                command.args(command_template.args);
478                command.envs(command_template.env);
479                Ok(command)
480            }
481            None => {
482                let mut command = std::process::Command::new(command);
483                command.args(args);
484                command.envs(env);
485                if let Some(path) = path {
486                    command.current_dir(path);
487                }
488                Ok(command)
489            }
490        }
491        .map(|mut process| {
492            util::set_pre_exec_to_start_new_session(&mut process);
493            smol::process::Command::from(process)
494        })
495    }
496
497    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
498        &self.terminals.local_handles
499    }
500}
501
502fn create_remote_shell(
503    spawn_command: Option<(&String, &Vec<String>)>,
504    mut env: HashMap<String, String>,
505    working_directory: Option<Arc<Path>>,
506    remote_client: Entity<RemoteClient>,
507    cx: &mut App,
508) -> Result<(Shell, HashMap<String, String>)> {
509    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
510    // to properly display colors.
511    // We do not have the luxury of assuming the host has it installed,
512    // so we set it to a default that does not break the highlighting via ssh.
513    env.entry("TERM".to_string())
514        .or_insert_with(|| "xterm-256color".to_string());
515
516    let (program, args) = match spawn_command {
517        Some((program, args)) => (Some(program.clone()), args),
518        None => (None, &Vec::new()),
519    };
520
521    let command = remote_client.read(cx).build_command(
522        program,
523        args.as_slice(),
524        &env,
525        working_directory.map(|path| path.display().to_string()),
526        None,
527    )?;
528
529    log::debug!("Connecting to a remote server: {:?}", command.program);
530    let host = remote_client.read(cx).connection_options().display_name();
531
532    Ok((
533        Shell::WithArguments {
534            program: command.program,
535            args: command.args,
536            title_override: Some(format!("{} — Terminal", host).into()),
537        },
538        command.env,
539    ))
540}