terminals.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use feature_flags::{FeatureFlagAppExt, TerminalSandboxFeatureFlag};
  4use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
  5
  6use futures::{FutureExt, future::Shared};
  7use itertools::Itertools as _;
  8use language::LanguageName;
  9use remote::RemoteClient;
 10use settings::{Settings, SettingsLocation};
 11use smol::channel::bounded;
 12use std::{
 13    path::{Path, PathBuf},
 14    sync::Arc,
 15};
 16use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
 17use terminal::{
 18    TaskState, TaskStatus, Terminal, TerminalBuilder, insert_zed_terminal_env,
 19    terminal_settings::TerminalSettings,
 20};
 21use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath};
 22
 23use crate::{Project, ProjectPath};
 24
 25pub struct Terminals {
 26    pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
 27}
 28
 29impl Project {
 30    pub fn active_entry_directory(&self, cx: &App) -> Option<PathBuf> {
 31        let entry_id = self.active_entry()?;
 32        let worktree = self.worktree_for_entry(entry_id, cx)?;
 33        let worktree = worktree.read(cx);
 34        let entry = worktree.entry_for_id(entry_id)?;
 35
 36        let absolute_path = worktree.absolutize(entry.path.as_ref());
 37        if entry.is_dir() {
 38            Some(absolute_path)
 39        } else {
 40            absolute_path.parent().map(|p| p.to_path_buf())
 41        }
 42    }
 43
 44    pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
 45        self.active_entry()
 46            .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
 47            .into_iter()
 48            .chain(self.worktrees(cx))
 49            .find_map(|tree| tree.read(cx).root_dir())
 50    }
 51
 52    pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
 53        let worktree = self.worktrees(cx).next()?;
 54        let worktree = worktree.read(cx);
 55        if worktree.root_entry()?.is_dir() {
 56            Some(worktree.abs_path().to_path_buf())
 57        } else {
 58            None
 59        }
 60    }
 61
 62    pub fn create_terminal_task(
 63        &mut self,
 64        spawn_task: SpawnInTerminal,
 65        sandbox_config: Option<terminal::terminal_settings::SandboxConfig>,
 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 => settings.shell.program(),
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                        sandbox_config,
262                    ))
263                })??
264                .await?;
265            project.update(cx, move |this, cx| {
266                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
267
268                this.terminals
269                    .local_handles
270                    .push(terminal_handle.downgrade());
271
272                let id = terminal_handle.entity_id();
273                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
274                    let handles = &mut project.terminals.local_handles;
275
276                    if let Some(index) = handles
277                        .iter()
278                        .position(|terminal| terminal.entity_id() == id)
279                    {
280                        handles.remove(index);
281                        cx.notify();
282                    }
283                })
284                .detach();
285
286                terminal_handle
287            })
288        })
289    }
290
291    pub fn create_terminal_shell(
292        &mut self,
293        cwd: Option<PathBuf>,
294        cx: &mut Context<Self>,
295    ) -> Task<Result<Entity<Terminal>>> {
296        self.create_terminal_shell_internal(cwd, false, cx)
297    }
298
299    /// Creates a local terminal even if the project is remote.
300    /// In remote projects: opens in Zed's launch directory (bypasses SSH).
301    /// In local projects: opens in the project directory (same as regular terminals).
302    pub fn create_local_terminal(
303        &mut self,
304        cx: &mut Context<Self>,
305    ) -> Task<Result<Entity<Terminal>>> {
306        let working_directory = if self.remote_client.is_some() {
307            // Remote project: don't use remote paths, let shell use Zed's cwd
308            None
309        } else {
310            // Local project: use project directory like normal terminals
311            self.active_project_directory(cx).map(|p| p.to_path_buf())
312        };
313        self.create_terminal_shell_internal(working_directory, true, cx)
314    }
315
316    /// Internal method for creating terminal shells.
317    /// If force_local is true, creates a local terminal even if the project has a remote client.
318    /// This allows "breaking out" to a local shell in remote projects.
319    fn create_terminal_shell_internal(
320        &mut self,
321        cwd: Option<PathBuf>,
322        force_local: bool,
323        cx: &mut Context<Self>,
324    ) -> Task<Result<Entity<Terminal>>> {
325        let path = cwd.map(|p| Arc::from(&*p));
326        let is_via_remote = !force_local && self.remote_client.is_some();
327
328        let mut settings_location = None;
329        if let Some(path) = path.as_ref()
330            && let Some((worktree, _)) = self.find_worktree(path, cx)
331        {
332            settings_location = Some(SettingsLocation {
333                worktree_id: worktree.read(cx).id(),
334                path: RelPath::empty(),
335            });
336        }
337        let settings = TerminalSettings::get(settings_location, cx).clone();
338        let detect_venv = settings.detect_venv.as_option().is_some();
339        let local_path = if is_via_remote { None } else { path.clone() };
340
341        let project_path_contexts = self
342            .active_entry()
343            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
344            .into_iter()
345            .chain(
346                self.visible_worktrees(cx)
347                    .map(|wt| wt.read(cx).id())
348                    .map(|worktree_id| ProjectPath {
349                        worktree_id,
350                        path: RelPath::empty().into(),
351                    }),
352            );
353        let toolchains = project_path_contexts
354            .filter(|_| detect_venv)
355            .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
356            .collect::<Vec<_>>();
357        let remote_client = if force_local {
358            None
359        } else {
360            self.remote_client.clone()
361        };
362        let shell = match &remote_client {
363            Some(remote_client) => remote_client
364                .read(cx)
365                .shell()
366                .unwrap_or_else(get_default_system_shell),
367            None => settings.shell.program(),
368        };
369
370        let path_style = self.path_style(cx);
371
372        // Prepare a task for resolving the environment
373        let env_task =
374            self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
375
376        let lang_registry = self.languages.clone();
377        cx.spawn(async move |project, cx| {
378            let shell_kind = ShellKind::new(&shell, path_style.is_windows());
379            let mut env = env_task.await.unwrap_or_default();
380            env.extend(settings.env);
381
382            let activation_script = maybe!(async {
383                for toolchain in toolchains {
384                    let Some(toolchain) = toolchain.await else {
385                        continue;
386                    };
387                    let language = lang_registry
388                        .language_for_name(&toolchain.language_name.0)
389                        .await
390                        .ok();
391                    let lister = language?.toolchain_lister()?;
392                    let future =
393                        cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx));
394                    return Some(future.await);
395                }
396                None
397            })
398            .await
399            .unwrap_or_default();
400
401            let builder = project
402                .update(cx, move |_, cx| {
403                    let (shell, env) = {
404                        match remote_client {
405                            Some(remote_client) => {
406                                create_remote_shell(None, env, path, remote_client, cx)?
407                            }
408                            None => (settings.shell, env),
409                        }
410                    };
411
412                    // Resolve sandbox config for the user terminal (feature-flagged)
413                    let sandbox_config = settings.sandbox.as_ref().and_then(|sandbox| {
414                        if !cx.has_flag::<TerminalSandboxFeatureFlag>() {
415                            return None;
416                        }
417                        if !sandbox.enabled.unwrap_or(false) {
418                            return None;
419                        }
420                        let apply_to = sandbox.apply_to.unwrap_or_default();
421                        match apply_to {
422                            settings::SandboxApplyTo::Terminal | settings::SandboxApplyTo::Both => {
423                            }
424                            _ => return None,
425                        }
426                        let project_dir = local_path
427                            .as_ref()
428                            .map(|p| p.to_path_buf())
429                            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
430                        Some(terminal::terminal_settings::SandboxConfig::from_settings(
431                            sandbox,
432                            project_dir,
433                        ))
434                    });
435
436                    anyhow::Ok(TerminalBuilder::new(
437                        local_path.map(|path| path.to_path_buf()),
438                        None,
439                        shell,
440                        env,
441                        settings.cursor_shape,
442                        settings.alternate_scroll,
443                        settings.max_scroll_history_lines,
444                        settings.path_hyperlink_regexes,
445                        settings.path_hyperlink_timeout_ms,
446                        is_via_remote,
447                        cx.entity_id().as_u64(),
448                        None,
449                        cx,
450                        activation_script,
451                        path_style,
452                        sandbox_config,
453                    ))
454                })??
455                .await?;
456            project.update(cx, move |this, cx| {
457                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
458
459                this.terminals
460                    .local_handles
461                    .push(terminal_handle.downgrade());
462
463                let id = terminal_handle.entity_id();
464                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
465                    let handles = &mut project.terminals.local_handles;
466
467                    if let Some(index) = handles
468                        .iter()
469                        .position(|terminal| terminal.entity_id() == id)
470                    {
471                        handles.remove(index);
472                        cx.notify();
473                    }
474                })
475                .detach();
476
477                terminal_handle
478            })
479        })
480    }
481
482    pub fn clone_terminal(
483        &mut self,
484        terminal: &Entity<Terminal>,
485        cx: &mut Context<'_, Project>,
486        cwd: Option<PathBuf>,
487    ) -> Task<Result<Entity<Terminal>>> {
488        // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
489        // For now, create a new shell instead.
490        if terminal.read(cx).task().is_some() {
491            return self.create_terminal_shell(cwd, cx);
492        }
493        let local_path = if self.is_via_remote_server() {
494            None
495        } else {
496            cwd
497        };
498
499        let builder = terminal.read(cx).clone_builder(cx, local_path);
500        cx.spawn(async |project, cx| {
501            let terminal = builder.await?;
502            project.update(cx, |project, cx| {
503                let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
504
505                project
506                    .terminals
507                    .local_handles
508                    .push(terminal_handle.downgrade());
509
510                let id = terminal_handle.entity_id();
511                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
512                    let handles = &mut project.terminals.local_handles;
513
514                    if let Some(index) = handles
515                        .iter()
516                        .position(|terminal| terminal.entity_id() == id)
517                    {
518                        handles.remove(index);
519                        cx.notify();
520                    }
521                })
522                .detach();
523
524                terminal_handle
525            })
526        })
527    }
528
529    pub fn terminal_settings<'a>(
530        &'a self,
531        path: &'a Option<PathBuf>,
532        cx: &'a App,
533    ) -> &'a TerminalSettings {
534        let mut settings_location = None;
535        if let Some(path) = path.as_ref()
536            && let Some((worktree, _)) = self.find_worktree(path, cx)
537        {
538            settings_location = Some(SettingsLocation {
539                worktree_id: worktree.read(cx).id(),
540                path: RelPath::empty(),
541            });
542        }
543        TerminalSettings::get(settings_location, cx)
544    }
545
546    pub fn exec_in_shell(
547        &self,
548        command: String,
549        cx: &mut Context<Self>,
550    ) -> Task<Result<smol::process::Command>> {
551        let path = self.first_project_directory(cx);
552        let remote_client = self.remote_client.clone();
553        let settings = self.terminal_settings(&path, cx).clone();
554        let shell = remote_client
555            .as_ref()
556            .and_then(|remote_client| remote_client.read(cx).shell())
557            .map(Shell::Program)
558            .unwrap_or_else(|| settings.shell.clone());
559        let is_windows = self.path_style(cx).is_windows();
560        let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
561        let (command, args) = builder.build(Some(command), &Vec::new());
562
563        let env_task = self.resolve_directory_environment(
564            &shell.program(),
565            path.as_ref().map(|p| Arc::from(&**p)),
566            remote_client.clone(),
567            cx,
568        );
569
570        cx.spawn(async move |project, cx| {
571            let mut env = env_task.await.unwrap_or_default();
572            env.extend(settings.env);
573
574            project.update(cx, move |_, cx| {
575                match remote_client {
576                    Some(remote_client) => {
577                        let command_template = remote_client.read(cx).build_command(
578                            Some(command),
579                            &args,
580                            &env,
581                            None,
582                            None,
583                        )?;
584                        let mut command = new_std_command(command_template.program);
585                        command.args(command_template.args);
586                        command.envs(command_template.env);
587                        Ok(command)
588                    }
589                    None => {
590                        let mut command = new_std_command(command);
591                        command.args(args);
592                        command.envs(env);
593                        if let Some(path) = path {
594                            command.current_dir(path);
595                        }
596                        Ok(command)
597                    }
598                }
599                .map(|mut process| {
600                    util::set_pre_exec_to_start_new_session(&mut process);
601                    smol::process::Command::from(process)
602                })
603            })?
604        })
605    }
606
607    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
608        &self.terminals.local_handles
609    }
610
611    fn resolve_directory_environment(
612        &self,
613        shell: &str,
614        path: Option<Arc<Path>>,
615        remote_client: Option<Entity<RemoteClient>>,
616        cx: &mut App,
617    ) -> Shared<Task<Option<HashMap<String, String>>>> {
618        if let Some(path) = &path {
619            let shell = Shell::Program(shell.to_string());
620            self.environment
621                .update(cx, |project_env, cx| match &remote_client {
622                    Some(remote_client) => project_env.remote_directory_environment(
623                        &shell,
624                        path.clone(),
625                        remote_client.clone(),
626                        cx,
627                    ),
628                    None => project_env.local_directory_environment(&shell, path.clone(), cx),
629                })
630        } else {
631            Task::ready(None).shared()
632        }
633    }
634}
635
636fn create_remote_shell(
637    spawn_command: Option<(&String, &Vec<String>)>,
638    mut env: HashMap<String, String>,
639    working_directory: Option<Arc<Path>>,
640    remote_client: Entity<RemoteClient>,
641    cx: &mut App,
642) -> Result<(Shell, HashMap<String, String>)> {
643    insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
644
645    let (program, args) = match spawn_command {
646        Some((program, args)) => (Some(program.clone()), args),
647        None => (None, &Vec::new()),
648    };
649
650    let command = remote_client.read(cx).build_command(
651        program,
652        args.as_slice(),
653        &env,
654        working_directory.map(|path| path.display().to_string()),
655        None,
656    )?;
657
658    log::debug!("Connecting to a remote server: {:?}", command.program);
659    let host = remote_client.read(cx).connection_options().display_name();
660
661    Ok((
662        Shell::WithArguments {
663            program: command.program,
664            args: command.args,
665            title_override: Some(format!("{} — Terminal", host)),
666        },
667        command.env,
668    ))
669}