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