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