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