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