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