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