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