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