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