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