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