terminals.rs

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