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