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, SpawnInTerminal};
 16use terminal::{
 17    TaskState, TaskStatus, Terminal, TerminalBuilder, insert_zed_terminal_env,
 18    terminal_settings::TerminalSettings,
 19};
 20use util::shell::ShellKind;
 21use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath};
 22
 23use crate::{Project, ProjectPath};
 24
 25pub struct Terminals {
 26    pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
 27}
 28
 29impl Project {
 30    pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
 31        self.active_entry()
 32            .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
 33            .into_iter()
 34            .chain(self.worktrees(cx))
 35            .find_map(|tree| tree.read(cx).root_dir())
 36    }
 37
 38    pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
 39        let worktree = self.worktrees(cx).next()?;
 40        let worktree = worktree.read(cx);
 41        if worktree.root_entry()?.is_dir() {
 42            Some(worktree.abs_path().to_path_buf())
 43        } else {
 44            None
 45        }
 46    }
 47
 48    pub fn create_terminal_task(
 49        &mut self,
 50        spawn_task: SpawnInTerminal,
 51        cx: &mut Context<Self>,
 52    ) -> Task<Result<Entity<Terminal>>> {
 53        let is_via_remote = self.remote_client.is_some();
 54
 55        let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
 56            if is_via_remote {
 57                Some(Arc::from(cwd.as_ref()))
 58            } else {
 59                let cwd = cwd.to_string_lossy();
 60                let tilde_substituted = shellexpand::tilde(&cwd);
 61                Some(Arc::from(Path::new(tilde_substituted.as_ref())))
 62            }
 63        } else {
 64            self.active_project_directory(cx)
 65        };
 66
 67        let mut settings_location = None;
 68        if let Some(path) = path.as_ref()
 69            && let Some((worktree, _)) = self.find_worktree(path, cx)
 70        {
 71            settings_location = Some(SettingsLocation {
 72                worktree_id: worktree.read(cx).id(),
 73                path: RelPath::empty(),
 74            });
 75        }
 76        let settings = TerminalSettings::get(settings_location, cx).clone();
 77        let detect_venv = settings.detect_venv.as_option().is_some();
 78
 79        let (completion_tx, completion_rx) = bounded(1);
 80
 81        let local_path = if is_via_remote { None } else { path.clone() };
 82        let task_state = Some(TaskState {
 83            spawned_task: spawn_task.clone(),
 84            status: TaskStatus::Running,
 85            completion_rx,
 86        });
 87        let remote_client = self.remote_client.clone();
 88        let shell = match &remote_client {
 89            Some(remote_client) => remote_client
 90                .read(cx)
 91                .shell()
 92                .unwrap_or_else(get_default_system_shell),
 93            None => settings.shell.program(),
 94        };
 95        let path_style = self.path_style(cx);
 96        let shell_kind = ShellKind::new(&shell);
 97
 98        // Prepare a task for resolving the environment
 99        let env_task =
