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