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