100            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
101
102        let project_path_contexts = self
103            .active_entry()
104            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
105            .into_iter()
106            .chain(
107                self.visible_worktrees(cx)
108                    .map(|wt| wt.read(cx).id())
109                    .map(|worktree_id| ProjectPath {
110                        worktree_id,
111                        path: Arc::from(RelPath::empty()),
112                    }),
113            );
114        let toolchains = project_path_contexts
115            .filter(|_| detect_venv)
116            .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
117            .collect::<Vec<_>>();
118        let lang_registry = self.languages.clone();
119        cx.spawn(async move |project, cx| {
120            let mut env = env_task.await.unwrap_or_default();
121            env.extend(settings.env);
122
123            let activation_script = maybe!(async {
124                for toolchain in toolchains {
125                    let Some(toolchain) = toolchain.await else {
126                        continue;
127                    };
128                    let language = lang_registry
129                        .language_for_name(&toolchain.language_name.0)
130                        .await
131                        .ok();
132                    let lister = language?.toolchain_lister()?;
133                    let future =
134                        cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx));
135                    return Some(future.await);
136                }
137                None
138            })
139            .await
140            .unwrap_or_default();
141
142            let builder = project
143                .update(cx, move |_, cx| {
144                    let format_to_run = || {
145                        if let Some(command) = &spawn_task.command {
146                            let command =
147                                util::shell::prepend_command_prefix_option(shell_kind, command);
148                            let command =
149                                util::shell::try_quote_prefix_aware_option(shell_kind, &command);
150                            let args = spawn_task
151                                .args
152                                .iter()
153                                .filter_map(|arg| util::shell::try_quote_option(shell_kind, arg));
154
155                            command.into_iter().chain(args).join(" ")
156                        } else {
157                            // todo: this breaks for remotes to windows
158                            format!("exec {shell} -l")
159                        }
160                    };
161
162                    let (shell, env) = {
163                        env.extend(spawn_task.env);
164                        match remote_client {
165                            Some(remote_client) => match activation_script.clone() {
166                                activation_script if !activation_script.is_empty() => {
167                                    let separator =
168                                        util::shell::sequential_commands_separator_option(
169                                            shell_kind,
170                                        );
171                                    let activation_script =
172                                        activation_script.join(&format!("{separator} "));
173                                    let to_run = format_to_run();
174
175                                    let arg = format!("{activation_script}{separator} {to_run}");
176                                    let args =
177                                        util::shell::args_for_shell_option(shell_kind, false, arg);
178                                    let shell = remote_client
179                                        .read(cx)
180                                        .shell()
181                                        .unwrap_or_else(get_default_system_shell);
182
183                                    create_remote_shell(
184                                        Some((&shell, &args)),
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 =
205                                        util::shell::sequential_commands_separator_option(
206                                            shell_kind,
207                                        );
208                                    let activation_script =
209                                        activation_script.join(&format!("{separator} "));
210                                    let to_run = format_to_run();
211
212                                    let arg = format!("{activation_script}{separator} {to_run}");
213                                    let args =
214                                        util::shell::args_for_shell_option(shell_kind, false, arg);
215
216                                    (
217                                        Shell::WithArguments {
218                                            program: shell,
219                                            args,
220                                            title_override: None,
221                                        },
222                                        env,
223                                    )
224                                }
225                                _ => (
226                                    if let Some(program) = spawn_task.command {
227                                        Shell::WithArguments {
228                                            program,
229                                            args: spawn_task.args,
230                                            title_override: None,
231                                        }
232                                    } else {
233                                        Shell::System
234                                    },
235                                    env,
236                                ),
237                            },
238                        }
239                    };
240                    anyhow::Ok(TerminalBuilder::new(
241                        local_path.map(|path| path.to_path_buf()),
242                        task_state,
243                        shell,
244                        env,
245                        settings.cursor_shape,
246                        settings.alternate_scroll,
247                        settings.max_scroll_history_lines,
248                        settings.path_hyperlink_regexes,
249                        settings.path_hyperlink_timeout_ms,
250                        is_via_remote,
251                        cx.entity_id().as_u64(),
252                        Some(completion_tx),
253                        cx,
254                        activation_script,
255                        path_style,
256                    ))
257                })??
258                .await?;
259            project.update(cx, move |this, cx| {
260                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
261
262                this.terminals
263                    .local_handles
264                    .push(terminal_handle.downgrade());
265
266                let id = terminal_handle.entity_id();
267                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
268                    let handles = &mut project.terminals.local_handles;
269
270                    if let Some(index) = handles
271                        .iter()
272                        .position(|terminal| terminal.entity_id() == id)
273                    {
274                        handles.remove(index);
275                        cx.notify();
276                    }
277                })
278                .detach();
279
280                terminal_handle
281            })
282        })
283    }
284
285    pub fn create_terminal_shell(
286        &mut self,
287        cwd: Option<PathBuf>,
288        cx: &mut Context<Self>,
289    ) -> Task<Result<Entity<Terminal>>> {
290        self.create_terminal_shell_internal(cwd, false, cx)
291    }
292
293    /// Creates a local terminal even if the project is remote.
294    /// In remote projects: opens in Zed's launch directory (bypasses SSH).
295    /// In local projects: opens in the project directory (same as regular terminals).
296    pub fn create_local_terminal(
297        &mut self,
298        cx: &mut Context<Self>,
299    ) -> Task<Result<Entity<Terminal>>> {
300        let working_directory = if self.remote_client.is_some() {
301            // Remote project: don't use remote paths, let shell use Zed's cwd
302            None
303        } else {
304            // Local project: use project directory like normal terminals
305            self.active_project_directory(cx).map(|p| p.to_path_buf())
306        };
307        self.create_terminal_shell_internal(working_directory, true, cx)
308    }
309
310    /// Internal method for creating terminal shells.
311    /// If force_local is true, creates a local terminal even if the project has a remote client.
312    /// This allows "breaking out" to a local shell in remote projects.
313    fn create_terminal_shell_internal(
314        &mut self,
315        cwd: Option<PathBuf>,
316        force_local: bool,
317        cx: &mut Context<Self>,
318    ) -> Task<Result<Entity<Terminal>>> {
319        let path = cwd.map(|p| Arc::from(&*p));
320        let is_via_remote = !force_local && self.remote_client.is_some();
321
322        let mut settings_location = None;
323        if let Some(path) = path.as_ref()
324            && let Some((worktree, _)) = self.find_worktree(path, cx)
325        {
326            settings_location = Some(SettingsLocation {
327                worktree_id: worktree.read(cx).id(),
328                path: RelPath::empty(),
329            });
330        }
331        let settings = TerminalSettings::get(settings_location, cx).clone();
332        let detect_venv = settings.detect_venv.as_option().is_some();
333        let local_path = if is_via_remote { None } else { path.clone() };
334
335        let project_path_contexts = self
336            .active_entry()
337            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
338            .into_iter()
339            .chain(
340                self.visible_worktrees(cx)
341                    .map(|wt| wt.read(cx).id())
342                    .map(|worktree_id| ProjectPath {
343                        worktree_id,
344                        path: RelPath::empty().into(),
345                    }),
346            );
347        let toolchains = project_path_contexts
348            .filter(|_| detect_venv)
349            .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
350            .collect::<Vec<_>>();
351        let remote_client = if force_local {
352            None
353        } else {
354            self.remote_client.clone()
355        };
356        let shell = match &remote_client {
357            Some(remote_client) => remote_client
358                .read(cx)
359                .shell()
360                .unwrap_or_else(get_default_system_shell),
361            None => settings.shell.program(),
362        };
363
364        let path_style = self.path_style(cx);
365
366        // Prepare a task for resolving the environment
367        let env_task =
368            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
369
370        let lang_registry = self.languages.clone();
371        cx.spawn(async move |project, cx| {
372            let shell_kind = ShellKind::new(&shell);
373            let mut env = env_task.await.unwrap_or_default();
374            env.extend(settings.env);
375
376            let activation_script = maybe!(async {
377                for toolchain in toolchains {
378                    let Some(toolchain) = toolchain.await else {
379                        continue;
380                    };
381                    let language = lang_registry
382                        .language_for_name(&toolchain.language_name.0)
383                        .await
384                        .ok();
385                    let lister = language?.toolchain_lister()?;
386                    let future =
387                        cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx));
388                    return Some(future.await);
389                }
390                None
391            })
392            .await
393            .unwrap_or_default();
394
395            let builder = project
396                .update(cx, move |_, cx| {
397                    let (shell, env) = {
398                        match remote_client {
399                            Some(remote_client) => {
400                                create_remote_shell(None, env, path, remote_client, cx)?
401                            }
402                            None => (settings.shell, env),
403                        }
404                    };
405                    anyhow::Ok(TerminalBuilder::new(
406                        local_path.map(|path| path.to_path_buf()),
407                        None,
408                        shell,
409                        env,
410                        settings.cursor_shape,
411                        settings.alternate_scroll,
412                        settings.max_scroll_history_lines,
413                        settings.path_hyperlink_regexes,
414                        settings.path_hyperlink_timeout_ms,
415                        is_via_remote,
416                        cx.entity_id().as_u64(),
417                        None,
418                        cx,
419                        activation_script,
420                        path_style,
421                    ))
422                })??
423                .await?;
424            project.update(cx, move |this, cx| {
425                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
426
427                this.terminals
428                    .local_handles
429                    .push(terminal_handle.downgrade());
430
431                let id = terminal_handle.entity_id();
432                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
433                    let handles = &mut project.terminals.local_handles;
434
435                    if let Some(index) = handles
436                        .iter()
437                        .position(|terminal| terminal.entity_id() == id)
438                    {
439                        handles.remove(index);
440                        cx.notify();
441                    }
442                })
443                .detach();
444
445                terminal_handle
446            })
447        })
448    }
449
450    pub fn clone_terminal(
451        &mut self,
452        terminal: &Entity<Terminal>,
453        cx: &mut Context<'_, Project>,
454        cwd: Option<PathBuf>,
455    ) -> Task<Result<Entity<Terminal>>> {
456        // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
457        // For now, create a new shell instead.
458        if terminal.read(cx).task().is_some() {
459            return self.create_terminal_shell(cwd, cx);
460        }
461        let local_path = if self.is_via_remote_server() {
462            None
463        } else {
464            cwd
465        };
466
467        let builder = terminal.read(cx).clone_builder(cx, local_path);
468        cx.spawn(async |project, cx| {
469            let terminal = builder.await?;
470            project.update(cx, |project, cx| {
471                let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
472
473                project
474                    .terminals
475                    .local_handles
476                    .push(terminal_handle.downgrade());
477
478                let id = terminal_handle.entity_id();
479                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
480                    let handles = &mut project.terminals.local_handles;
481
482                    if let Some(index) = handles
483                        .iter()
484                        .position(|terminal| terminal.entity_id() == id)
485                    {
486                        handles.remove(index);
487                        cx.notify();
488                    }
489                })
490                .detach();
491
492                terminal_handle
493            })
494        })
495    }
496
497    pub fn terminal_settings<'a>(
498        &'a self,
499        path: &'a Option<PathBuf>,
500        cx: &'a App,
501    ) -> &'a TerminalSettings {
502        let mut settings_location = None;
503        if let Some(path) = path.as_ref()
504            && let Some((worktree, _)) = self.find_worktree(path, cx)
505        {
506            settings_location = Some(SettingsLocation {
507                worktree_id: worktree.read(cx).id(),
508                path: RelPath::empty(),
509            });
510        }
511        TerminalSettings::get(settings_location, cx)
512    }
513
514    pub fn exec_in_shell(
515        &self,
516        command: String,
517        cx: &mut Context<Self>,
518    ) -> Task<Result<smol::process::Command>> {
519        let path = self.first_project_directory(cx);
520        let remote_client = self.remote_client.clone();
521        let settings = self.terminal_settings(&path, cx).clone();
522        let shell = remote_client
523            .as_ref()
524            .and_then(|remote_client| remote_client.read(cx).shell())
525            .map(Shell::Program)
526            .unwrap_or_else(|| settings.shell.clone());
527        let builder = ShellBuilder::new(&shell).non_interactive();
528        let (command, args) = builder.build(Some(command), &Vec::new());
529
530        let env_task = self.resolve_directory_environment(
531            &shell.program(),
532            path.as_ref().map(|p| Arc::from(&**p)),
533            remote_client.clone(),
534            cx,
535        );
536
537        cx.spawn(async move |project, cx| {
538            let mut env = env_task.await.unwrap_or_default();
539            env.extend(settings.env);
540
541            project.update(cx, move |_, cx| {
542                match remote_client {
543                    Some(remote_client) => {
544                        let command_template = remote_client.read(cx).build_command(
545                            Some(command),
546                            &args,
547                            &env,
548                            None,
549                            None,
550                        )?;
551                        let mut command = new_std_command(command_template.program);
552                        command.args(command_template.args);
553                        command.envs(command_template.env);
554                        Ok(command)
555                    }
556                    None => {
557                        let mut command = new_std_command(command);
558                        command.args(args);
559                        command.envs(env);
560                        if let Some(path) = path {
561                            command.current_dir(path);
562                        }
563                        Ok(command)
564                    }
565                }
566                .map(|mut process| {
567                    util::set_pre_exec_to_start_new_session(&mut process);
568                    smol::process::Command::from(process)
569                })
570            })?
571        })
572    }
573
574    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
575        &self.terminals.local_handles
576    }
577
578    fn resolve_directory_environment(
579        &self,
580        shell: &str,
581        path: Option<Arc<Path>>,
582        remote_client: Option<Entity<RemoteClient>>,
583        cx: &mut App,
584    ) -> Shared<Task<Option<HashMap<String, String>>>> {
585        if let Some(path) = &path {
586            let shell = Shell::Program(shell.to_string());
587            self.environment
588                .update(cx, |project_env, cx| match &remote_client {
589                    Some(remote_client) => project_env.remote_directory_environment(
590                        &shell,
591                        path.clone(),
592                        remote_client.clone(),
593                        cx,
594                    ),
595                    None => project_env.local_directory_environment(&shell, path.clone(), cx),
596                })
597        } else {
598            Task::ready(None).shared()
599        }
600    }
601}
602
603fn create_remote_shell(
604    spawn_command: Option<(&String, &Vec<String>)>,
605    mut env: HashMap<String, String>,
606    working_directory: Option<Arc<Path>>,
607    remote_client: Entity<RemoteClient>,
608    cx: &mut App,
609) -> Result<(Shell, HashMap<String, String>)> {
610    insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
611
612    let (program, args) = match spawn_command {
613        Some((program, args)) => (Some(program.clone()), args),
614        None => (None, &Vec::new()),
615    };
616
617    let command = remote_client.read(cx).build_command(
618        program,
619        args.as_slice(),
620        &env,
621        working_directory.map(|path| path.display().to_string()),
622        None,
623    )?;
624
625    log::debug!("Connecting to a remote server: {:?}", command.program);
626    let host = remote_client.read(cx).connection_options().display_name();
627
628    Ok((
629        Shell::WithArguments {
630            program: command.program,
631            args: command.args,
632            title_override: Some(format!("{} — Terminal", host)),
633        },
634        command.env,
635    ))
636}