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