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