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::{command::new_std_command, 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                        settings.path_hyperlink_regexes,
244                        settings.path_hyperlink_timeout_ms,
245                        is_via_remote,
246                        cx.entity_id().as_u64(),
247                        Some(completion_tx),
248                        cx,
249                        activation_script,
250                    ))
251                })??
252                .await?;
253            project.update(cx, move |this, cx| {
254                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
255
256                this.terminals
257                    .local_handles
258                    .push(terminal_handle.downgrade());
259
260                let id = terminal_handle.entity_id();
261                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
262                    let handles = &mut project.terminals.local_handles;
263
264                    if let Some(index) = handles
265                        .iter()
266                        .position(|terminal| terminal.entity_id() == id)
267                    {
268                        handles.remove(index);
269                        cx.notify();
270                    }
271                })
272                .detach();
273
274                terminal_handle
275            })
276        })
277    }
278
279    pub fn create_terminal_shell(
280        &mut self,
281        cwd: Option<PathBuf>,
282        cx: &mut Context<Self>,
283    ) -> Task<Result<Entity<Terminal>>> {
284        let path = cwd.map(|p| Arc::from(&*p));
285        let is_via_remote = self.remote_client.is_some();
286
287        let mut settings_location = None;
288        if let Some(path) = path.as_ref()
289            && let Some((worktree, _)) = self.find_worktree(path, cx)
290        {
291            settings_location = Some(SettingsLocation {
292                worktree_id: worktree.read(cx).id(),
293                path: RelPath::empty(),
294            });
295        }
296        let settings = TerminalSettings::get(settings_location, cx).clone();
297        let detect_venv = settings.detect_venv.as_option().is_some();
298        let local_path = if is_via_remote { None } else { path.clone() };
299
300        let project_path_contexts = self
301            .active_entry()
302            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
303            .into_iter()
304            .chain(
305                self.visible_worktrees(cx)
306                    .map(|wt| wt.read(cx).id())
307                    .map(|worktree_id| ProjectPath {
308                        worktree_id,
309                        path: RelPath::empty().into(),
310                    }),
311            );
312        let toolchains = project_path_contexts
313            .filter(|_| detect_venv)
314            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
315            .collect::<Vec<_>>();
316        let remote_client = self.remote_client.clone();
317        let shell = match &remote_client {
318            Some(remote_client) => remote_client
319                .read(cx)
320                .shell()
321                .unwrap_or_else(get_default_system_shell),
322            None => settings.shell.program(),
323        };
324
325        let is_windows = self.path_style(cx).is_windows();
326
327        // Prepare a task for resolving the environment
328        let env_task =
329            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
330
331        let lang_registry = self.languages.clone();
332        cx.spawn(async move |project, cx| {
333            let shell_kind = ShellKind::new(&shell, is_windows);
334            let mut env = env_task.await.unwrap_or_default();
335            env.extend(settings.env);
336
337            let activation_script = maybe!(async {
338                for toolchain in toolchains {
339                    let Some(toolchain) = toolchain.await else {
340                        continue;
341                    };
342                    let language = lang_registry
343                        .language_for_name(&toolchain.language_name.0)
344                        .await
345                        .ok();
346                    let lister = language?.toolchain_lister()?;
347                    return cx
348                        .update(|cx| lister.activation_script(&toolchain, shell_kind, cx))
349                        .ok();
350                }
351                None
352            })
353            .await
354            .unwrap_or_default();
355
356            let builder = project
357                .update(cx, move |_, cx| {
358                    let (shell, env) = {
359                        match remote_client {
360                            Some(remote_client) => {
361                                create_remote_shell(None, env, path, remote_client, cx)?
362                            }
363                            None => (settings.shell, env),
364                        }
365                    };
366                    anyhow::Ok(TerminalBuilder::new(
367                        local_path.map(|path| path.to_path_buf()),
368                        None,
369                        shell,
370                        env,
371                        settings.cursor_shape,
372                        settings.alternate_scroll,
373                        settings.max_scroll_history_lines,
374                        settings.path_hyperlink_regexes,
375                        settings.path_hyperlink_timeout_ms,
376                        is_via_remote,
377                        cx.entity_id().as_u64(),
378                        None,
379                        cx,
380                        activation_script,
381                    ))
382                })??
383                .await?;
384            project.update(cx, move |this, cx| {
385                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
386
387                this.terminals
388                    .local_handles
389                    .push(terminal_handle.downgrade());
390
391                let id = terminal_handle.entity_id();
392                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
393                    let handles = &mut project.terminals.local_handles;
394
395                    if let Some(index) = handles
396                        .iter()
397                        .position(|terminal| terminal.entity_id() == id)
398                    {
399                        handles.remove(index);
400                        cx.notify();
401                    }
402                })
403                .detach();
404
405                terminal_handle
406            })
407        })
408    }
409
410    pub fn clone_terminal(
411        &mut self,
412        terminal: &Entity<Terminal>,
413        cx: &mut Context<'_, Project>,
414        cwd: Option<PathBuf>,
415    ) -> Task<Result<Entity<Terminal>>> {
416        // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
417        // For now, create a new shell instead.
418        if terminal.read(cx).task().is_some() {
419            return self.create_terminal_shell(cwd, cx);
420        }
421        let local_path = if self.is_via_remote_server() {
422            None
423        } else {
424            cwd
425        };
426
427        let builder = terminal.read(cx).clone_builder(cx, local_path);
428        cx.spawn(async |project, cx| {
429            let terminal = builder.await?;
430            project.update(cx, |project, cx| {
431                let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
432
433                project
434                    .terminals
435                    .local_handles
436                    .push(terminal_handle.downgrade());
437
438                let id = terminal_handle.entity_id();
439                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
440                    let handles = &mut project.terminals.local_handles;
441
442                    if let Some(index) = handles
443                        .iter()
444                        .position(|terminal| terminal.entity_id() == id)
445                    {
446                        handles.remove(index);
447                        cx.notify();
448                    }
449                })
450                .detach();
451
452                terminal_handle
453            })
454        })
455    }
456
457    pub fn terminal_settings<'a>(
458        &'a self,
459        path: &'a Option<PathBuf>,
460        cx: &'a App,
461    ) -> &'a TerminalSettings {
462        let mut settings_location = None;
463        if let Some(path) = path.as_ref()
464            && let Some((worktree, _)) = self.find_worktree(path, cx)
465        {
466            settings_location = Some(SettingsLocation {
467                worktree_id: worktree.read(cx).id(),
468                path: RelPath::empty(),
469            });
470        }
471        TerminalSettings::get(settings_location, cx)
472    }
473
474    pub fn exec_in_shell(
475        &self,
476        command: String,
477        cx: &mut Context<Self>,
478    ) -> Task<Result<smol::process::Command>> {
479        let path = self.first_project_directory(cx);
480        let remote_client = self.remote_client.clone();
481        let settings = self.terminal_settings(&path, cx).clone();
482        let shell = remote_client
483            .as_ref()
484            .and_then(|remote_client| remote_client.read(cx).shell())
485            .map(Shell::Program)
486            .unwrap_or_else(|| settings.shell.clone());
487        let is_windows = self.path_style(cx).is_windows();
488        let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
489        let (command, args) = builder.build(Some(command), &Vec::new());
490
491        let env_task = self.resolve_directory_environment(
492            &shell.program(),
493            path.as_ref().map(|p| Arc::from(&**p)),
494            remote_client.clone(),
495            cx,
496        );
497
498        cx.spawn(async move |project, cx| {
499            let mut env = env_task.await.unwrap_or_default();
500            env.extend(settings.env);
501
502            project.update(cx, move |_, cx| {
503                match remote_client {
504                    Some(remote_client) => {
505                        let command_template = remote_client.read(cx).build_command(
506                            Some(command),
507                            &args,
508                            &env,
509                            None,
510                            None,
511                        )?;
512                        let mut command = new_std_command(command_template.program);
513                        command.args(command_template.args);
514                        command.envs(command_template.env);
515                        Ok(command)
516                    }
517                    None => {
518                        let mut command = new_std_command(command);
519                        command.args(args);
520                        command.envs(env);
521                        if let Some(path) = path {
522                            command.current_dir(path);
523                        }
524                        Ok(command)
525                    }
526                }
527                .map(|mut process| {
528                    util::set_pre_exec_to_start_new_session(&mut process);
529                    smol::process::Command::from(process)
530                })
531            })?
532        })
533    }
534
535    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
536        &self.terminals.local_handles
537    }
538
539    fn resolve_directory_environment(
540        &self,
541        shell: &str,
542        path: Option<Arc<Path>>,
543        remote_client: Option<Entity<RemoteClient>>,
544        cx: &mut App,
545    ) -> Shared<Task<Option<HashMap<String, String>>>> {
546        if let Some(path) = &path {
547            let shell = Shell::Program(shell.to_string());
548            self.environment
549                .update(cx, |project_env, cx| match &remote_client {
550                    Some(remote_client) => project_env.remote_directory_environment(
551                        &shell,
552                        path.clone(),
553                        remote_client.clone(),
554                        cx,
555                    ),
556                    None => project_env.local_directory_environment(&shell, path.clone(), cx),
557                })
558        } else {
559            Task::ready(None).shared()
560        }
561    }
562}
563
564fn create_remote_shell(
565    spawn_command: Option<(&String, &Vec<String>)>,
566    mut env: HashMap<String, String>,
567    working_directory: Option<Arc<Path>>,
568    remote_client: Entity<RemoteClient>,
569    cx: &mut App,
570) -> Result<(Shell, HashMap<String, String>)> {
571    // Set default terminfo that does not break the highlighting via ssh.
572    env.insert("TERM".to_string(), "xterm-256color".to_string());
573
574    let (program, args) = match spawn_command {
575        Some((program, args)) => (Some(program.clone()), args),
576        None => (None, &Vec::new()),
577    };
578
579    let command = remote_client.read(cx).build_command(
580        program,
581        args.as_slice(),
582        &env,
583        working_directory.map(|path| path.display().to_string()),
584        None,
585    )?;
586
587    log::debug!("Connecting to a remote server: {:?}", command.program);
588    let host = remote_client.read(cx).connection_options().display_name();
589
590    Ok((
591        Shell::WithArguments {
592            program: command.program,
593            args: command.args,
594            title_override: Some(format!("{} — Terminal", host)),
595        },
596        command.env,
597    ))
598}