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 iter,
13 path::{Path, PathBuf},
14 sync::Arc,
15};
16use task::{Shell, ShellBuilder, SpawnInTerminal};
17use terminal::{
18 TaskState, TaskStatus, Terminal, TerminalBuilder,
19 terminal_settings::{self, TerminalSettings, VenvSettings},
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: Option<Arc<Path>>,
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, .. } => cwd.clone(),
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, .. } => cwd.clone(),
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, relative_path)) = 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 ProjectPath {
418 worktree_id: worktree.read(cx).id(),
419 path: relative_path.into(),
420 },
421 LanguageName::new("Python"),
422 cx,
423 )
424 })
425 .ok()?
426 .await;
427
428 if let Some(toolchain) = toolchain {
429 let toolchain_path = Path::new(toolchain.path.as_ref());
430 return Some(toolchain_path.parent()?.parent()?.to_path_buf());
431 }
432 }
433 let venv_settings = venv_settings.as_option()?;
434 this.update(cx, move |this, cx| {
435 if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
436 return Some(path);
437 }
438 this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
439 })
440 .ok()
441 .flatten()
442 })
443 }
444
445 fn find_venv_in_worktree(
446 &self,
447 abs_path: &Path,
448 venv_settings: &terminal_settings::VenvSettingsContent,
449 cx: &App,
450 ) -> Option<PathBuf> {
451 let bin_dir_name = match std::env::consts::OS {
452 "windows" => "Scripts",
453 _ => "bin",
454 };
455 venv_settings
456 .directories
457 .iter()
458 .map(|name| abs_path.join(name))
459 .find(|venv_path| {
460 let bin_path = venv_path.join(bin_dir_name);
461 self.find_worktree(&bin_path, cx)
462 .and_then(|(worktree, relative_path)| {
463 worktree.read(cx).entry_for_path(&relative_path)
464 })
465 .is_some_and(|entry| entry.is_dir())
466 })
467 }
468
469 fn find_venv_on_filesystem(
470 &self,
471 abs_path: &Path,
472 venv_settings: &terminal_settings::VenvSettingsContent,
473 cx: &App,
474 ) -> Option<PathBuf> {
475 let (worktree, _) = self.find_worktree(abs_path, cx)?;
476 let fs = worktree.read(cx).as_local()?.fs();
477 let bin_dir_name = match std::env::consts::OS {
478 "windows" => "Scripts",
479 _ => "bin",
480 };
481 venv_settings
482 .directories
483 .iter()
484 .map(|name| abs_path.join(name))
485 .find(|venv_path| {
486 let bin_path = venv_path.join(bin_dir_name);
487 // One-time synchronous check is acceptable for terminal/task initialization
488 smol::block_on(fs.metadata(&bin_path))
489 .ok()
490 .flatten()
491 .map_or(false, |meta| meta.is_dir)
492 })
493 }
494
495 fn python_activate_command(
496 &self,
497 venv_base_directory: &Path,
498 venv_settings: &VenvSettings,
499 ) -> Option<String> {
500 let venv_settings = venv_settings.as_option()?;
501 let activate_keyword = match venv_settings.activate_script {
502 terminal_settings::ActivateScript::Default => match std::env::consts::OS {
503 "windows" => ".",
504 _ => "source",
505 },
506 terminal_settings::ActivateScript::Nushell => "overlay use",
507 terminal_settings::ActivateScript::PowerShell => ".",
508 _ => "source",
509 };
510 let activate_script_name = match venv_settings.activate_script {
511 terminal_settings::ActivateScript::Default => "activate",
512 terminal_settings::ActivateScript::Csh => "activate.csh",
513 terminal_settings::ActivateScript::Fish => "activate.fish",
514 terminal_settings::ActivateScript::Nushell => "activate.nu",
515 terminal_settings::ActivateScript::PowerShell => "activate.ps1",
516 };
517 let path = venv_base_directory
518 .join(match std::env::consts::OS {
519 "windows" => "Scripts",
520 _ => "bin",
521 })
522 .join(activate_script_name)
523 .to_string_lossy()
524 .to_string();
525 let quoted = shlex::try_quote(&path).ok()?;
526 let line_ending = match std::env::consts::OS {
527 "windows" => "\r",
528 _ => "\n",
529 };
530 smol::block_on(self.fs.metadata(path.as_ref()))
531 .ok()
532 .flatten()?;
533
534 Some(format!(
535 "{} {} ; clear{}",
536 activate_keyword, quoted, line_ending
537 ))
538 }
539
540 fn activate_python_virtual_environment(
541 &self,
542 command: String,
543 terminal_handle: &Entity<Terminal>,
544 cx: &mut App,
545 ) {
546 terminal_handle.update(cx, |terminal, _| terminal.input_bytes(command.into_bytes()));
547 }
548
549 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
550 &self.terminals.local_handles
551 }
552}
553
554fn wrap_for_ssh(
555 ssh_command: &SshCommand,
556 command: Option<(&String, &Vec<String>)>,
557 path: Option<&Path>,
558 env: HashMap<String, String>,
559 venv_directory: Option<&Path>,
560) -> (String, Vec<String>) {
561 let to_run = if let Some((command, args)) = command {
562 let command = Cow::Borrowed(command.as_str());
563 let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
564 iter::once(command).chain(args).join(" ")
565 } else {
566 "exec ${SHELL:-sh} -l".to_string()
567 };
568
569 let mut env_changes = String::new();
570 for (k, v) in env.iter() {
571 if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
572 env_changes.push_str(&format!("{}={} ", k, v));
573 }
574 }
575 if let Some(venv_directory) = venv_directory {
576 if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
577 env_changes.push_str(&format!("PATH={}:$PATH ", str));
578 }
579 }
580
581 let commands = if let Some(path) = path {
582 let path_string = path.to_string_lossy().to_string();
583 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
584 // replace ith with something that works
585 let tilde_prefix = "~/";
586 if path.starts_with(tilde_prefix) {
587 let trimmed_path = path_string
588 .trim_start_matches("/")
589 .trim_start_matches("~")
590 .trim_start_matches("/");
591
592 format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
593 } else {
594 format!("cd {path:?}; {env_changes} {to_run}")
595 }
596 } else {
597 format!("cd; {env_changes} {to_run}")
598 };
599 let shell_invocation = format!("sh -c {}", shlex::try_quote(&commands).unwrap());
600
601 let program = "ssh".to_string();
602 let mut args = ssh_command.arguments.clone();
603
604 args.push("-t".to_string());
605 args.push(shell_invocation);
606 (program, args)
607}
608
609fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
610 let mut env_paths = vec![new_path.to_path_buf()];
611 if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
612 let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
613 env_paths.append(&mut paths);
614 }
615
616 let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
617 env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
618
619 Ok(())
620}
621
622#[cfg(test)]
623mod tests {
624 use collections::HashMap;
625
626 #[test]
627 fn test_add_environment_path_with_existing_path() {
628 let tmp_path = std::path::PathBuf::from("/tmp/new");
629 let mut env = HashMap::default();
630 let old_path = if cfg!(windows) {
631 "/usr/bin;/usr/local/bin"
632 } else {
633 "/usr/bin:/usr/local/bin"
634 };
635 env.insert("PATH".to_string(), old_path.to_string());
636 env.insert("OTHER".to_string(), "aaa".to_string());
637
638 super::add_environment_path(&mut env, &tmp_path).unwrap();
639 if cfg!(windows) {
640 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
641 } else {
642 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
643 }
644 assert_eq!(env.get("OTHER").unwrap(), "aaa");
645 }
646
647 #[test]
648 fn test_add_environment_path_with_empty_path() {
649 let tmp_path = std::path::PathBuf::from("/tmp/new");
650 let mut env = HashMap::default();
651 env.insert("OTHER".to_string(), "aaa".to_string());
652 let os_path = std::env::var("PATH").unwrap();
653 super::add_environment_path(&mut env, &tmp_path).unwrap();
654 if cfg!(windows) {
655 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
656 } else {
657 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
658 }
659 assert_eq!(env.get("OTHER").unwrap(), "aaa");
660 }
661}