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