1use anyhow::Result;
2use collections::HashMap;
3use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
4
5use futures::{FutureExt, future::Shared};
6use itertools::Itertools as _;
7use language::LanguageName;
8use remote::RemoteClient;
9use settings::{Settings, SettingsLocation};
10use smol::channel::bounded;
11use std::{
12 path::{Path, PathBuf},
13 sync::Arc,
14};
15use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
16use terminal::{
17 TaskState, TaskStatus, Terminal, TerminalBuilder, insert_zed_terminal_env,
18 terminal_settings::TerminalSettings,
19};
20use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath};
21
22use crate::{Project, ProjectPath};
23
24pub struct Terminals {
25 pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
26}
27
28impl Project {
29 pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
30 self.active_entry()
31 .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
32 .into_iter()
33 .chain(self.worktrees(cx))
34 .find_map(|tree| tree.read(cx).root_dir())
35 }
36
37 pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
38 let worktree = self.worktrees(cx).next()?;
39 let worktree = worktree.read(cx);
40 if worktree.root_entry()?.is_dir() {
41 Some(worktree.abs_path().to_path_buf())
42 } else {
43 None
44 }
45 }
46
47 pub fn create_terminal_task(
48 &mut self,
49 spawn_task: SpawnInTerminal,
50 cx: &mut Context<Self>,
51 ) -> Task<Result<Entity<Terminal>>> {
52 let is_via_remote = self.remote_client.is_some();
53
54 let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
55 if is_via_remote {
56 Some(Arc::from(cwd.as_ref()))
57 } else {
58 let cwd = cwd.to_string_lossy();
59 let tilde_substituted = shellexpand::tilde(&cwd);
60 Some(Arc::from(Path::new(tilde_substituted.as_ref())))
61 }
62 } else {
63 self.active_project_directory(cx)
64 };
65
66 let mut settings_location = None;
67 if let Some(path) = path.as_ref()
68 && let Some((worktree, _)) = self.find_worktree(path, cx)
69 {
70 settings_location = Some(SettingsLocation {
71 worktree_id: worktree.read(cx).id(),
72 path: RelPath::empty(),
73 });
74 }
75 let settings = TerminalSettings::get(settings_location, cx).clone();
76 let detect_venv = settings.detect_venv.as_option().is_some();
77
78 let (completion_tx, completion_rx) = bounded(1);
79
80 let local_path = if is_via_remote { None } else { path.clone() };
81 let task_state = Some(TaskState {
82 spawned_task: spawn_task.clone(),
83 status: TaskStatus::Running,
84 completion_rx,
85 });
86 let remote_client = self.remote_client.clone();
87 let shell = match &remote_client {
88 Some(remote_client) => remote_client
89 .read(cx)
90 .shell()
91 .unwrap_or_else(get_default_system_shell),
92 None => settings.shell.program(),
93 };
94 let path_style = self.path_style(cx);
95 let shell_kind = ShellKind::new(&shell, path_style.is_windows());
96
97 // Prepare a task for resolving the environment
98 let env_task =
99 self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
100
101 let project_path_contexts = self
102 .active_entry()
103 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
104 .into_iter()
105 .chain(
106 self.visible_worktrees(cx)
107 .map(|wt| wt.read(cx).id())
108 .map(|worktree_id| ProjectPath {
109 worktree_id,
110 path: Arc::from(RelPath::empty()),
111 }),
112 );
113 let toolchains = project_path_contexts
114 .filter(|_| detect_venv)
115 .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
116 .collect::<Vec<_>>();
117 let lang_registry = self.languages.clone();
118 cx.spawn(async move |project, cx| {
119 let mut env = env_task.await.unwrap_or_default();
120 env.extend(settings.env);
121
122 let activation_script = maybe!(async {
123 for toolchain in toolchains {
124 let Some(toolchain) = toolchain.await else {
125 continue;
126 };
127 let language = lang_registry
128 .language_for_name(&toolchain.language_name.0)
129 .await
130 .ok();
131 let lister = language?.toolchain_lister()?;
132 let future =
133 cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx));
134 return Some(future.await);
135 }
136 None
137 })
138 .await
139 .unwrap_or_default();
140
141 let builder = project
142 .update(cx, move |_, cx| {
143 let format_to_run = || {
144 if let Some(command) = &spawn_task.command {
145 let command = shell_kind.prepend_command_prefix(command);
146 let command = shell_kind.try_quote_prefix_aware(&command);
147 let args = spawn_task
148 .args
149 .iter()
150 .filter_map(|arg| shell_kind.try_quote(&arg));
151
152 command.into_iter().chain(args).join(" ")
153 } else {
154 // todo: this breaks for remotes to windows
155 format!("exec {shell} -l")
156 }
157 };
158
159 let (shell, env) = {
160 env.extend(spawn_task.env);
161 match remote_client {
162 Some(remote_client) => match activation_script.clone() {
163 activation_script if !activation_script.is_empty() => {
164 let separator = shell_kind.sequential_commands_separator();
165 let activation_script =
166 activation_script.join(&format!("{separator} "));
167 let to_run = format_to_run();
168
169 let arg = format!("{activation_script}{separator} {to_run}");
170 let args = shell_kind.args_for_shell(false, arg);
171 let shell = remote_client
172 .read(cx)
173 .shell()
174 .unwrap_or_else(get_default_system_shell);
175
176 create_remote_shell(
177 Some((&shell, &args)),
178 env,
179 path,
180 remote_client,
181 cx,
182 )?
183 }
184 _ => create_remote_shell(
185 spawn_task
186 .command
187 .as_ref()
188 .map(|command| (command, &spawn_task.args)),
189 env,
190 path,
191 remote_client,
192 cx,
193 )?,
194 },
195 None => match activation_script.clone() {
196 activation_script if !activation_script.is_empty() => {
197 let separator = shell_kind.sequential_commands_separator();
198 let activation_script =
199 activation_script.join(&format!("{separator} "));
200 let to_run = format_to_run();
201
202 let mut arg =
203 format!("{activation_script}{separator} {to_run}");
204 if shell_kind == ShellKind::Cmd {
205 // We need to put the entire command in quotes since otherwise CMD tries to execute them
206 // as separate commands rather than chaining one after another.
207 arg = format!("\"{arg}\"");
208 }
209
210 let args = shell_kind.args_for_shell(false, arg);
211
212 (
213 Shell::WithArguments {
214 program: shell,
215 args,
216 title_override: None,
217 },
218 env,
219 )
220 }
221 _ => (
222 if let Some(program) = spawn_task.command {
223 Shell::WithArguments {
224 program,
225 args: spawn_task.args,
226 title_override: None,
227 }
228 } else {
229 Shell::System
230 },
231 env,
232 ),
233 },
234 }
235 };
236 anyhow::Ok(TerminalBuilder::new(
237 local_path.map(|path| path.to_path_buf()),
238 task_state,
239 shell,
240 env,
241 settings.cursor_shape,
242 settings.alternate_scroll,
243 settings.max_scroll_history_lines,
244 settings.path_hyperlink_regexes,
245 settings.path_hyperlink_timeout_ms,
246 is_via_remote,
247 cx.entity_id().as_u64(),
248 Some(completion_tx),
249 cx,
250 activation_script,
251 path_style,
252 ))
253 })??
254 .await?;
255 project.update(cx, move |this, cx| {
256 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
257
258 this.terminals
259 .local_handles
260 .push(terminal_handle.downgrade());
261
262 let id = terminal_handle.entity_id();
263 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
264 let handles = &mut project.terminals.local_handles;
265
266 if let Some(index) = handles
267 .iter()
268 .position(|terminal| terminal.entity_id() == id)
269 {
270 handles.remove(index);
271 cx.notify();
272 }
273 })
274 .detach();
275
276 terminal_handle
277 })
278 })
279 }
280
281 pub fn create_terminal_shell(
282 &mut self,
283 cwd: Option<PathBuf>,
284 cx: &mut Context<Self>,
285 ) -> Task<Result<Entity<Terminal>>> {
286 self.create_terminal_shell_internal(cwd, false, cx)
287 }
288
289 /// Creates a local terminal even if the project is remote.
290 /// In remote projects: opens in Zed's launch directory (bypasses SSH).
291 /// In local projects: opens in the project directory (same as regular terminals).
292 pub fn create_local_terminal(
293 &mut self,
294 cx: &mut Context<Self>,
295 ) -> Task<Result<Entity<Terminal>>> {
296 let working_directory = if self.remote_client.is_some() {
297 // Remote project: don't use remote paths, let shell use Zed's cwd
298 None
299 } else {
300 // Local project: use project directory like normal terminals
301 self.active_project_directory(cx).map(|p| p.to_path_buf())
302 };
303 self.create_terminal_shell_internal(working_directory, true, cx)
304 }
305
306 /// Internal method for creating terminal shells.
307 /// If force_local is true, creates a local terminal even if the project has a remote client.
308 /// This allows "breaking out" to a local shell in remote projects.
309 fn create_terminal_shell_internal(
310 &mut self,
311 cwd: Option<PathBuf>,
312 force_local: bool,
313 cx: &mut Context<Self>,
314 ) -> Task<Result<Entity<Terminal>>> {
315 let path = cwd.map(|p| Arc::from(&*p));
316 let is_via_remote = !force_local && self.remote_client.is_some();
317
318 let mut settings_location = None;
319 if let Some(path) = path.as_ref()
320 && let Some((worktree, _)) = self.find_worktree(path, cx)
321 {
322 settings_location = Some(SettingsLocation {
323 worktree_id: worktree.read(cx).id(),
324 path: RelPath::empty(),
325 });
326 }
327 let settings = TerminalSettings::get(settings_location, cx).clone();
328 let detect_venv = settings.detect_venv.as_option().is_some();
329 let local_path = if is_via_remote { None } else { path.clone() };
330
331 let project_path_contexts = self
332 .active_entry()
333 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
334 .into_iter()
335 .chain(
336 self.visible_worktrees(cx)
337 .map(|wt| wt.read(cx).id())
338 .map(|worktree_id| ProjectPath {
339 worktree_id,
340 path: RelPath::empty().into(),
341 }),
342 );
343 let toolchains = project_path_contexts
344 .filter(|_| detect_venv)
345 .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
346 .collect::<Vec<_>>();
347 let remote_client = if force_local {
348 None
349 } else {
350 self.remote_client.clone()
351 };
352 let shell = match &remote_client {
353 Some(remote_client) => remote_client
354 .read(cx)
355 .shell()
356 .unwrap_or_else(get_default_system_shell),
357 None => settings.shell.program(),
358 };
359
360 let path_style = self.path_style(cx);
361
362 // Prepare a task for resolving the environment
363 let env_task =
364 self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
365
366 let lang_registry = self.languages.clone();
367 cx.spawn(async move |project, cx| {
368 let shell_kind = ShellKind::new(&shell, path_style.is_windows());
369 let mut env = env_task.await.unwrap_or_default();
370 env.extend(settings.env);
371
372 let activation_script = maybe!(async {
373 for toolchain in toolchains {
374 let Some(toolchain) = toolchain.await else {
375 continue;
376 };
377 let language = lang_registry
378 .language_for_name(&toolchain.language_name.0)
379 .await
380 .ok();
381 let lister = language?.toolchain_lister()?;
382 let future =
383 cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx));
384 return Some(future.await);
385 }
386 None
387 })
388 .await
389 .unwrap_or_default();
390
391 let builder = project
392 .update(cx, move |_, cx| {
393 let (shell, env) = {
394 match remote_client {
395 Some(remote_client) => {
396 create_remote_shell(None, env, path, remote_client, cx)?
397 }
398 None => (settings.shell, env),
399 }
400 };
401 anyhow::Ok(TerminalBuilder::new(
402 local_path.map(|path| path.to_path_buf()),
403 None,
404 shell,
405 env,
406 settings.cursor_shape,
407 settings.alternate_scroll,
408 settings.max_scroll_history_lines,
409 settings.path_hyperlink_regexes,
410 settings.path_hyperlink_timeout_ms,
411 is_via_remote,
412 cx.entity_id().as_u64(),
413 None,
414 cx,
415 activation_script,
416 path_style,
417 ))
418 })??
419 .await?;
420 project.update(cx, move |this, cx| {
421 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
422
423 this.terminals
424 .local_handles
425 .push(terminal_handle.downgrade());
426
427 let id = terminal_handle.entity_id();
428 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
429 let handles = &mut project.terminals.local_handles;
430
431 if let Some(index) = handles
432 .iter()
433 .position(|terminal| terminal.entity_id() == id)
434 {
435 handles.remove(index);
436 cx.notify();
437 }
438 })
439 .detach();
440
441 terminal_handle
442 })
443 })
444 }
445
446 pub fn clone_terminal(
447 &mut self,
448 terminal: &Entity<Terminal>,
449 cx: &mut Context<'_, Project>,
450 cwd: Option<PathBuf>,
451 ) -> Task<Result<Entity<Terminal>>> {
452 // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
453 // For now, create a new shell instead.
454 if terminal.read(cx).task().is_some() {
455 return self.create_terminal_shell(cwd, cx);
456 }
457 let local_path = if self.is_via_remote_server() {
458 None
459 } else {
460 cwd
461 };
462
463 let builder = terminal.read(cx).clone_builder(cx, local_path);
464 cx.spawn(async |project, cx| {
465 let terminal = builder.await?;
466 project.update(cx, |project, cx| {
467 let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
468
469 project
470 .terminals
471 .local_handles
472 .push(terminal_handle.downgrade());
473
474 let id = terminal_handle.entity_id();
475 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
476 let handles = &mut project.terminals.local_handles;
477
478 if let Some(index) = handles
479 .iter()
480 .position(|terminal| terminal.entity_id() == id)
481 {
482 handles.remove(index);
483 cx.notify();
484 }
485 })
486 .detach();
487
488 terminal_handle
489 })
490 })
491 }
492
493 pub fn terminal_settings<'a>(
494 &'a self,
495 path: &'a Option<PathBuf>,
496 cx: &'a App,
497 ) -> &'a TerminalSettings {
498 let mut settings_location = None;
499 if let Some(path) = path.as_ref()
500 && let Some((worktree, _)) = self.find_worktree(path, cx)
501 {
502 settings_location = Some(SettingsLocation {
503 worktree_id: worktree.read(cx).id(),
504 path: RelPath::empty(),
505 });
506 }
507 TerminalSettings::get(settings_location, cx)
508 }
509
510 pub fn exec_in_shell(
511 &self,
512 command: String,
513 cx: &mut Context<Self>,
514 ) -> Task<Result<smol::process::Command>> {
515 let path = self.first_project_directory(cx);
516 let remote_client = self.remote_client.clone();
517 let settings = self.terminal_settings(&path, cx).clone();
518 let shell = remote_client
519 .as_ref()
520 .and_then(|remote_client| remote_client.read(cx).shell())
521 .map(Shell::Program)
522 .unwrap_or_else(|| settings.shell.clone());
523 let is_windows = self.path_style(cx).is_windows();
524 let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
525 let (command, args) = builder.build(Some(command), &Vec::new());
526
527 let env_task = self.resolve_directory_environment(
528 &shell.program(),
529 path.as_ref().map(|p| Arc::from(&**p)),
530 remote_client.clone(),
531 cx,
532 );
533
534 cx.spawn(async move |project, cx| {
535 let mut env = env_task.await.unwrap_or_default();
536 env.extend(settings.env);
537
538 project.update(cx, move |_, cx| {
539 match remote_client {
540 Some(remote_client) => {
541 let command_template = remote_client.read(cx).build_command(
542 Some(command),
543 &args,
544 &env,
545 None,
546 None,
547 )?;
548 let mut command = new_std_command(command_template.program);
549 command.args(command_template.args);
550 command.envs(command_template.env);
551 Ok(command)
552 }
553 None => {
554 let mut command = new_std_command(command);
555 command.args(args);
556 command.envs(env);
557 if let Some(path) = path {
558 command.current_dir(path);
559 }
560 Ok(command)
561 }
562 }
563 .map(|mut process| {
564 util::set_pre_exec_to_start_new_session(&mut process);
565 smol::process::Command::from(process)
566 })
567 })?
568 })
569 }
570
571 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
572 &self.terminals.local_handles
573 }
574
575 fn resolve_directory_environment(
576 &self,
577 shell: &str,
578 path: Option<Arc<Path>>,
579 remote_client: Option<Entity<RemoteClient>>,
580 cx: &mut App,
581 ) -> Shared<Task<Option<HashMap<String, String>>>> {
582 if let Some(path) = &path {
583 let shell = Shell::Program(shell.to_string());
584 self.environment
585 .update(cx, |project_env, cx| match &remote_client {
586 Some(remote_client) => project_env.remote_directory_environment(
587 &shell,
588 path.clone(),
589 remote_client.clone(),
590 cx,
591 ),
592 None => project_env.local_directory_environment(&shell, path.clone(), cx),
593 })
594 } else {
595 Task::ready(None).shared()
596 }
597 }
598}
599
600fn create_remote_shell(
601 spawn_command: Option<(&String, &Vec<String>)>,
602 mut env: HashMap<String, String>,
603 working_directory: Option<Arc<Path>>,
604 remote_client: Entity<RemoteClient>,
605 cx: &mut App,
606) -> Result<(Shell, HashMap<String, String>)> {
607 insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
608
609 let (program, args) = match spawn_command {
610 Some((program, args)) => (Some(program.clone()), args),
611 None => (None, &Vec::new()),
612 };
613
614 let command = remote_client.read(cx).build_command(
615 program,
616 args.as_slice(),
617 &env,
618 working_directory.map(|path| path.display().to_string()),
619 None,
620 )?;
621
622 log::debug!("Connecting to a remote server: {:?}", command.program);
623 let host = remote_client.read(cx).connection_options().display_name();
624
625 Ok((
626 Shell::WithArguments {
627 program: command.program,
628 args: command.args,
629 title_override: Some(format!("{} — Terminal", host)),
630 },
631 command.env,
632 ))
633}