terminals.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
  4
  5use itertools::Itertools as _;
  6use language::LanguageName;
  7use remote::RemoteClient;
  8use settings::{Settings, SettingsLocation};
  9use smol::channel::bounded;
 10use std::{
 11    path::{Path, PathBuf},
 12    sync::Arc,
 13};
 14use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
 15use terminal::{
 16    TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
 17};
 18use util::{get_default_system_shell, maybe, rel_path::RelPath};
 19
 20use crate::{Project, ProjectPath};
 21
 22pub struct Terminals {
 23    pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
 24}
 25
 26impl Project {
 27    pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
 28        self.active_entry()
 29            .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
 30            .into_iter()
 31            .chain(self.worktrees(cx))
 32            .find_map(|tree| tree.read(cx).root_dir())
 33    }
 34
 35    pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
 36        let worktree = self.worktrees(cx).next()?;
 37        let worktree = worktree.read(cx);
 38        if worktree.root_entry()?.is_dir() {
 39            Some(worktree.abs_path().to_path_buf())
 40        } else {
 41            None
 42        }
 43    }
 44
 45    pub fn create_terminal_task(
 46        &mut self,
 47        spawn_task: SpawnInTerminal,
 48        cx: &mut Context<Self>,
 49    ) -> Task<Result<Entity<Terminal>>> {
 50        let is_via_remote = self.remote_client.is_some();
 51
 52        let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
 53            if is_via_remote {
 54                Some(Arc::from(cwd.as_ref()))
 55            } else {
 56                let cwd = cwd.to_string_lossy();
 57                let tilde_substituted = shellexpand::tilde(&cwd);
 58                Some(Arc::from(Path::new(tilde_substituted.as_ref())))
 59            }
 60        } else {
 61            self.active_project_directory(cx)
 62        };
 63
 64        let mut settings_location = None;
 65        if let Some(path) = path.as_ref()
 66            && let Some((worktree, _)) = self.find_worktree(path, cx)
 67        {
 68            settings_location = Some(SettingsLocation {
 69                worktree_id: worktree.read(cx).id(),
 70                path: RelPath::empty(),
 71            });
 72        }
 73        let settings = TerminalSettings::get(settings_location, cx).clone();
 74        let detect_venv = settings.detect_venv.as_option().is_some();
 75
 76        let (completion_tx, completion_rx) = bounded(1);
 77
 78        // Start with the environment that we might have inherited from the Zed CLI.
 79        let mut env = self
 80            .environment
 81            .read(cx)
 82            .get_cli_environment()
 83            .unwrap_or_default();
 84        // Then extend it with the explicit env variables from the settings, so they take
 85        // precedence.
 86        env.extend(settings.env);
 87
 88        let local_path = if is_via_remote { None } else { path.clone() };
 89        let task_state = Some(TaskState {
 90            spawned_task: spawn_task.clone(),
 91            status: TaskStatus::Running,
 92            completion_rx,
 93        });
 94        let remote_client = self.remote_client.clone();
 95        let shell = match &remote_client {
 96            Some(remote_client) => remote_client
 97                .read(cx)
 98                .shell()
 99                .unwrap_or_else(get_default_system_shell),
100            None => settings.shell.program(),
101        };
102
103        let is_windows = self.path_style(cx).is_windows();
104
105        let project_path_contexts = self
106            .active_entry()
107            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
108            .into_iter()
109            .chain(
110                self.visible_worktrees(cx)
111                    .map(|wt| wt.read(cx).id())
112                    .map(|worktree_id| ProjectPath {
113                        worktree_id,
114                        path: Arc::from(RelPath::empty()),
115                    }),
116            );
117        let toolchains = project_path_contexts
118            .filter(|_| detect_venv)
119            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
120            .collect::<Vec<_>>();
121        let lang_registry = self.languages.clone();
122        cx.spawn(async move |project, cx| {
123            let shell_kind = ShellKind::new(&shell, is_windows);
124
125            let activation_script = maybe!(async {
126                for toolchain in toolchains {
127                    let Some(toolchain) = toolchain.await else {
128                        continue;
129                    };
130                    let language = lang_registry
131                        .language_for_name(&toolchain.language_name.0)
132                        .await
133                        .ok();
134                    let lister = language?.toolchain_lister();
135                    return Some(lister?.activation_script(&toolchain, shell_kind));
136                }
137                None
138            })
139            .await
140            .unwrap_or_default();
141
142            let builder = project
143                .update(cx, move |_, cx| {
144                    let format_to_run = || {
145                        if let Some(command) = &spawn_task.command {
146                            let command = shell_kind.prepend_command_prefix(command);
147                            let command = shell_kind.try_quote_prefix_aware(&command);
148                            let args = spawn_task
149                                .args
150                                .iter()
151                                .filter_map(|arg| shell_kind.try_quote(&arg));
152
153                            command.into_iter().chain(args).join(" ")
154                        } else {
155                            // todo: this breaks for remotes to windows
156                            format!("exec {shell} -l")
157                        }
158                    };
159
160                    let (shell, env) = {
161                        env.extend(spawn_task.env);
162                        match remote_client {
163                            Some(remote_client) => match activation_script.clone() {
164                                activation_script if !activation_script.is_empty() => {
165                                    let separator = shell_kind.sequential_commands_separator();
166                                    let activation_script =
167                                        activation_script.join(&format!("{separator} "));
168                                    let to_run = format_to_run();
169
170                                    let arg = format!("{activation_script}{separator} {to_run}");
171                                    let args = shell_kind.args_for_shell(false, arg);
172                                    let shell = remote_client
173                                        .read(cx)
174                                        .shell()
175                                        .unwrap_or_else(get_default_system_shell);
176
177                                    create_remote_shell(
178                                        Some((&shell, &args)),
179                                        env,
180                                        path,
181                                        remote_client,
182                                        cx,
183                                    )?
184                                }
185                                _ => create_remote_shell(
186                                    spawn_task
187                                        .command
188                                        .as_ref()
189                                        .map(|command| (command, &spawn_task.args)),
190                                    env,
191                                    path,
192                                    remote_client,
193                                    cx,
194                                )?,
195                            },
196                            None => match activation_script.clone() {
197                                activation_script if !activation_script.is_empty() => {
198                                    let separator = shell_kind.sequential_commands_separator();
199                                    let activation_script =
200                                        activation_script.join(&format!("{separator} "));
201                                    let to_run = format_to_run();
202
203                                    let mut arg =
204                                        format!("{activation_script}{separator} {to_run}");
205                                    if shell_kind == ShellKind::Cmd {
206                                        // We need to put the entire command in quotes since otherwise CMD tries to execute them
207                                        // as separate commands rather than chaining one after another.
208                                        arg = format!("\"{arg}\"");
209                                    }
210
211                                    let args = shell_kind.args_for_shell(false, arg);
212
213                                    (
214                                        Shell::WithArguments {
215                                            program: shell,
216                                            args,
217                                            title_override: None,
218                                        },
219                                        env,
220                                    )
221                                }
222                                _ => (
223                                    if let Some(program) = spawn_task.command {
224                                        Shell::WithArguments {
225                                            program,
226                                            args: spawn_task.args,
227                                            title_override: None,
228                                        }
229                                    } else {
230                                        Shell::System
231                                    },
232                                    env,
233                                ),
234                            },
235                        }
236                    };
237                    anyhow::Ok(TerminalBuilder::new(
238                        local_path.map(|path| path.to_path_buf()),
239                        task_state,
240                        shell,
241                        env,
242                        settings.cursor_shape,
243                        settings.alternate_scroll,
244                        settings.max_scroll_history_lines,
245                        is_via_remote,
246                        cx.entity_id().as_u64(),
247                        Some(completion_tx),
248                        cx,
249                        activation_script,
250                    ))
251                })??
252                .await?;
253            project.update(cx, move |this, cx| {
254                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
255
256                this.terminals
257                    .local_handles
258                    .push(terminal_handle.downgrade());
259
260                let id = terminal_handle.entity_id();
261                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
262                    let handles = &mut project.terminals.local_handles;
263
264                    if let Some(index) = handles
265                        .iter()
266                        .position(|terminal| terminal.entity_id() == id)
267                    {
268                        handles.remove(index);
269                        cx.notify();
270                    }
271                })
272                .detach();
273
274                terminal_handle
275            })
276        })
277    }
278
279    pub fn create_terminal_shell(
280        &mut self,
281        cwd: Option<PathBuf>,
282        cx: &mut Context<Self>,
283    ) -> Task<Result<Entity<Terminal>>> {
284        let path = cwd.map(|p| Arc::from(&*p));
285        let is_via_remote = self.remote_client.is_some();
286
287        let mut settings_location = None;
288        if let Some(path) = path.as_ref()
289            && let Some((worktree, _)) = self.find_worktree(path, cx)
290        {
291            settings_location = Some(SettingsLocation {
292                worktree_id: worktree.read(cx).id(),
293                path: RelPath::empty(),
294            });
295        }
296        let settings = TerminalSettings::get(settings_location, cx).clone();
297        let detect_venv = settings.detect_venv.as_option().is_some();
298
299        // Start with the environment that we might have inherited from the Zed CLI.
300        let mut env = self
301            .environment
302            .read(cx)
303            .get_cli_environment()
304            .unwrap_or_default();
305        // Then extend it with the explicit env variables from the settings, so they take
306        // precedence.
307        env.extend(settings.env);
308
309        let local_path = if is_via_remote { None } else { path.clone() };
310
311        let project_path_contexts = self
312            .active_entry()
313            .and_then(|entry_id| self.path_for_entry(entry_id, cx))
314            .into_iter()
315            .chain(
316                self.visible_worktrees(cx)
317                    .map(|wt| wt.read(cx).id())
318                    .map(|worktree_id| ProjectPath {
319                        worktree_id,
320                        path: RelPath::empty().into(),
321                    }),
322            );
323        let toolchains = project_path_contexts
324            .filter(|_| detect_venv)
325            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
326            .collect::<Vec<_>>();
327        let remote_client = self.remote_client.clone();
328        let shell = match &remote_client {
329            Some(remote_client) => remote_client
330                .read(cx)
331                .shell()
332                .unwrap_or_else(get_default_system_shell),
333            None => settings.shell.program(),
334        };
335
336        let shell_kind = ShellKind::new(&shell, self.path_style(cx).is_windows());
337
338        let lang_registry = self.languages.clone();
339        cx.spawn(async move |project, cx| {
340            let activation_script = maybe!(async {
341                for toolchain in toolchains {
342                    let Some(toolchain) = toolchain.await else {
343                        continue;
344                    };
345                    let language = lang_registry
346                        .language_for_name(&toolchain.language_name.0)
347                        .await
348                        .ok();
349                    let lister = language?.toolchain_lister();
350                    return Some(lister?.activation_script(&toolchain, shell_kind));
351                }
352                None
353            })
354            .await
355            .unwrap_or_default();
356            let builder = project
357                .update(cx, move |_, cx| {
358                    let (shell, env) = {
359                        match remote_client {
360                            Some(remote_client) => {
361                                create_remote_shell(None, env, path, remote_client, cx)?
362                            }
363                            None => (settings.shell, env),
364                        }
365                    };
366                    anyhow::Ok(TerminalBuilder::new(
367                        local_path.map(|path| path.to_path_buf()),
368                        None,
369                        shell,
370                        env,
371                        settings.cursor_shape,
372                        settings.alternate_scroll,
373                        settings.max_scroll_history_lines,
374                        is_via_remote,
375                        cx.entity_id().as_u64(),
376                        None,
377                        cx,
378                        activation_script,
379                    ))
380                })??
381                .await?;
382            project.update(cx, move |this, cx| {
383                let terminal_handle = cx.new(|cx| builder.subscribe(cx));
384
385                this.terminals
386                    .local_handles
387                    .push(terminal_handle.downgrade());
388
389                let id = terminal_handle.entity_id();
390                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
391                    let handles = &mut project.terminals.local_handles;
392
393                    if let Some(index) = handles
394                        .iter()
395                        .position(|terminal| terminal.entity_id() == id)
396                    {
397                        handles.remove(index);
398                        cx.notify();
399                    }
400                })
401                .detach();
402
403                terminal_handle
404            })
405        })
406    }
407
408    pub fn clone_terminal(
409        &mut self,
410        terminal: &Entity<Terminal>,
411        cx: &mut Context<'_, Project>,
412        cwd: Option<PathBuf>,
413    ) -> Task<Result<Entity<Terminal>>> {
414        // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
415        // For now, create a new shell instead.
416        if terminal.read(cx).task().is_some() {
417            return self.create_terminal_shell(cwd, cx);
418        }
419        let local_path = if self.is_via_remote_server() {
420            None
421        } else {
422            cwd
423        };
424
425        let builder = terminal.read(cx).clone_builder(cx, local_path);
426        cx.spawn(async |project, cx| {
427            let terminal = builder.await?;
428            project.update(cx, |project, cx| {
429                let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
430
431                project
432                    .terminals
433                    .local_handles
434                    .push(terminal_handle.downgrade());
435
436                let id = terminal_handle.entity_id();
437                cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
438                    let handles = &mut project.terminals.local_handles;
439
440                    if let Some(index) = handles
441                        .iter()
442                        .position(|terminal| terminal.entity_id() == id)
443                    {
444                        handles.remove(index);
445                        cx.notify();
446                    }
447                })
448                .detach();
449
450                terminal_handle
451            })
452        })
453    }
454
455    pub fn terminal_settings<'a>(
456        &'a self,
457        path: &'a Option<PathBuf>,
458        cx: &'a App,
459    ) -> &'a TerminalSettings {
460        let mut settings_location = None;
461        if let Some(path) = path.as_ref()
462            && let Some((worktree, _)) = self.find_worktree(path, cx)
463        {
464            settings_location = Some(SettingsLocation {
465                worktree_id: worktree.read(cx).id(),
466                path: RelPath::empty(),
467            });
468        }
469        TerminalSettings::get(settings_location, cx)
470    }
471
472    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<smol::process::Command> {
473        let path = self.first_project_directory(cx);
474        let remote_client = self.remote_client.as_ref();
475        let settings = self.terminal_settings(&path, cx).clone();
476        let shell = remote_client
477            .as_ref()
478            .and_then(|remote_client| remote_client.read(cx).shell())
479            .map(Shell::Program)
480            .unwrap_or_else(|| settings.shell.clone());
481        let is_windows = self.path_style(cx).is_windows();
482        let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
483        let (command, args) = builder.build(Some(command), &Vec::new());
484
485        let mut env = self
486            .environment
487            .read(cx)
488            .get_cli_environment()
489            .unwrap_or_default();
490        env.extend(settings.env);
491
492        match remote_client {
493            Some(remote_client) => {
494                let command_template =
495                    remote_client
496                        .read(cx)
497                        .build_command(Some(command), &args, &env, None, None)?;
498                let mut command = std::process::Command::new(command_template.program);
499                command.args(command_template.args);
500                command.envs(command_template.env);
501                Ok(command)
502            }
503            None => {
504                let mut command = std::process::Command::new(command);
505                command.args(args);
506                command.envs(env);
507                if let Some(path) = path {
508                    command.current_dir(path);
509                }
510                Ok(command)
511            }
512        }
513        .map(|mut process| {
514            util::set_pre_exec_to_start_new_session(&mut process);
515            smol::process::Command::from(process)
516        })
517    }
518
519    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
520        &self.terminals.local_handles
521    }
522}
523
524fn create_remote_shell(
525    spawn_command: Option<(&String, &Vec<String>)>,
526    mut env: HashMap<String, String>,
527    working_directory: Option<Arc<Path>>,
528    remote_client: Entity<RemoteClient>,
529    cx: &mut App,
530) -> Result<(Shell, HashMap<String, String>)> {
531    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
532    // to properly display colors.
533    // We do not have the luxury of assuming the host has it installed,
534    // so we set it to a default that does not break the highlighting via ssh.
535    env.entry("TERM".to_string())
536        .or_insert_with(|| "xterm-256color".to_string());
537
538    let (program, args) = match spawn_command {
539        Some((program, args)) => (Some(program.clone()), args),
540        None => (None, &Vec::new()),
541    };
542
543    let command = remote_client.read(cx).build_command(
544        program,
545        args.as_slice(),
546        &env,
547        working_directory.map(|path| path.display().to_string()),
548        None,
549    )?;
550
551    log::debug!("Connecting to a remote server: {:?}", command.program);
552    let host = remote_client.read(cx).connection_options().display_name();
553
554    Ok((
555        Shell::WithArguments {
556            program: command.program,
557            args: command.args,
558            title_override: Some(format!("{} — Terminal", host)),
559        },
560        command.env,
561    ))
562}