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