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