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