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