terminals.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
  4
  5use futures::{FutureExt, future::Shared};
  6use itertools::Itertools as _;
  7use language::LanguageName;
  8use remote::RemoteClient;
  9use settings::{Settings, SettingsLocation};
 10use smol::channel::bounded;
 11use std::{
 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        let local_path = if is_via_remote { None } else { path.clone() };
 80        let task_state = Some(TaskState {
 81            spawned_task: spawn_task.clone(),
 82            status: TaskStatus::Running,
 83            completion_rx,
 84        });
 85        let remote_client = self.remote_client.clone();
 86        let shell = match &remote_client {
 87            Some(remote_client) => remote_client
 88                .read(cx)
 89                .shell()
 90                .unwrap_or_else(get_default_system_shell),
 91            None => settings.shell.program(),
 92        };
 93        let is_windows = self.path_style(cx).is_windows();
 94        let shell_kind = ShellKind::new(&shell, is_windows);
 95
 96        // Prepare a task for resolving the environment
 97        let env_task =
 98            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
 99
100        let project_path_contexts = self
101            .active_entry()
102            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
103            .into_iter()
104            .chain(
105                self.visible_worktrees(cx)
106                    .map(|wt| wt.read(cx).id())
107                    .map(|worktree_id| ProjectPath {
108                        worktree_id,
109                        path: Arc::from(RelPath::empty()),
110                    }),
111            );
112        let toolchains = project_path_contexts
113            .filter(|_| detect_venv)
114            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
115            .collect::<Vec<_>>();
116        let lang_registry = self.languages.clone();
117        cx.spawn(async move |project, cx| {
118            let mut env = env_task.await.unwrap_or_default();
119            env.extend(settings.env);
120
121            let activation_script = maybe!(async {
122                for toolchain in toolchains {
123                    let Some(toolchain) = toolchain.await else {
124                        continue;
125                    };
126                    let language = lang_registry
127                        .language_for_name(&toolchain.language_name.0)
128                        .await
129                        .ok();
130                    let lister = language?.toolchain_lister();
131                    return Some(lister?.activation_script(&toolchain, shell_kind));
132                }
133                None
134            })
135            .await
136            .unwrap_or_default();
137
138            let builder = project
139                .update(cx, move |_, cx| {
140                    let format_to_run = || {
141                        if let Some(command) = &spawn_task.command {
142                            let command = shell_kind.prepend_command_prefix(command);
143                            let command = shell_kind.try_quote_prefix_aware(&command);
144                            let args = spawn_task
145                                .args
146                                .iter()
147                                .filter_map(|arg| shell_kind.try_quote(&arg));
148
149                            command.into_iter().chain(args).join(" ")
150                        } else {
151                            // todo: this breaks for remotes to windows
152                            format!("exec {shell} -l")
153                        }
154                    };
155
156                    let (shell, env) = {
157                        env.extend(spawn_task.env);
158                        match remote_client {
159                            Some(remote_client) => match activation_script.clone() {
160                                activation_script if !activation_script.is_empty() => {
161                                    let separator = shell_kind.sequential_commands_separator();
162                                    let activation_script =
163                                        activation_script.join(&format!("{separator} "));
164                                    let to_run = format_to_run();
165
166                                    let arg = format!("{activation_script}{separator} {to_run}");
167                                    let args = shell_kind.args_for_shell(false, arg);
168                                    let shell = remote_client
169                                        .read(cx)
170                                        .shell()
171                                        .unwrap_or_else(get_default_system_shell);
172
173                                    create_remote_shell(
174                                        Some((&shell, &args)),
175                                        env,
176                                        path,
177                                        remote_client,
178                                        cx,
179                                    )?
180                                }
181                                _ => create_remote_shell(
182                                    spawn_task
183                                        .command
184                                        .as_ref()
185                                        .map(|command| (command, &spawn_task.args)),
186                                    env,
187                                    path,
188                                    remote_client,
189                                    cx,
190                                )?,
191                            },
192                            None => match activation_script.clone() {
193                                activation_script if !activation_script.is_empty() => {
194                                    let separator = shell_kind.sequential_commands_separator();
195                                    let activation_script =
196                                        activation_script.join(&format!("{separator} "));
197                                    let to_run = format_to_run();
198
199                                    let mut arg =
200                                        format!("{activation_script}{separator} {to_run}");
201                                    if shell_kind == ShellKind::Cmd {
202                                        // We need to put the entire command in quotes since otherwise CMD tries to execute them
203                                        // as separate commands rather than chaining one after another.
204                                        arg = format!("\"{arg}\"");
205                                    }
206
207                                    let args = shell_kind.args_for_shell(false, arg);
208
209                                    (
210                                        Shell::WithArguments {
211                                            program: shell,
212                                            args,
213                                            title_override: None,
214                                        },
215                                        env,
216                                    )
217                                }
218                                _ => (
219                                    if let Some(program) = spawn_task.command {
220                                        Shell::WithArguments {
221                                            program,
222                                            args: spawn_task.args,
223                                            title_override: None,
224                                        }
225                                    } else {
226                                        Shell::System
227                                    },
228                                    env,
229                                ),
230                            },
231                        }
232                    };
233                    anyhow::Ok(TerminalBuilder::new(
234                        local_path.map(|path| path.to_path_buf()),
235                        task_state,
236                        shell,
237                        env,
238                        settings.cursor_shape,
239                        settings.alternate_scroll,
240                        settings.max_scroll_history_lines,
241                        is_via_remote,
242                        cx.entity_id().as_u64(),
243                        Some(completion_tx),
244                        cx,
245                        activation_script,
246                    ))
247                })??
248                .await?;
249            project.update(cx, move |this, cx| {
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    pub fn create_terminal_shell(
276        &mut self,
277        cwd: Option<PathBuf>,
278        cx: &mut Context<Self>,
279    ) -> Task<Result<Entity<Terminal>>> {
280        let path = cwd.map(|p| Arc::from(&*p));
281        let is_via_remote = self.remote_client.is_some();
282
283        let mut settings_location = None;
284        if let Some(path) = path.as_ref()
285            && let Some((worktree, _)) = self.find_worktree(path, cx)
286        {
287            settings_location = Some(SettingsLocation {
288                worktree_id: worktree.read(cx).id(),
289                path: RelPath::empty(),
290            });
291        }
292        let settings = TerminalSettings::get(settings_location, cx).clone();
293        let detect_venv = settings.detect_venv.as_option().is_some();
294        let local_path = if is_via_remote { None } else { path.clone() };
295
296        let project_path_contexts = self
297            .active_entry()
298            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
299            .into_iter()
300            .chain(
301                self.visible_worktrees(cx)
302                    .map(|wt| wt.read(cx).id())
303                    .map(|worktree_id| ProjectPath {
304                        worktree_id,
305                        path: RelPath::empty().into(),
306                    }),
307            );
308        let toolchains = project_path_contexts
309            .filter(|_| detect_venv)
310            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
311            .collect::<Vec<_>>();
312        let remote_client = self.remote_client.clone();
313        let shell = match &remote_client {
314            Some(remote_client) => remote_client
315                .read(cx)
316                .shell()
317                .unwrap_or_else(get_default_system_shell),
318            None => settings.shell.program(),
319        };
320        let shell_kind = ShellKind::new(&shell, self.path_style(cx).is_windows());
321
322        // Prepare a task for resolving the environment
323        let env_task =
324            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
325
326        let lang_registry = self.languages.clone();
327        cx.spawn(async move |project, cx| {
328            let mut env = env_task.await.unwrap_or_default();
329            env.extend(settings.env);
330
331            let activation_script = maybe!(async {
332                for toolchain in toolchains {
333                    let Some(toolchain) = toolchain.await else {
334                        continue;
335                    };
336                    let language = lang_registry
337                        .language_for_name(&toolchain.language_name.0)
338                        .await
339                        .ok();
340                    let lister = language?.toolchain_lister();
341                    return Some(lister?.activation_script(&toolchain, shell_kind));
342                }
343                None
344            })
345            .await
346            .unwrap_or_default();
347            let builder = project
348                .update(cx, move |_, cx| {
349                    let (shell, env) = {
350                        match remote_client {
351                            Some(remote_client) => {
352                                create_remote_shell(None, env, path, remote_client, cx)?
353                            }
354                            None => (settings.shell, env),
355                        }
356                    };
357                    anyhow::Ok(TerminalBuilder::new(
358                        local_path.map(|path| path.to_path_buf()),
359                        None,
360                        shell,
361                        env,
362                        settings.cursor_shape,
363                        settings.alternate_scroll,
364                        settings.max_scroll_history_lines,
365                        is_via_remote,
366                        cx.entity_id().as_u64(),
367                        None,
368                        cx,
369                        activation_script,
370                    ))
371                })??
372                .await?;
373            project.update(cx, move |this, cx| {
374                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
375
376                this.terminals
377                    .local_handles
378                    .push(terminal_handle.downgrade());
379
380                let id = terminal_handle.entity_id();
381                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
382                    let handles = &mut project.terminals.local_handles;
383
384                    if let Some(index) = handles
385                        .iter()
386                        .position(|terminal| terminal.entity_id() == id)
387                    {
388                        handles.remove(index);
389                        cx.notify();
390                    }
391                })
392                .detach();
393
394                terminal_handle
395            })
396        })
397    }
398
399    pub fn clone_terminal(
400        &mut self,
401        terminal: &Entity<Terminal>,
402        cx: &mut Context<'_, Project>,
403        cwd: Option<PathBuf>,
404    ) -> Task<Result<Entity<Terminal>>> {
405        // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
406        // For now, create a new shell instead.
407        if terminal.read(cx).task().is_some() {
408            return self.create_terminal_shell(cwd, cx);
409        }
410        let local_path = if self.is_via_remote_server() {
411            None
412        } else {
413            cwd
414        };
415
416        let builder = terminal.read(cx).clone_builder(cx, local_path);
417        cx.spawn(async |project, cx| {
418            let terminal = builder.await?;
419            project.update(cx, |project, cx| {
420                let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
421
422                project
423                    .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
446    pub fn terminal_settings<'a>(
447        &'a self,
448        path: &'a Option<PathBuf>,
449        cx: &'a App,
450    ) -> &'a TerminalSettings {
451        let mut settings_location = None;
452        if let Some(path) = path.as_ref()
453            && let Some((worktree, _)) = self.find_worktree(path, cx)
454        {
455            settings_location = Some(SettingsLocation {
456                worktree_id: worktree.read(cx).id(),
457                path: RelPath::empty(),
458            });
459        }
460        TerminalSettings::get(settings_location, cx)
461    }
462
463    pub fn exec_in_shell(
464        &self,
465        command: String,
466        cx: &mut Context<Self>,
467    ) -> Task<Result<smol::process::Command>> {
468        let path = self.first_project_directory(cx);
469        let remote_client = self.remote_client.clone();
470        let settings = self.terminal_settings(&path, cx).clone();
471        let shell = remote_client
472            .as_ref()
473            .and_then(|remote_client| remote_client.read(cx).shell())
474            .map(Shell::Program)
475            .unwrap_or_else(|| settings.shell.clone());
476        let is_windows = self.path_style(cx).is_windows();
477        let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
478        let (command, args) = builder.build(Some(command), &Vec::new());
479
480        let env_task = self.resolve_directory_environment(
481            &shell.program(),
482            path.as_ref().map(|p| Arc::from(&**p)),
483            remote_client.clone(),
484            cx,
485        );
486
487        cx.spawn(async move |project, cx| {
488            let mut env = env_task.await.unwrap_or_default();
489            env.extend(settings.env);
490
491            project.update(cx, move |_, cx| {
492                match remote_client {
493                    Some(remote_client) => {
494                        let command_template = remote_client.read(cx).build_command(
495                            Some(command),
496                            &args,
497                            &env,
498                            None,
499                            None,
500                        )?;
501                        let mut command = std::process::Command::new(command_template.program);
502                        command.args(command_template.args);
503                        command.envs(command_template.env);
504                        Ok(command)
505                    }
506                    None => {
507                        let mut command = std::process::Command::new(command);
508                        command.args(args);
509                        command.envs(env);
510                        if let Some(path) = path {
511                            command.current_dir(path);
512                        }
513                        Ok(command)
514                    }
515                }
516                .map(|mut process| {
517                    util::set_pre_exec_to_start_new_session(&mut process);
518                    smol::process::Command::from(process)
519                })
520            })?
521        })
522    }
523
524    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
525        &self.terminals.local_handles
526    }
527
528    fn resolve_directory_environment(
529        &self,
530        shell: &str,
531        path: Option<Arc<Path>>,
532        remote_client: Option<Entity<RemoteClient>>,
533        cx: &mut App,
534    ) -> Shared<Task<Option<HashMap<String, String>>>> {
535        if let Some(path) = &path {
536            let shell = Shell::Program(shell.to_string());
537            self.environment
538                .update(cx, |project_env, cx| match &remote_client {
539                    Some(remote_client) => project_env.remote_directory_environment(
540                        &shell,
541                        path.clone(),
542                        remote_client.clone(),
543                        cx,
544                    ),
545                    None => project_env.local_directory_environment(&shell, path.clone(), cx),
546                })
547        } else {
548            Task::ready(None).shared()
549        }
550    }
551}
552
553fn create_remote_shell(
554    spawn_command: Option<(&String, &Vec<String>)>,
555    mut env: HashMap<String, String>,
556    working_directory: Option<Arc<Path>>,
557    remote_client: Entity<RemoteClient>,
558    cx: &mut App,
559) -> Result<(Shell, HashMap<String, String>)> {
560    // Set default terminfo that does not break the highlighting via ssh.
561    env.insert("TERM".to_string(), "xterm-256color".to_string());
562
563    let (program, args) = match spawn_command {
564        Some((program, args)) => (Some(program.clone()), args),
565        None => (None, &Vec::new()),
566    };
567
568    let command = remote_client.read(cx).build_command(
569        program,
570        args.as_slice(),
571        &env,
572        working_directory.map(|path| path.display().to_string()),
573        None,
574    )?;
575
576    log::debug!("Connecting to a remote server: {:?}", command.program);
577    let host = remote_client.read(cx).connection_options().display_name();
578
579    Ok((
580        Shell::WithArguments {
581            program: command.program,
582            args: command.args,
583            title_override: Some(format!("{} — Terminal", host)),
584        },
585        command.env,
586    ))
587}