terminals.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
  4use itertools::Itertools;
  5use language::LanguageName;
  6use remote::RemoteClient;
  7use settings::{Settings, SettingsLocation};
  8use smol::channel::bounded;
  9use std::{
 10    borrow::Cow,
 11    path::{Path, PathBuf},
 12    sync::Arc,
 13};
 14use task::{Shell, ShellBuilder, SpawnInTerminal};
 15use terminal::{
 16    TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
 17};
 18use util::{get_default_system_shell, get_system_shell, maybe};
 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        let project_path_context = self
 52            .active_entry()
 53            .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
 54            .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
 55            .map(|worktree_id| ProjectPath {
 56                worktree_id,
 57                path: Arc::from(Path::new("")),
 58            });
 59
 60        let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
 61            if is_via_remote {
 62                Some(Arc::from(cwd.as_ref()))
 63            } else {
 64                let cwd = cwd.to_string_lossy();
 65                let tilde_substituted = shellexpand::tilde(&cwd);
 66                Some(Arc::from(Path::new(tilde_substituted.as_ref())))
 67            }
 68        } else {
 69            self.active_project_directory(cx)
 70        };
 71
 72        let mut settings_location = None;
 73        if let Some(path) = path.as_ref()
 74            && let Some((worktree, _)) = self.find_worktree(path, cx)
 75        {
 76            settings_location = Some(SettingsLocation {
 77                worktree_id: worktree.read(cx).id(),
 78                path,
 79            });
 80        }
 81        let settings = TerminalSettings::get(settings_location, cx).clone();
 82        let detect_venv = settings.detect_venv.as_option().is_some();
 83
 84        let (completion_tx, completion_rx) = bounded(1);
 85
 86        // Start with the environment that we might have inherited from the Zed CLI.
 87        let mut env = self
 88            .environment
 89            .read(cx)
 90            .get_cli_environment()
 91            .unwrap_or_default();
 92        // Then extend it with the explicit env variables from the settings, so they take
 93        // precedence.
 94        env.extend(settings.env);
 95
 96        let local_path = if is_via_remote { None } else { path.clone() };
 97        let task_state = Some(TaskState {
 98            id: spawn_task.id,
 99            full_label: spawn_task.full_label,
100            label: spawn_task.label,
101            command_label: spawn_task.command_label,
102            hide: spawn_task.hide,
103            status: TaskStatus::Running,
104            show_summary: spawn_task.show_summary,
105            show_command: spawn_task.show_command,
106            show_rerun: spawn_task.show_rerun,
107            completion_rx,
108        });
109        let remote_client = self.remote_client.clone();
110        let shell = match &remote_client {
111            Some(remote_client) => remote_client
112                .read(cx)
113                .shell()
114                .unwrap_or_else(get_default_system_shell),
115            None => match &settings.shell {
116                Shell::Program(program) => program.clone(),
117                Shell::WithArguments {
118                    program,
119                    args: _,
120                    title_override: _,
121                } => program.clone(),
122                Shell::System => get_system_shell(),
123            },
124        };
125
126        let toolchain = project_path_context
127            .filter(|_| detect_venv)
128            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
129        let lang_registry = self.languages.clone();
130        let fs = self.fs.clone();
131        cx.spawn(async move |project, cx| {
132            let activation_script = maybe!(async {
133                let toolchain = toolchain?.await?;
134                lang_registry
135                    .language_for_name(&toolchain.language_name.0)
136                    .await
137                    .ok()?
138                    .toolchain_lister()?
139                    .activation_script(&toolchain, fs.as_ref())
140                    .await
141            })
142            .await;
143
144            project.update(cx, move |this, cx| {
145                let shell = {
146                    env.extend(spawn_task.env);
147                    match remote_client {
148                        Some(remote_client) => create_remote_shell(
149                            spawn_task
150                                .command
151                                .as_ref()
152                                .map(|command| (command, &spawn_task.args)),
153                            &mut env,
154                            path,
155                            remote_client,
156                            activation_script.clone(),
157                            cx,
158                        )?,
159                        None => match activation_script.clone() {
160                            Some(activation_script) => {
161                                let to_run = if let Some(command) = spawn_task.command {
162                                    let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
163                                    let args = spawn_task
164                                        .args
165                                        .iter()
166                                        .filter_map(|arg| shlex::try_quote(arg).ok());
167                                    command.into_iter().chain(args).join(" ")
168                                } else {
169                                    format!("exec {shell} -l")
170                                };
171                                Shell::WithArguments {
172                                    program: get_default_system_shell(),
173                                    args: vec![
174                                        "-c".to_owned(),
175                                        format!("{activation_script}; {to_run}",),
176                                    ],
177                                    title_override: None,
178                                }
179                            }
180                            None => {
181                                if let Some(program) = spawn_task.command {
182                                    Shell::WithArguments {
183                                        program,
184                                        args: spawn_task.args,
185                                        title_override: None,
186                                    }
187                                } else {
188                                    Shell::System
189                                }
190                            }
191                        },
192                    }
193                };
194                TerminalBuilder::new(
195                    local_path.map(|path| path.to_path_buf()),
196                    task_state,
197                    shell,
198                    env,
199                    settings.cursor_shape.unwrap_or_default(),
200                    settings.alternate_scroll,
201                    settings.max_scroll_history_lines,
202                    is_via_remote,
203                    cx.entity_id().as_u64(),
204                    Some(completion_tx),
205                    cx,
206                    activation_script,
207                )
208                .map(|builder| {
209                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
210
211                    this.terminals
212                        .local_handles
213                        .push(terminal_handle.downgrade());
214
215                    let id = terminal_handle.entity_id();
216                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
217                        let handles = &mut project.terminals.local_handles;
218
219                        if let Some(index) = handles
220                            .iter()
221                            .position(|terminal| terminal.entity_id() == id)
222                        {
223                            handles.remove(index);
224                            cx.notify();
225                        }
226                    })
227                    .detach();
228
229                    terminal_handle
230                })
231            })?
232        })
233    }
234
235    pub fn create_terminal_shell(
236        &mut self,
237        cwd: Option<PathBuf>,
238        cx: &mut Context<Self>,
239    ) -> Task<Result<Entity<Terminal>>> {
240        let project_path_context = self
241            .active_entry()
242            .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
243            .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
244            .map(|worktree_id| ProjectPath {
245                worktree_id,
246                path: Arc::from(Path::new("")),
247            });
248        let path = cwd.map(|p| Arc::from(&*p));
249        let is_via_remote = self.remote_client.is_some();
250
251        let mut settings_location = None;
252        if let Some(path) = path.as_ref()
253            && let Some((worktree, _)) = self.find_worktree(path, cx)
254        {
255            settings_location = Some(SettingsLocation {
256                worktree_id: worktree.read(cx).id(),
257                path,
258            });
259        }
260        let settings = TerminalSettings::get(settings_location, cx).clone();
261        let detect_venv = settings.detect_venv.as_option().is_some();
262
263        // Start with the environment that we might have inherited from the Zed CLI.
264        let mut env = self
265            .environment
266            .read(cx)
267            .get_cli_environment()
268            .unwrap_or_default();
269        // Then extend it with the explicit env variables from the settings, so they take
270        // precedence.
271        env.extend(settings.env);
272
273        let local_path = if is_via_remote { None } else { path.clone() };
274
275        let toolchain = project_path_context
276            .filter(|_| detect_venv)
277            .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
278        let remote_client = self.remote_client.clone();
279        let shell = match &remote_client {
280            Some(remote_client) => remote_client
281                .read(cx)
282                .shell()
283                .unwrap_or_else(get_default_system_shell),
284            None => match &settings.shell {
285                Shell::Program(program) => program.clone(),
286                Shell::WithArguments {
287                    program,
288                    args: _,
289                    title_override: _,
290                } => program.clone(),
291                Shell::System => get_system_shell(),
292            },
293        };
294
295        let lang_registry = self.languages.clone();
296        let fs = self.fs.clone();
297        cx.spawn(async move |project, cx| {
298            let activation_script = maybe!(async {
299                let toolchain = toolchain?.await?;
300                let language = lang_registry
301                    .language_for_name(&toolchain.language_name.0)
302                    .await
303                    .ok();
304                let lister = language?.toolchain_lister();
305                lister?.activation_script(&toolchain, fs.as_ref()).await
306            })
307            .await;
308            project.update(cx, move |this, cx| {
309                let shell = {
310                    match remote_client {
311                        Some(remote_client) => create_remote_shell(
312                            None,
313                            &mut env,
314                            path,
315                            remote_client,
316                            activation_script.clone(),
317                            cx,
318                        )?,
319                        None => match activation_script.clone() {
320                            Some(activation_script) => Shell::WithArguments {
321                                program: get_default_system_shell(),
322                                args: vec![
323                                    "-c".to_owned(),
324                                    format!("{activation_script}; exec {shell} -l",),
325                                ],
326                                title_override: Some(shell.into()),
327                            },
328                            None => settings.shell,
329                        },
330                    }
331                };
332                TerminalBuilder::new(
333                    local_path.map(|path| path.to_path_buf()),
334                    None,
335                    shell,
336                    env,
337                    settings.cursor_shape.unwrap_or_default(),
338                    settings.alternate_scroll,
339                    settings.max_scroll_history_lines,
340                    is_via_remote,
341                    cx.entity_id().as_u64(),
342                    None,
343                    cx,
344                    activation_script,
345                )
346                .map(|builder| {
347                    let terminal_handle = cx.new(|cx| builder.subscribe(cx));
348
349                    this.terminals
350                        .local_handles
351                        .push(terminal_handle.downgrade());
352
353                    let id = terminal_handle.entity_id();
354                    cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
355                        let handles = &mut project.terminals.local_handles;
356
357                        if let Some(index) = handles
358                            .iter()
359                            .position(|terminal| terminal.entity_id() == id)
360                        {
361                            handles.remove(index);
362                            cx.notify();
363                        }
364                    })
365                    .detach();
366
367                    terminal_handle
368                })
369            })?
370        })
371    }
372
373    pub fn clone_terminal(
374        &mut self,
375        terminal: &Entity<Terminal>,
376        cx: &mut Context<'_, Project>,
377        cwd: impl FnOnce() -> Option<PathBuf>,
378    ) -> Result<Entity<Terminal>> {
379        terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
380            let terminal_handle = cx.new(|cx| builder.subscribe(cx));
381
382            self.terminals
383                .local_handles
384                .push(terminal_handle.downgrade());
385
386            let id = terminal_handle.entity_id();
387            cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
388                let handles = &mut project.terminals.local_handles;
389
390                if let Some(index) = handles
391                    .iter()
392                    .position(|terminal| terminal.entity_id() == id)
393                {
394                    handles.remove(index);
395                    cx.notify();
396                }
397            })
398            .detach();
399
400            terminal_handle
401        })
402    }
403
404    pub fn terminal_settings<'a>(
405        &'a self,
406        path: &'a Option<PathBuf>,
407        cx: &'a App,
408    ) -> &'a TerminalSettings {
409        let mut settings_location = None;
410        if let Some(path) = path.as_ref()
411            && let Some((worktree, _)) = self.find_worktree(path, cx)
412        {
413            settings_location = Some(SettingsLocation {
414                worktree_id: worktree.read(cx).id(),
415                path,
416            });
417        }
418        TerminalSettings::get(settings_location, cx)
419    }
420
421    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
422        let path = self.first_project_directory(cx);
423        let remote_client = self.remote_client.as_ref();
424        let settings = self.terminal_settings(&path, cx).clone();
425        let remote_shell = remote_client
426            .as_ref()
427            .and_then(|remote_client| remote_client.read(cx).shell());
428        let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
429        let (command, args) = builder.build(Some(command), &Vec::new());
430
431        let mut env = self
432            .environment
433            .read(cx)
434            .get_cli_environment()
435            .unwrap_or_default();
436        env.extend(settings.env);
437
438        match remote_client {
439            Some(remote_client) => {
440                let command_template = remote_client.read(cx).build_command(
441                    Some(command),
442                    &args,
443                    &env,
444                    None,
445                    // todo
446                    None,
447                    None,
448                )?;
449                let mut command = std::process::Command::new(command_template.program);
450                command.args(command_template.args);
451                command.envs(command_template.env);
452                Ok(command)
453            }
454            None => {
455                let mut command = std::process::Command::new(command);
456                command.args(args);
457                command.envs(env);
458                if let Some(path) = path {
459                    command.current_dir(path);
460                }
461                Ok(command)
462            }
463        }
464    }
465
466    pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
467        &self.terminals.local_handles
468    }
469}
470
471fn create_remote_shell(
472    spawn_command: Option<(&String, &Vec<String>)>,
473    env: &mut HashMap<String, String>,
474    working_directory: Option<Arc<Path>>,
475    remote_client: Entity<RemoteClient>,
476    activation_script: Option<String>,
477    cx: &mut App,
478) -> Result<Shell> {
479    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
480    // to properly display colors.
481    // We do not have the luxury of assuming the host has it installed,
482    // so we set it to a default that does not break the highlighting via ssh.
483    env.entry("TERM".to_string())
484        .or_insert_with(|| "xterm-256color".to_string());
485
486    let (program, args) = match spawn_command {
487        Some((program, args)) => (Some(program.clone()), args),
488        None => (None, &Vec::new()),
489    };
490
491    let command = remote_client.read(cx).build_command(
492        program,
493        args.as_slice(),
494        env,
495        working_directory.map(|path| path.display().to_string()),
496        activation_script,
497        None,
498    )?;
499    *env = command.env;
500
501    log::debug!("Connecting to a remote server: {:?}", command.program);
502    let host = remote_client.read(cx).connection_options().host;
503
504    Ok(Shell::WithArguments {
505        program: command.program,
506        args: command.args,
507        title_override: Some(format!("{} — Terminal", host).into()),
508    })
509}