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