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        let project_path_context = self
 53            .active_entry()
 54            .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
 55            .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
 56            .map(|worktree_id| ProjectPath {
 57                worktree_id,
 58                path: Arc::from(Path::new("")),
 59            });
 60
 61        let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
 62            if is_via_remote {
 63                Some(Arc::from(cwd.as_ref()))
 64            } else {
 65                let cwd = cwd.to_string_lossy();
 66                let tilde_substituted = shellexpand::tilde(&cwd);
 67                Some(Arc::from(Path::new(tilde_substituted.as_ref())))
 68            }
 69        } else {
 70            self.active_project_directory(cx)
 71        };
 72
 73        let mut settings_location = None;
 74        if let Some(path) = path.as_ref()
 75            && let Some((worktree, _)) = self.find_worktree(path, cx)
 76        {
 77            settings_location = Some(SettingsLocation {
 78                worktree_id: worktree.read(cx).id(),
 79                path,
 80            });
 81        }
 82        let settings = TerminalSettings::get(settings_location, cx).clone();
 83        let detect_venv = settings.detect_venv.as_option().is_some();
 84
 85        let (completion_tx, completion_rx) = bounded(1);
 86
 87        // Start with the environment that we might have inherited from the Zed CLI.
 88        let mut env = self
 89            .environment
 90            .read(cx)
 91            .get_cli_environment()
 92            .unwrap_or_default();
 93        // Then extend it with the explicit env variables from the settings, so they take
 94        // precedence.
 95        env.extend(settings.env);
 96
 97        let local_path = if is_via_remote { None } else { path.clone() };
 98        let task_state = Some(TaskState {
 99            id: spawn_task.id,
100            full_label: spawn_task.full_label,
101            label: spawn_task.label,
102            command_label: spawn_task.command_label,
103            hide: spawn_task.hide,
104            status: TaskStatus::Running,
105            show_summary: spawn_task.show_summary,
106            show_command: spawn_task.show_command,
107            show_rerun: spawn_task.show_rerun,
108            completion_rx,
109        });
110        let remote_client = self.remote_client.clone();
111        let shell = match &remote_client {
112            Some(remote_client) => remote_client
113                .read(cx)
114                .shell()
115                .unwrap_or_else(get_default_system_shell),
116            None => match &settings.shell {
117                Shell::Program(program) => program.clone(),
118                Shell::WithArguments {
119                    program,
120                    args: _,
121                    title_override: _,
122                } => program.clone(),
123                Shell::System => get_system_shell(),
124            },
125        };
126
127        let toolchain = project_path_context
128            .filter(|_| detect_venv)
129            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
130        let lang_registry = self.languages.clone();
131        let fs = self.fs.clone();
132        cx.spawn(async move |project, cx| {
133            let activation_script = maybe!(async {
134                let toolchain = toolchain?.await?;
135                Some(
136                    lang_registry
137                        .language_for_name(&toolchain.language_name.0)
138                        .await
139                        .ok()?
140                        .toolchain_lister()?
141                        .activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
142                        .await,
143                )
144            })
145            .await
146            .unwrap_or_default();
147
148            project.update(cx, move |this, cx| {
149                let shell = {
150                    env.extend(spawn_task.env);
151                    match remote_client {
152                        Some(remote_client) => match activation_script.clone() {
153                            activation_script if !activation_script.is_empty() => {
154                                let activation_script = activation_script.join("; ");
155                                let to_run = if let Some(command) = spawn_task.command {
156                                    let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
157                                    let args = spawn_task
158                                        .args
159                                        .iter()
160                                        .filter_map(|arg| shlex::try_quote(arg).ok());
161                                    command.into_iter().chain(args).join(" ")
162                                } else {
163                                    format!("exec {shell} -l")
164                                };
165                                let args = vec![
166                                    "-c".to_owned(),
167                                    format!("{activation_script}; {to_run}",),
168                                ];
169                                create_remote_shell(
170                                    Some((&shell, &args)),
171                                    &mut env,
172                                    path,
173                                    remote_client,
174                                    cx,
175                                )?
176                            }
177                            _ => create_remote_shell(
178                                spawn_task
179                                    .command
180                                    .as_ref()
181                                    .map(|command| (command, &spawn_task.args)),
182                                &mut env,
183                                path,
184                                remote_client,
185                                cx,
186                            )?,
187                        },
188                        None => match activation_script.clone() {
189                            activation_script if !activation_script.is_empty() => {
190                                let activation_script = activation_script.join("; ");
191                                let to_run = if let Some(command) = spawn_task.command {
192                                    let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
193                                    let args = spawn_task
194                                        .args
195                                        .iter()
196                                        .filter_map(|arg| shlex::try_quote(arg).ok());
197                                    command.into_iter().chain(args).join(" ")
198                                } else {
199                                    format!("exec {shell} -l")
200                                };
201                                Shell::WithArguments {
202                                    program: shell,
203                                    args: vec![
204                                        "-c".to_owned(),
205                                        format!("{activation_script}; {to_run}",),
206                                    ],
207                                    title_override: None,
208                                }
209                            }
210                            _ => {
211                                if let Some(program) = spawn_task.command {
212                                    Shell::WithArguments {
213                                        program,
214                                        args: spawn_task.args,
215                                        title_override: None,
216                                    }
217                                } else {
218                                    Shell::System
219                                }
220                            }
221                        },
222                    }
223                };
224                TerminalBuilder::new(
225                    local_path.map(|path| path.to_path_buf()),
226                    task_state,
227                    shell,
228                    env,
229                    settings.cursor_shape.unwrap_or_default(),
230                    settings.alternate_scroll,
231                    settings.max_scroll_history_lines,
232                    is_via_remote,
233                    cx.entity_id().as_u64(),
234                    Some(completion_tx),
235                    cx,
236                    activation_script,
237                )
238                .map(|builder| {
239                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
240
241                    this.terminals
242                        .local_handles
243                        .push(terminal_handle.downgrade());
244
245                    let id = terminal_handle.entity_id();
246                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
247                        let handles = &mut project.terminals.local_handles;
248
249                        if let Some(index) = handles
250                            .iter()
251                            .position(|terminal| terminal.entity_id() == id)
252                        {
253                            handles.remove(index);
254                            cx.notify();
255                        }
256                    })
257                    .detach();
258
259                    terminal_handle
260                })
261            })?
262        })
263    }
264
265    pub fn create_terminal_shell(
266        &mut self,
267        cwd: Option<PathBuf>,
268        cx: &mut Context<Self>,
269    ) -> Task<Result<Entity<Terminal>>> {
270        let project_path_context = self
271            .active_entry()
272            .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
273            .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
274            .map(|worktree_id| ProjectPath {
275                worktree_id,
276                path: Arc::from(Path::new("")),
277            });
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,
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 toolchain = project_path_context
306            .filter(|_| detect_venv)
307            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
308        let remote_client = self.remote_client.clone();
309        let shell = match &remote_client {
310            Some(remote_client) => remote_client
311                .read(cx)
312                .shell()
313                .unwrap_or_else(get_default_system_shell),
314            None => match &settings.shell {
315                Shell::Program(program) => program.clone(),
316                Shell::WithArguments {
317                    program,
318                    args: _,
319                    title_override: _,
320                } => program.clone(),
321                Shell::System => get_system_shell(),
322            },
323        };
324
325        let lang_registry = self.languages.clone();
326        let fs = self.fs.clone();
327        cx.spawn(async move |project, cx| {
328            let activation_script = maybe!(async {
329                let toolchain = toolchain?.await?;
330                let language = lang_registry
331                    .language_for_name(&toolchain.language_name.0)
332                    .await
333                    .ok();
334                let lister = language?.toolchain_lister();
335                Some(
336                    lister?
337                        .activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
338                        .await,
339                )
340            })
341            .await
342            .unwrap_or_default();
343            project.update(cx, move |this, cx| {
344                let shell = {
345                    match remote_client {
346                        Some(remote_client) => {
347                            create_remote_shell(None, &mut env, path, remote_client, cx)?
348                        }
349                        None => settings.shell,
350                    }
351                };
352                TerminalBuilder::new(
353                    local_path.map(|path| path.to_path_buf()),
354                    None,
355                    shell,
356                    env,
357                    settings.cursor_shape.unwrap_or_default(),
358                    settings.alternate_scroll,
359                    settings.max_scroll_history_lines,
360                    is_via_remote,
361                    cx.entity_id().as_u64(),
362                    None,
363                    cx,
364                    activation_script,
365                )
366                .map(|builder| {
367                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
368
369                    this.terminals
370                        .local_handles
371                        .push(terminal_handle.downgrade());
372
373                    let id = terminal_handle.entity_id();
374                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
375                        let handles = &mut project.terminals.local_handles;
376
377                        if let Some(index) = handles
378                            .iter()
379                            .position(|terminal| terminal.entity_id() == id)
380                        {
381                            handles.remove(index);
382                            cx.notify();
383                        }
384                    })
385                    .detach();
386
387                    terminal_handle
388                })
389            })?
390        })
391    }
392
393    pub fn clone_terminal(
394        &mut self,
395        terminal: &Entity<Terminal>,
396        cx: &mut Context<'_, Project>,
397        cwd: impl FnOnce() -> Option<PathBuf>,
398    ) -> Result<Entity<Terminal>> {
399        terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
400            let terminal_handle = cx.new(|cx| builder.subscribe(cx));
401
402            self.terminals
403                .local_handles
404                .push(terminal_handle.downgrade());
405
406            let id = terminal_handle.entity_id();
407            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
408                let handles = &mut project.terminals.local_handles;
409
410                if let Some(index) = handles
411                    .iter()
412                    .position(|terminal| terminal.entity_id() == id)
413                {
414                    handles.remove(index);
415                    cx.notify();
416                }
417            })
418            .detach();
419
420            terminal_handle
421        })
422    }
423
424    pub fn terminal_settings<'a>(
425        &'a self,
426        path: &'a Option<PathBuf>,
427        cx: &'a App,
428    ) -> &'a TerminalSettings {
429        let mut settings_location = None;
430        if let Some(path) = path.as_ref()
431            && let Some((worktree, _)) = self.find_worktree(path, cx)
432        {
433            settings_location = Some(SettingsLocation {
434                worktree_id: worktree.read(cx).id(),
435                path,
436            });
437        }
438        TerminalSettings::get(settings_location, cx)
439    }
440
441    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
442        let path = self.first_project_directory(cx);
443        let remote_client = self.remote_client.as_ref();
444        let settings = self.terminal_settings(&path, cx).clone();
445        let remote_shell = remote_client
446            .as_ref()
447            .and_then(|remote_client| remote_client.read(cx).shell());
448        let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
449        let (command, args) = builder.build(Some(command), &Vec::new());
450
451        let mut env = self
452            .environment
453            .read(cx)
454            .get_cli_environment()
455            .unwrap_or_default();
456        env.extend(settings.env);
457
458        match remote_client {
459            Some(remote_client) => {
460                let command_template =
461                    remote_client
462                        .read(cx)
463                        .build_command(Some(command), &args, &env, None, None)?;
464                let mut command = std::process::Command::new(command_template.program);
465                command.args(command_template.args);
466                command.envs(command_template.env);
467                Ok(command)
468            }
469            None => {
470                let mut command = std::process::Command::new(command);
471                command.args(args);
472                command.envs(env);
473                if let Some(path) = path {
474                    command.current_dir(path);
475                }
476                Ok(command)
477            }
478        }
479    }
480
481    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
482        &self.terminals.local_handles
483    }
484}
485
486fn create_remote_shell(
487    spawn_command: Option<(&String, &Vec<String>)>,
488    env: &mut HashMap<String, String>,
489    working_directory: Option<Arc<Path>>,
490    remote_client: Entity<RemoteClient>,
491    cx: &mut App,
492) -> Result<Shell> {
493    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
494    // to properly display colors.
495    // We do not have the luxury of assuming the host has it installed,
496    // so we set it to a default that does not break the highlighting via ssh.
497    env.entry("TERM".to_string())
498        .or_insert_with(|| "xterm-256color".to_string());
499
500    let (program, args) = match spawn_command {
501        Some((program, args)) => (Some(program.clone()), args),
502        None => (None, &Vec::new()),
503    };
504
505    let command = remote_client.read(cx).build_command(
506        program,
507        args.as_slice(),
508        env,
509        working_directory.map(|path| path.display().to_string()),
510        None,
511    )?;
512    *env = command.env;
513
514    log::debug!("Connecting to a remote server: {:?}", command.program);
515    let host = remote_client.read(cx).connection_options().display_name();
516
517    Ok(Shell::WithArguments {
518        program: command.program,
519        args: command.args,
520        title_override: Some(format!("{} — Terminal", host).into()),
521    })
522}