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