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, get_system_shell, maybe};
 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,
 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            id: spawn_task.id,
 92            full_label: spawn_task.full_label,
 93            label: spawn_task.label,
 94            command_label: spawn_task.command_label,
 95            hide: spawn_task.hide,
 96            status: TaskStatus::Running,
 97            show_summary: spawn_task.show_summary,
 98            show_command: spawn_task.show_command,
 99            show_rerun: spawn_task.show_rerun,
100            completion_rx,
101        });
102        let remote_client = self.remote_client.clone();
103        let shell = match &remote_client {
104            Some(remote_client) => remote_client
105                .read(cx)
106                .shell()
107                .unwrap_or_else(get_default_system_shell),
108            None => match &settings.shell {
109                Shell::Program(program) => program.clone(),
110                Shell::WithArguments {
111                    program,
112                    args: _,
113                    title_override: _,
114                } => program.clone(),
115                Shell::System => get_system_shell(),
116            },
117        };
118
119        let project_path_contexts = self
120            .active_entry()
121            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
122            .into_iter()
123            .chain(
124                self.visible_worktrees(cx)
125                    .map(|wt| wt.read(cx).id())
126                    .map(|worktree_id| ProjectPath {
127                        worktree_id,
128                        path: Arc::from(Path::new("")),
129                    }),
130            );
131        let toolchains = project_path_contexts
132            .filter(|_| detect_venv)
133            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
134            .collect::<Vec<_>>();
135        let lang_registry = self.languages.clone();
136        let fs = self.fs.clone();
137        cx.spawn(async move |project, cx| {
138            let shell_kind = ShellKind::new(&shell);
139            let activation_script = maybe!(async {
140                for toolchain in toolchains {
141                    let Some(toolchain) = toolchain.await else {
142                        continue;
143                    };
144                    let language = lang_registry
145                        .language_for_name(&toolchain.language_name.0)
146                        .await
147                        .ok();
148                    let lister = language?.toolchain_lister();
149                    return Some(
150                        lister?
151                            .activation_script(&toolchain, shell_kind, fs.as_ref())
152                            .await,
153                    );
154                }
155                None
156            })
157            .await
158            .unwrap_or_default();
159
160            project.update(cx, move |this, cx| {
161                let format_to_run = || {
162                    if let Some(command) = &spawn_task.command {
163                        let mut command: Option<Cow<str>> = shlex::try_quote(command).ok();
164                        if let Some(command) = &mut command
165                            && command.starts_with('"')
166                            && let Some(prefix) = shell_kind.command_prefix()
167                        {
168                            *command = Cow::Owned(format!("{prefix}{command}"));
169                        }
170
171                        let args = spawn_task
172                            .args
173                            .iter()
174                            .filter_map(|arg| shlex::try_quote(arg).ok());
175                        command.into_iter().chain(args).join(" ")
176                    } else {
177                        // todo: this breaks for remotes to windows
178                        format!("exec {shell} -l")
179                    }
180                };
181
182                let (shell, env) = {
183                    env.extend(spawn_task.env);
184                    match remote_client {
185                        Some(remote_client) => match activation_script.clone() {
186                            activation_script if !activation_script.is_empty() => {
187                                let activation_script = activation_script.join("; ");
188                                let to_run = format_to_run();
189                                let args =
190                                    vec!["-c".to_owned(), format!("{activation_script}; {to_run}")];
191                                create_remote_shell(
192                                    Some((
193                                        &remote_client
194                                            .read(cx)
195                                            .shell()
196                                            .unwrap_or_else(get_default_system_shell),
197                                        &args,
198                                    )),
199                                    env,
200                                    path,
201                                    remote_client,
202                                    cx,
203                                )?
204                            }
205                            _ => create_remote_shell(
206                                spawn_task
207                                    .command
208                                    .as_ref()
209                                    .map(|command| (command, &spawn_task.args)),
210                                env,
211                                path,
212                                remote_client,
213                                cx,
214                            )?,
215                        },
216                        None => match activation_script.clone() {
217                            activation_script if !activation_script.is_empty() => {
218                                let activation_script = activation_script.join("; ");
219                                let to_run = format_to_run();
220
221                                // todo(lw): Alacritty uses `CreateProcessW` on windows with the entire command and arg sequence merged into a single string,
222                                // without quoting the arguments
223                                #[cfg(windows)]
224                                let arg =
225                                    quote_arg(&format!("{activation_script}; {to_run}"), true);
226                                #[cfg(not(windows))]
227                                let arg = format!("{activation_script}; {to_run}");
228
229                                (
230                                    Shell::WithArguments {
231                                        program: shell,
232                                        args: vec!["-c".to_owned(), arg],
233                                        title_override: None,
234                                    },
235                                    env,
236                                )
237                            }
238                            _ => (
239                                if let Some(program) = spawn_task.command {
240                                    Shell::WithArguments {
241                                        program,
242                                        args: spawn_task.args,
243                                        title_override: None,
244                                    }
245                                } else {
246                                    Shell::System
247                                },
248                                env,
249                            ),
250                        },
251                    }
252                };
253                TerminalBuilder::new(
254                    local_path.map(|path| path.to_path_buf()),
255                    task_state,
256                    shell,
257                    env,
258                    settings.cursor_shape.unwrap_or_default(),
259                    settings.alternate_scroll,
260                    settings.max_scroll_history_lines,
261                    is_via_remote,
262                    cx.entity_id().as_u64(),
263                    Some(completion_tx),
264                    cx,
265                    activation_script,
266                )
267                .map(|builder| {
268                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
269
270                    this.terminals
271                        .local_handles
272                        .push(terminal_handle.downgrade());
273
274                    let id = terminal_handle.entity_id();
275                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
276                        let handles = &mut project.terminals.local_handles;
277
278                        if let Some(index) = handles
279                            .iter()
280                            .position(|terminal| terminal.entity_id() == id)
281                        {
282                            handles.remove(index);
283                            cx.notify();
284                        }
285                    })
286                    .detach();
287
288                    terminal_handle
289                })
290            })?
291        })
292    }
293
294    pub fn create_terminal_shell(
295        &mut self,
296        cwd: Option<PathBuf>,
297        cx: &mut Context<Self>,
298    ) -> Task<Result<Entity<Terminal>>> {
299        let path = cwd.map(|p| Arc::from(&*p));
300        let is_via_remote = self.remote_client.is_some();
301
302        let mut settings_location = None;
303        if let Some(path) = path.as_ref()
304            && let Some((worktree, _)) = self.find_worktree(path, cx)
305        {
306            settings_location = Some(SettingsLocation {
307                worktree_id: worktree.read(cx).id(),
308                path,
309            });
310        }
311        let settings = TerminalSettings::get(settings_location, cx).clone();
312        let detect_venv = settings.detect_venv.as_option().is_some();
313
314        // Start with the environment that we might have inherited from the Zed CLI.
315        let mut env = self
316            .environment
317            .read(cx)
318            .get_cli_environment()
319            .unwrap_or_default();
320        // Then extend it with the explicit env variables from the settings, so they take
321        // precedence.
322        env.extend(settings.env);
323
324        let local_path = if is_via_remote { None } else { path.clone() };
325
326        let project_path_contexts = self
327            .active_entry()
328            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
329            .into_iter()
330            .chain(
331                self.visible_worktrees(cx)
332                    .map(|wt| wt.read(cx).id())
333                    .map(|worktree_id| ProjectPath {
334                        worktree_id,
335                        path: Arc::from(Path::new("")),
336                    }),
337            );
338        let toolchains = project_path_contexts
339            .filter(|_| detect_venv)
340            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
341            .collect::<Vec<_>>();
342        let remote_client = self.remote_client.clone();
343        let shell_kind = ShellKind::new(&match &remote_client {
344            Some(remote_client) => remote_client
345                .read(cx)
346                .shell()
347                .unwrap_or_else(get_default_system_shell),
348            None => match &settings.shell {
349                Shell::Program(program) => program.clone(),
350                Shell::WithArguments {
351                    program,
352                    args: _,
353                    title_override: _,
354                } => program.clone(),
355                Shell::System => get_system_shell(),
356            },
357        });
358
359        let lang_registry = self.languages.clone();
360        let fs = self.fs.clone();
361        cx.spawn(async move |project, cx| {
362            let activation_script = maybe!(async {
363                for toolchain in toolchains {
364                    let Some(toolchain) = toolchain.await else {
365                        continue;
366                    };
367                    let language = lang_registry
368                        .language_for_name(&toolchain.language_name.0)
369                        .await
370                        .ok();
371                    let lister = language?.toolchain_lister();
372                    return Some(
373                        lister?
374                            .activation_script(&toolchain, shell_kind, fs.as_ref())
375                            .await,
376                    );
377                }
378                None
379            })
380            .await
381            .unwrap_or_default();
382            project.update(cx, move |this, cx| {
383                let (shell, env) = {
384                    match remote_client {
385                        Some(remote_client) => {
386                            create_remote_shell(None, env, path, remote_client, cx)?
387                        }
388                        None => (settings.shell, env),
389                    }
390                };
391                TerminalBuilder::new(
392                    local_path.map(|path| path.to_path_buf()),
393                    None,
394                    shell,
395                    env,
396                    settings.cursor_shape.unwrap_or_default(),
397                    settings.alternate_scroll,
398                    settings.max_scroll_history_lines,
399                    is_via_remote,
400                    cx.entity_id().as_u64(),
401                    None,
402                    cx,
403                    activation_script,
404                )
405                .map(|builder| {
406                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
407
408                    this.terminals
409                        .local_handles
410                        .push(terminal_handle.downgrade());
411
412                    let id = terminal_handle.entity_id();
413                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
414                        let handles = &mut project.terminals.local_handles;
415
416                        if let Some(index) = handles
417                            .iter()
418                            .position(|terminal| terminal.entity_id() == id)
419                        {
420                            handles.remove(index);
421                            cx.notify();
422                        }
423                    })
424                    .detach();
425
426                    terminal_handle
427                })
428            })?
429        })
430    }
431
432    pub fn clone_terminal(
433        &mut self,
434        terminal: &Entity<Terminal>,
435        cx: &mut Context<'_, Project>,
436        cwd: impl FnOnce() -> Option<PathBuf>,
437    ) -> Result<Entity<Terminal>> {
438        terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
439            let terminal_handle = cx.new(|cx| builder.subscribe(cx));
440
441            self.terminals
442                .local_handles
443                .push(terminal_handle.downgrade());
444
445            let id = terminal_handle.entity_id();
446            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
447                let handles = &mut project.terminals.local_handles;
448
449                if let Some(index) = handles
450                    .iter()
451                    .position(|terminal| terminal.entity_id() == id)
452                {
453                    handles.remove(index);
454                    cx.notify();
455                }
456            })
457            .detach();
458
459            terminal_handle
460        })
461    }
462
463    pub fn terminal_settings<'a>(
464        &'a self,
465        path: &'a Option<PathBuf>,
466        cx: &'a App,
467    ) -> &'a TerminalSettings {
468        let mut settings_location = None;
469        if let Some(path) = path.as_ref()
470            && let Some((worktree, _)) = self.find_worktree(path, cx)
471        {
472            settings_location = Some(SettingsLocation {
473                worktree_id: worktree.read(cx).id(),
474                path,
475            });
476        }
477        TerminalSettings::get(settings_location, cx)
478    }
479
480    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
481        let path = self.first_project_directory(cx);
482        let remote_client = self.remote_client.as_ref();
483        let settings = self.terminal_settings(&path, cx).clone();
484        let remote_shell = remote_client
485            .as_ref()
486            .and_then(|remote_client| remote_client.read(cx).shell());
487        let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
488        let (command, args) = builder.build(Some(command), &Vec::new());
489
490        let mut env = self
491            .environment
492            .read(cx)
493            .get_cli_environment()
494            .unwrap_or_default();
495        env.extend(settings.env);
496
497        match remote_client {
498            Some(remote_client) => {
499                let command_template =
500                    remote_client
501                        .read(cx)
502                        .build_command(Some(command), &args, &env, None, None)?;
503                let mut command = std::process::Command::new(command_template.program);
504                command.args(command_template.args);
505                command.envs(command_template.env);
506                Ok(command)
507            }
508            None => {
509                let mut command = std::process::Command::new(command);
510                command.args(args);
511                command.envs(env);
512                if let Some(path) = path {
513                    command.current_dir(path);
514                }
515                Ok(command)
516            }
517        }
518    }
519
520    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
521        &self.terminals.local_handles
522    }
523}
524
525/// We're not using shlex for windows as it is overly eager with escaping some of the special characters (^) we need for nu. Hence, we took
526/// that quote impl straight from Rust stdlib (Command API).
527#[cfg(windows)]
528fn quote_arg(argument: &str, quote: bool) -> String {
529    let mut arg = String::new();
530    if quote {
531        arg.push('"');
532    }
533
534    let mut backslashes: usize = 0;
535    for x in argument.chars() {
536        if x == '\\' {
537            backslashes += 1;
538        } else {
539            if x == '"' {
540                // Add n+1 backslashes to total 2n+1 before internal '"'.
541                arg.extend((0..=backslashes).map(|_| '\\'));
542            }
543            backslashes = 0;
544        }
545        arg.push(x);
546    }
547
548    if quote {
549        // Add n backslashes to total 2n before ending '"'.
550        arg.extend((0..backslashes).map(|_| '\\'));
551        arg.push('"');
552    }
553    arg
554}
555
556fn create_remote_shell(
557    spawn_command: Option<(&String, &Vec<String>)>,
558    mut env: HashMap<String, String>,
559    working_directory: Option<Arc<Path>>,
560    remote_client: Entity<RemoteClient>,
561    cx: &mut App,
562) -> Result<(Shell, HashMap<String, String>)> {
563    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
564    // to properly display colors.
565    // We do not have the luxury of assuming the host has it installed,
566    // so we set it to a default that does not break the highlighting via ssh.
567    env.entry("TERM".to_string())
568        .or_insert_with(|| "xterm-256color".to_string());
569
570    let (program, args) = match spawn_command {
571        Some((program, args)) => (Some(program.clone()), args),
572        None => (None, &Vec::new()),
573    };
574
575    let command = remote_client.read(cx).build_command(
576        program,
577        args.as_slice(),
578        &env,
579        working_directory.map(|path| path.display().to_string()),
580        None,
581    )?;
582
583    log::debug!("Connecting to a remote server: {:?}", command.program);
584    let host = remote_client.read(cx).connection_options().display_name();
585
586    Ok((
587        Shell::WithArguments {
588            program: command.program,
589            args: command.args,
590            title_override: Some(format!("{} — Terminal", host).into()),
591        },
592        command.env,
593    ))
594}