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 if !sandbox.enabled.unwrap_or(false) {
418 return None;
419 }
420 let apply_to = sandbox.apply_to.unwrap_or_default();
421 match apply_to {
422 settings::SandboxApplyTo::Terminal | settings::SandboxApplyTo::Both => {
423 }
424 _ => return None,
425 }
426 let project_dir = local_path
427 .as_ref()
428 .map(|p| p.to_path_buf())
429 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
430 Some(terminal::terminal_settings::SandboxConfig::from_settings(
431 sandbox,
432 project_dir,
433 ))
434 });
435
436 anyhow::Ok(TerminalBuilder::new(
437 local_path.map(|path| path.to_path_buf()),
438 None,
439 shell,
440 env,
441 settings.cursor_shape,
442 settings.alternate_scroll,
443 settings.max_scroll_history_lines,
444 settings.path_hyperlink_regexes,
445 settings.path_hyperlink_timeout_ms,
446 is_via_remote,
447 cx.entity_id().as_u64(),
448 None,
449 cx,
450 activation_script,
451 path_style,
452 sandbox_config,
453 ))
454 })??
455 .await?;
456 project.update(cx, move |this, cx| {
457 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
458
459 this.terminals
460 .local_handles
461 .push(terminal_handle.downgrade());
462
463 let id = terminal_handle.entity_id();
464 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
465 let handles = &mut project.terminals.local_handles;
466
467 if let Some(index) = handles
468 .iter()
469 .position(|terminal| terminal.entity_id() == id)
470 {
471 handles.remove(index);
472 cx.notify();
473 }
474 })
475 .detach();
476
477 terminal_handle
478 })
479 })
480 }
481
482 pub fn clone_terminal(
483 &mut self,
484 terminal: &Entity<Terminal>,
485 cx: &mut Context<'_, Project>,
486 cwd: Option<PathBuf>,
487 ) -> Task<Result<Entity<Terminal>>> {
488 // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
489 // For now, create a new shell instead.
490 if terminal.read(cx).task().is_some() {
491 return self.create_terminal_shell(cwd, cx);
492 }
493 let local_path = if self.is_via_remote_server() {
494 None
495 } else {
496 cwd
497 };
498
499 let builder = terminal.read(cx).clone_builder(cx, local_path);
500 cx.spawn(async |project, cx| {
501 let terminal = builder.await?;
502 project.update(cx, |project, cx| {
503 let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
504
505 project
506 .terminals
507 .local_handles
508 .push(terminal_handle.downgrade());
509
510 let id = terminal_handle.entity_id();
511 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
512 let handles = &mut project.terminals.local_handles;
513
514 if let Some(index) = handles
515 .iter()
516 .position(|terminal| terminal.entity_id() == id)
517 {
518 handles.remove(index);
519 cx.notify();
520 }
521 })
522 .detach();
523
524 terminal_handle
525 })
526 })
527 }
528
529 pub fn terminal_settings<'a>(
530 &'a self,
531 path: &'a Option<PathBuf>,
532 cx: &'a App,
533 ) -> &'a TerminalSettings {
534 let mut settings_location = None;
535 if let Some(path) = path.as_ref()
536 && let Some((worktree, _)) = self.find_worktree(path, cx)
537 {
538 settings_location = Some(SettingsLocation {
539 worktree_id: worktree.read(cx).id(),
540 path: RelPath::empty(),
541 });
542 }
543 TerminalSettings::get(settings_location, cx)
544 }
545
546 pub fn exec_in_shell(
547 &self,
548 command: String,
549 cx: &mut Context<Self>,
550 ) -> Task<Result<smol::process::Command>> {
551 let path = self.first_project_directory(cx);
552 let remote_client = self.remote_client.clone();
553 let settings = self.terminal_settings(&path, cx).clone();
554 let shell = remote_client
555 .as_ref()
556 .and_then(|remote_client| remote_client.read(cx).shell())
557 .map(Shell::Program)
558 .unwrap_or_else(|| settings.shell.clone());
559 let is_windows = self.path_style(cx).is_windows();
560 let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
561 let (command, args) = builder.build(Some(command), &Vec::new());
562
563 let env_task = self.resolve_directory_environment(
564 &shell.program(),
565 path.as_ref().map(|p| Arc::from(&**p)),
566 remote_client.clone(),
567 cx,
568 );
569
570 cx.spawn(async move |project, cx| {
571 let mut env = env_task.await.unwrap_or_default();
572 env.extend(settings.env);
573
574 project.update(cx, move |_, cx| {
575 match remote_client {
576 Some(remote_client) => {
577 let command_template = remote_client.read(cx).build_command(
578 Some(command),
579 &args,
580 &env,
581 None,
582 None,
583 )?;
584 let mut command = new_std_command(command_template.program);
585 command.args(command_template.args);
586 command.envs(command_template.env);
587 Ok(command)
588 }
589 None => {
590 let mut command = new_std_command(command);
591 command.args(args);
592 command.envs(env);
593 if let Some(path) = path {
594 command.current_dir(path);
595 }
596 Ok(command)
597 }
598 }
599 .map(|mut process| {
600 util::set_pre_exec_to_start_new_session(&mut process);
601 smol::process::Command::from(process)
602 })
603 })?
604 })
605 }
606
607 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
608 &self.terminals.local_handles
609 }
610
611 fn resolve_directory_environment(
612 &self,
613 shell: &str,
614 path: Option<Arc<Path>>,
615 remote_client: Option<Entity<RemoteClient>>,
616 cx: &mut App,
617 ) -> Shared<Task<Option<HashMap<String, String>>>> {
618 if let Some(path) = &path {
619 let shell = Shell::Program(shell.to_string());
620 self.environment
621 .update(cx, |project_env, cx| match &remote_client {
622 Some(remote_client) => project_env.remote_directory_environment(
623 &shell,
624 path.clone(),
625 remote_client.clone(),
626 cx,
627 ),
628 None => project_env.local_directory_environment(&shell, path.clone(), cx),
629 })
630 } else {
631 Task::ready(None).shared()
632 }
633 }
634}
635
636fn create_remote_shell(
637 spawn_command: Option<(&String, &Vec<String>)>,
638 mut env: HashMap<String, String>,
639 working_directory: Option<Arc<Path>>,
640 remote_client: Entity<RemoteClient>,
641 cx: &mut App,
642) -> Result<(Shell, HashMap<String, String>)> {
643 insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
644
645 let (program, args) = match spawn_command {
646 Some((program, args)) => (Some(program.clone()), args),
647 None => (None, &Vec::new()),
648 };
649
650 let command = remote_client.read(cx).build_command(
651 program,
652 args.as_slice(),
653 &env,
654 working_directory.map(|path| path.display().to_string()),
655 None,
656 )?;
657
658 log::debug!("Connecting to a remote server: {:?}", command.program);
659 let host = remote_client.read(cx).connection_options().display_name();
660
661 Ok((
662 Shell::WithArguments {
663 program: command.program,
664 args: command.args,
665 title_override: Some(format!("{} — Terminal", host)),
666 },
667 command.env,
668 ))
669}