1use crate::{Project, ProjectPath};
2use anyhow::{Context as _, Result};
3use collections::HashMap;
4use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
5use itertools::Itertools;
6use language::LanguageName;
7use remote::{SshInfo, ssh_session::SshArgs};
8use settings::{Settings, SettingsLocation};
9use smol::channel::bounded;
10use std::{
11 borrow::Cow,
12 env::{self},
13 path::{Path, PathBuf},
14 sync::Arc,
15};
16use task::{Shell, ShellBuilder, SpawnInTerminal};
17use terminal::{
18 TaskState, TaskStatus, Terminal, TerminalBuilder,
19 terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings},
20};
21use util::{
22 ResultExt,
23 paths::{PathStyle, RemotePathBuf},
24};
25
26/// The directory inside a Python virtual environment that contains executables
27const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") {
28 "Scripts"
29} else {
30 "bin"
31};
32
33pub struct Terminals {
34 pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
35}
36
37/// Terminals are opened either for the users shell, or to run a task.
38
39#[derive(Debug)]
40pub enum TerminalKind {
41 /// Run a shell at the given path (or $HOME if None)
42 Shell(Option<PathBuf>),
43 /// Run a task.
44 Task(SpawnInTerminal),
45}
46
47/// SshCommand describes how to connect to a remote server
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct SshCommand {
50 pub arguments: Vec<String>,
51}
52
53impl SshCommand {
54 pub fn add_port_forwarding(&mut self, local_port: u16, host: String, remote_port: u16) {
55 self.arguments.push("-L".to_string());
56 self.arguments
57 .push(format!("{}:{}:{}", local_port, host, remote_port));
58 }
59}
60
61#[derive(Debug)]
62pub struct SshDetails {
63 pub host: String,
64 pub ssh_command: SshCommand,
65 pub envs: Option<HashMap<String, String>>,
66 pub path_style: PathStyle,
67 pub shell: String,
68}
69
70impl Project {
71 pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
72 self.active_entry()
73 .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
74 .into_iter()
75 .chain(self.worktrees(cx))
76 .find_map(|tree| tree.read(cx).root_dir())
77 }
78
79 pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
80 let worktree = self.worktrees(cx).next()?;
81 let worktree = worktree.read(cx);
82 if worktree.root_entry()?.is_dir() {
83 Some(worktree.abs_path().to_path_buf())
84 } else {
85 None
86 }
87 }
88
89 pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
90 if let Some(ssh_client) = &self.ssh_client {
91 let ssh_client = ssh_client.read(cx);
92 if let Some(SshInfo {
93 args: SshArgs { arguments, envs },
94 path_style,
95 shell,
96 }) = ssh_client.ssh_info()
97 {
98 return Some(SshDetails {
99 host: ssh_client.connection_options().host,
100 ssh_command: SshCommand { arguments },
101 envs,
102 path_style,
103 shell,
104 });
105 }
106 }
107
108 None
109 }
110
111 pub fn create_terminal(
112 &mut self,
113 kind: TerminalKind,
114 cx: &mut Context<Self>,
115 ) -> Task<Result<Entity<Terminal>>> {
116 let path: Option<Arc<Path>> = match &kind {
117 TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
118 TerminalKind::Task(spawn_task) => {
119 if let Some(cwd) = &spawn_task.cwd {
120 Some(Arc::from(cwd.as_ref()))
121 } else {
122 self.active_project_directory(cx)
123 }
124 }
125 };
126
127 let mut settings_location = None;
128 if let Some(path) = path.as_ref()
129 && let Some((worktree, _)) = self.find_worktree(path, cx)
130 {
131 settings_location = Some(SettingsLocation {
132 worktree_id: worktree.read(cx).id(),
133 path,
134 });
135 }
136 let venv = TerminalSettings::get(settings_location, cx)
137 .detect_venv
138 .clone();
139
140 cx.spawn(async move |project, cx| {
141 let python_venv_directory = if let Some(path) = path {
142 project
143 .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))?
144 .await
145 } else {
146 None
147 };
148 project.update(cx, |project, cx| {
149 project.create_terminal_with_venv(kind, python_venv_directory, cx)
150 })?
151 })
152 }
153
154 pub fn terminal_settings<'a>(
155 &'a self,
156 path: &'a Option<PathBuf>,
157 cx: &'a App,
158 ) -> &'a TerminalSettings {
159 let mut settings_location = None;
160 if let Some(path) = path.as_ref()
161 && let Some((worktree, _)) = self.find_worktree(path, cx)
162 {
163 settings_location = Some(SettingsLocation {
164 worktree_id: worktree.read(cx).id(),
165 path,
166 });
167 }
168 TerminalSettings::get(settings_location, cx)
169 }
170
171 pub fn exec_in_shell(&self, command: String, cx: &App) -> std::process::Command {
172 let path = self.first_project_directory(cx);
173 let ssh_details = self.ssh_details(cx);
174 let settings = self.terminal_settings(&path, cx).clone();
175
176 let builder =
177 ShellBuilder::new(ssh_details.as_ref().map(|ssh| &*ssh.shell), &settings.shell)
178 .non_interactive();
179 let (command, args) = builder.build(Some(command), &Vec::new());
180
181 let mut env = self
182 .environment
183 .read(cx)
184 .get_cli_environment()
185 .unwrap_or_default();
186 env.extend(settings.env);
187
188 match self.ssh_details(cx) {
189 Some(SshDetails {
190 ssh_command,
191 envs,
192 path_style,
193 shell,
194 ..
195 }) => {
196 let (command, args) = wrap_for_ssh(
197 &shell,
198 &ssh_command,
199 Some((&command, &args)),
200 path.as_deref(),
201 env,
202 None,
203 path_style,
204 );
205 let mut command = std::process::Command::new(command);
206 command.args(args);
207 if let Some(envs) = envs {
208 command.envs(envs);
209 }
210 command
211 }
212 None => {
213 let mut command = std::process::Command::new(command);
214 command.args(args);
215 command.envs(env);
216 if let Some(path) = path {
217 command.current_dir(path);
218 }
219 command
220 }
221 }
222 }
223
224 pub fn create_terminal_with_venv(
225 &mut self,
226 kind: TerminalKind,
227 python_venv_directory: Option<PathBuf>,
228 cx: &mut Context<Self>,
229 ) -> Result<Entity<Terminal>> {
230 let this = &mut *self;
231 let ssh_details = this.ssh_details(cx);
232 let path: Option<Arc<Path>> = match &kind {
233 TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
234 TerminalKind::Task(spawn_task) => {
235 if let Some(cwd) = &spawn_task.cwd {
236 if ssh_details.is_some() {
237 Some(Arc::from(cwd.as_ref()))
238 } else {
239 let cwd = cwd.to_string_lossy();
240 let tilde_substituted = shellexpand::tilde(&cwd);
241 Some(Arc::from(Path::new(tilde_substituted.as_ref())))
242 }
243 } else {
244 this.active_project_directory(cx)
245 }
246 }
247 };
248
249 let is_ssh_terminal = ssh_details.is_some();
250
251 let mut settings_location = None;
252 if let Some(path) = path.as_ref()
253 && let Some((worktree, _)) = this.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
262 let (completion_tx, completion_rx) = bounded(1);
263
264 // Start with the environment that we might have inherited from the Zed CLI.
265 let mut env = this
266 .environment
267 .read(cx)
268 .get_cli_environment()
269 .unwrap_or_default();
270 // Then extend it with the explicit env variables from the settings, so they take
271 // precedence.
272 env.extend(settings.env);
273
274 let local_path = if is_ssh_terminal { None } else { path.clone() };
275
276 let mut python_venv_activate_command = Task::ready(None);
277
278 let (spawn_task, shell) = match kind {
279 TerminalKind::Shell(_) => {
280 if let Some(python_venv_directory) = &python_venv_directory {
281 python_venv_activate_command = this.python_activate_command(
282 python_venv_directory,
283 &settings.detect_venv,
284 &settings.shell,
285 cx,
286 );
287 }
288
289 match ssh_details {
290 Some(SshDetails {
291 host,
292 ssh_command,
293 envs,
294 path_style,
295 shell,
296 }) => {
297 log::debug!("Connecting to a remote server: {ssh_command:?}");
298
299 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
300 // to properly display colors.
301 // We do not have the luxury of assuming the host has it installed,
302 // so we set it to a default that does not break the highlighting via ssh.
303 env.entry("TERM".to_string())
304 .or_insert_with(|| "xterm-256color".to_string());
305
306 let (program, args) = wrap_for_ssh(
307 &shell,
308 &ssh_command,
309 None,
310 path.as_deref(),
311 env,
312 None,
313 path_style,
314 );
315 env = HashMap::default();
316 if let Some(envs) = envs {
317 env.extend(envs);
318 }
319 (
320 Option::<TaskState>::None,
321 Shell::WithArguments {
322 program,
323 args,
324 title_override: Some(format!("{} — Terminal", host).into()),
325 },
326 )
327 }
328 None => (None, settings.shell),
329 }
330 }
331 TerminalKind::Task(spawn_task) => {
332 let task_state = Some(TaskState {
333 id: spawn_task.id,
334 full_label: spawn_task.full_label,
335 label: spawn_task.label,
336 command_label: spawn_task.command_label,
337 hide: spawn_task.hide,
338 status: TaskStatus::Running,
339 show_summary: spawn_task.show_summary,
340 show_command: spawn_task.show_command,
341 show_rerun: spawn_task.show_rerun,
342 completion_rx,
343 });
344
345 env.extend(spawn_task.env);
346
347 if let Some(venv_path) = &python_venv_directory {
348 env.insert(
349 "VIRTUAL_ENV".to_string(),
350 venv_path.to_string_lossy().to_string(),
351 );
352 }
353
354 match ssh_details {
355 Some(SshDetails {
356 host,
357 ssh_command,
358 envs,
359 path_style,
360 shell,
361 }) => {
362 log::debug!("Connecting to a remote server: {ssh_command:?}");
363 env.entry("TERM".to_string())
364 .or_insert_with(|| "xterm-256color".to_string());
365 let (program, args) = wrap_for_ssh(
366 &shell,
367 &ssh_command,
368 spawn_task
369 .command
370 .as_ref()
371 .map(|command| (command, &spawn_task.args)),
372 path.as_deref(),
373 env,
374 python_venv_directory.as_deref(),
375 path_style,
376 );
377 env = HashMap::default();
378 if let Some(envs) = envs {
379 env.extend(envs);
380 }
381 (
382 task_state,
383 Shell::WithArguments {
384 program,
385 args,
386 title_override: Some(format!("{} — Terminal", host).into()),
387 },
388 )
389 }
390 None => {
391 if let Some(venv_path) = &python_venv_directory {
392 add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR))
393 .log_err();
394 }
395
396 let shell = if let Some(program) = spawn_task.command {
397 Shell::WithArguments {
398 program,
399 args: spawn_task.args,
400 title_override: None,
401 }
402 } else {
403 Shell::System
404 };
405 (task_state, shell)
406 }
407 }
408 }
409 };
410 TerminalBuilder::new(
411 local_path.map(|path| path.to_path_buf()),
412 python_venv_directory,
413 spawn_task,
414 shell,
415 env,
416 settings.cursor_shape.unwrap_or_default(),
417 settings.alternate_scroll,
418 settings.max_scroll_history_lines,
419 is_ssh_terminal,
420 cx.entity_id().as_u64(),
421 completion_tx,
422 cx,
423 )
424 .map(|builder| {
425 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
426
427 this.terminals
428 .local_handles
429 .push(terminal_handle.downgrade());
430
431 let id = terminal_handle.entity_id();
432 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
433 let handles = &mut project.terminals.local_handles;
434
435 if let Some(index) = handles
436 .iter()
437 .position(|terminal| terminal.entity_id() == id)
438 {
439 handles.remove(index);
440 cx.notify();
441 }
442 })
443 .detach();
444
445 this.activate_python_virtual_environment(
446 python_venv_activate_command,
447 &terminal_handle,
448 cx,
449 );
450
451 terminal_handle
452 })
453 }
454
455 fn python_venv_directory(
456 &self,
457 abs_path: Arc<Path>,
458 venv_settings: VenvSettings,
459 cx: &Context<Project>,
460 ) -> Task<Option<PathBuf>> {
461 cx.spawn(async move |this, cx| {
462 if let Some((worktree, relative_path)) = this
463 .update(cx, |this, cx| this.find_worktree(&abs_path, cx))
464 .ok()?
465 {
466 let toolchain = this
467 .update(cx, |this, cx| {
468 this.active_toolchain(
469 ProjectPath {
470 worktree_id: worktree.read(cx).id(),
471 path: relative_path.into(),
472 },
473 LanguageName::new("Python"),
474 cx,
475 )
476 })
477 .ok()?
478 .await;
479
480 if let Some(toolchain) = toolchain {
481 let toolchain_path = Path::new(toolchain.path.as_ref());
482 return Some(toolchain_path.parent()?.parent()?.to_path_buf());
483 }
484 }
485 let venv_settings = venv_settings.as_option()?;
486 this.update(cx, move |this, cx| {
487 if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
488 return Some(path);
489 }
490 this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
491 })
492 .ok()
493 .flatten()
494 })
495 }
496
497 fn find_venv_in_worktree(
498 &self,
499 abs_path: &Path,
500 venv_settings: &terminal_settings::VenvSettingsContent,
501 cx: &App,
502 ) -> Option<PathBuf> {
503 venv_settings
504 .directories
505 .iter()
506 .map(|name| abs_path.join(name))
507 .find(|venv_path| {
508 let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
509 self.find_worktree(&bin_path, cx)
510 .and_then(|(worktree, relative_path)| {
511 worktree.read(cx).entry_for_path(&relative_path)
512 })
513 .is_some_and(|entry| entry.is_dir())
514 })
515 }
516
517 fn find_venv_on_filesystem(
518 &self,
519 abs_path: &Path,
520 venv_settings: &terminal_settings::VenvSettingsContent,
521 cx: &App,
522 ) -> Option<PathBuf> {
523 let (worktree, _) = self.find_worktree(abs_path, cx)?;
524 let fs = worktree.read(cx).as_local()?.fs();
525 venv_settings
526 .directories
527 .iter()
528 .map(|name| abs_path.join(name))
529 .find(|venv_path| {
530 let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
531 // One-time synchronous check is acceptable for terminal/task initialization
532 smol::block_on(fs.metadata(&bin_path))
533 .ok()
534 .flatten()
535 .is_some_and(|meta| meta.is_dir)
536 })
537 }
538
539 fn activate_script_kind(shell: Option<&str>) -> ActivateScript {
540 let shell_env = std::env::var("SHELL").ok();
541 let shell_path = shell.or_else(|| shell_env.as_deref());
542 let shell = std::path::Path::new(shell_path.unwrap_or(""))
543 .file_name()
544 .and_then(|name| name.to_str())
545 .unwrap_or("");
546 match shell {
547 "fish" => ActivateScript::Fish,
548 "tcsh" => ActivateScript::Csh,
549 "nu" => ActivateScript::Nushell,
550 "powershell" | "pwsh" => ActivateScript::PowerShell,
551 _ => ActivateScript::Default,
552 }
553 }
554
555 fn python_activate_command(
556 &self,
557 venv_base_directory: &Path,
558 venv_settings: &VenvSettings,
559 shell: &Shell,
560 cx: &mut App,
561 ) -> Task<Option<String>> {
562 let Some(venv_settings) = venv_settings.as_option() else {
563 return Task::ready(None);
564 };
565 let activate_keyword = match venv_settings.activate_script {
566 terminal_settings::ActivateScript::Default => match std::env::consts::OS {
567 "windows" => ".",
568 _ => ".",
569 },
570 terminal_settings::ActivateScript::Nushell => "overlay use",
571 terminal_settings::ActivateScript::PowerShell => ".",
572 terminal_settings::ActivateScript::Pyenv => "pyenv",
573 _ => "source",
574 };
575 let script_kind =
576 if venv_settings.activate_script == terminal_settings::ActivateScript::Default {
577 match shell {
578 Shell::Program(program) => Self::activate_script_kind(Some(program)),
579 Shell::WithArguments {
580 program,
581 args: _,
582 title_override: _,
583 } => Self::activate_script_kind(Some(program)),
584 Shell::System => Self::activate_script_kind(None),
585 }
586 } else {
587 venv_settings.activate_script
588 };
589
590 let activate_script_name = match script_kind {
591 terminal_settings::ActivateScript::Default
592 | terminal_settings::ActivateScript::Pyenv => "activate",
593 terminal_settings::ActivateScript::Csh => "activate.csh",
594 terminal_settings::ActivateScript::Fish => "activate.fish",
595 terminal_settings::ActivateScript::Nushell => "activate.nu",
596 terminal_settings::ActivateScript::PowerShell => "activate.ps1",
597 };
598
599 let line_ending = match std::env::consts::OS {
600 "windows" => "\r",
601 _ => "\n",
602 };
603
604 if venv_settings.venv_name.is_empty() {
605 let path = venv_base_directory
606 .join(PYTHON_VENV_BIN_DIR)
607 .join(activate_script_name)
608 .to_string_lossy()
609 .to_string();
610
611 let is_valid_path = self.resolve_abs_path(path.as_ref(), cx);
612 cx.background_spawn(async move {
613 let quoted = shlex::try_quote(&path).ok()?;
614 if is_valid_path.await.is_some_and(|meta| meta.is_file()) {
615 Some(format!(
616 "{} {} ; clear{}",
617 activate_keyword, quoted, line_ending
618 ))
619 } else {
620 None
621 }
622 })
623 } else {
624 Task::ready(Some(format!(
625 "{activate_keyword} {activate_script_name} {name}; clear{line_ending}",
626 name = venv_settings.venv_name
627 )))
628 }
629 }
630
631 fn activate_python_virtual_environment(
632 &self,
633 command: Task<Option<String>>,
634 terminal_handle: &Entity<Terminal>,
635 cx: &mut App,
636 ) {
637 terminal_handle.update(cx, |_, cx| {
638 cx.spawn(async move |this, cx| {
639 if let Some(command) = command.await {
640 this.update(cx, |this, _| {
641 this.input(command.into_bytes());
642 })
643 .ok();
644 }
645 })
646 .detach()
647 });
648 }
649
650 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
651 &self.terminals.local_handles
652 }
653}
654
655pub fn wrap_for_ssh(
656 shell: &str,
657 ssh_command: &SshCommand,
658 command: Option<(&String, &Vec<String>)>,
659 path: Option<&Path>,
660 env: HashMap<String, String>,
661 venv_directory: Option<&Path>,
662 path_style: PathStyle,
663) -> (String, Vec<String>) {
664 let to_run = if let Some((command, args)) = command {
665 let command: Option<Cow<str>> = shlex::try_quote(command).ok();
666 let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
667 command.into_iter().chain(args).join(" ")
668 } else {
669 format!("exec {shell} -l")
670 };
671
672 let mut env_changes = String::new();
673 for (k, v) in env.iter() {
674 if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
675 env_changes.push_str(&format!("{}={} ", k, v));
676 }
677 }
678 if let Some(venv_directory) = venv_directory
679 && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref())
680 {
681 let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
682 env_changes.push_str(&format!("PATH={}:$PATH ", path));
683 }
684
685 let commands = if let Some(path) = path {
686 let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
687 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
688 // replace ith with something that works
689 let tilde_prefix = "~/";
690 if path.starts_with(tilde_prefix) {
691 let trimmed_path = path
692 .trim_start_matches("/")
693 .trim_start_matches("~")
694 .trim_start_matches("/");
695
696 format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
697 } else {
698 format!("cd \"{path}\"; {env_changes} {to_run}")
699 }
700 } else {
701 format!("cd; {env_changes} {to_run}")
702 };
703 let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap());
704
705 let program = "ssh".to_string();
706 let mut args = ssh_command.arguments.clone();
707
708 args.push("-t".to_string());
709 args.push(shell_invocation);
710 (program, args)
711}
712
713fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
714 let mut env_paths = vec![new_path.to_path_buf()];
715 if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
716 let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
717 env_paths.append(&mut paths);
718 }
719
720 let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
721 env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
722
723 Ok(())
724}
725
726#[cfg(test)]
727mod tests {
728 use collections::HashMap;
729
730 #[test]
731 fn test_add_environment_path_with_existing_path() {
732 let tmp_path = std::path::PathBuf::from("/tmp/new");
733 let mut env = HashMap::default();
734 let old_path = if cfg!(windows) {
735 "/usr/bin;/usr/local/bin"
736 } else {
737 "/usr/bin:/usr/local/bin"
738 };
739 env.insert("PATH".to_string(), old_path.to_string());
740 env.insert("OTHER".to_string(), "aaa".to_string());
741
742 super::add_environment_path(&mut env, &tmp_path).unwrap();
743 if cfg!(windows) {
744 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
745 } else {
746 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
747 }
748 assert_eq!(env.get("OTHER").unwrap(), "aaa");
749 }
750
751 #[test]
752 fn test_add_environment_path_with_empty_path() {
753 let tmp_path = std::path::PathBuf::from("/tmp/new");
754 let mut env = HashMap::default();
755 env.insert("OTHER".to_string(), "aaa".to_string());
756 let os_path = std::env::var("PATH").unwrap();
757 super::add_environment_path(&mut env, &tmp_path).unwrap();
758 if cfg!(windows) {
759 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
760 } else {
761 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
762 }
763 assert_eq!(env.get("OTHER").unwrap(), "aaa");
764 }
765}