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 is_windows = self.path_style(cx).is_windows();
95 let shell_kind = ShellKind::new(&shell, 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 return Some(
133 cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx)),
134 );
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 ))
252 })??
253 .await?;
254 project.update(cx, move |this, cx| {
255 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
256
257 this.terminals
258 .local_handles
259 .push(terminal_handle.downgrade());
260
261 let id = terminal_handle.entity_id();
262 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
263 let handles = &mut project.terminals.local_handles;
264
265 if let Some(index) = handles
266 .iter()
267 .position(|terminal| terminal.entity_id() == id)
268 {
269 handles.remove(index);
270 cx.notify();
271 }
272 })
273 .detach();
274
275 terminal_handle
276 })
277 })
278 }
279
280 pub fn create_terminal_shell(
281 &mut self,
282 cwd: Option<PathBuf>,
283 cx: &mut Context<Self>,
284 ) -> Task<Result<Entity<Terminal>>> {
285 self.create_terminal_shell_internal(cwd, false, cx)
286 }
287
288 /// Creates a local terminal even if the project is remote.
289 /// In remote projects: opens in Zed's launch directory (bypasses SSH).
290 /// In local projects: opens in the project directory (same as regular terminals).
291 pub fn create_local_terminal(
292 &mut self,
293 cx: &mut Context<Self>,
294 ) -> Task<Result<Entity<Terminal>>> {
295 let working_directory = if self.remote_client.is_some() {
296 // Remote project: don't use remote paths, let shell use Zed's cwd
297 None
298 } else {
299 // Local project: use project directory like normal terminals
300 self.active_project_directory(cx).map(|p| p.to_path_buf())
301 };
302 self.create_terminal_shell_internal(working_directory, true, cx)
303 }
304
305 /// Internal method for creating terminal shells.
306 /// If force_local is true, creates a local terminal even if the project has a remote client.
307 /// This allows "breaking out" to a local shell in remote projects.
308 fn create_terminal_shell_internal(
309 &mut self,
310 cwd: Option<PathBuf>,
311 force_local: bool,
312 cx: &mut Context<Self>,
313 ) -> Task<Result<Entity<Terminal>>> {
314 let path = cwd.map(|p| Arc::from(&*p));
315 let is_via_remote = !force_local && self.remote_client.is_some();
316
317 let mut settings_location = None;
318 if let Some(path) = path.as_ref()
319 && let Some((worktree, _)) = self.find_worktree(path, cx)
320 {
321 settings_location = Some(SettingsLocation {
322 worktree_id: worktree.read(cx).id(),
323 path: RelPath::empty(),
324 });
325 }
326 let settings = TerminalSettings::get(settings_location, cx).clone();
327 let detect_venv = settings.detect_venv.as_option().is_some();
328 let local_path = if is_via_remote { None } else { path.clone() };
329
330 let project_path_contexts = self
331 .active_entry()
332 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
333 .into_iter()
334 .chain(
335 self.visible_worktrees(cx)
336 .map(|wt| wt.read(cx).id())
337 .map(|worktree_id| ProjectPath {
338 worktree_id,
339 path: RelPath::empty().into(),
340 }),
341 );
342 let toolchains = project_path_contexts
343 .filter(|_| detect_venv)
344 .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx))
345 .collect::<Vec<_>>();
346 let remote_client = if force_local {
347 None
348 } else {
349 self.remote_client.clone()
350 };
351 let shell = match &remote_client {
352 Some(remote_client) => remote_client
353 .read(cx)
354 .shell()
355 .unwrap_or_else(get_default_system_shell),
356 None => settings.shell.program(),
357 };
358
359 let is_windows = self.path_style(cx).is_windows();
360
361 // Prepare a task for resolving the environment
362 let env_task =
363 self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
364
365 let lang_registry = self.languages.clone();
366 cx.spawn(async move |project, cx| {
367 let shell_kind = ShellKind::new(&shell, is_windows);
368 let mut env = env_task.await.unwrap_or_default();
369 env.extend(settings.env);
370
371 let activation_script = maybe!(async {
372 for toolchain in toolchains {
373 let Some(toolchain) = toolchain.await else {
374 continue;
375 };
376 let language = lang_registry
377 .language_for_name(&toolchain.language_name.0)
378 .await
379 .ok();
380 let lister = language?.toolchain_lister()?;
381 return Some(
382 cx.update(|cx| lister.activation_script(&toolchain, shell_kind, cx)),
383 );
384 }
385 None
386 })
387 .await
388 .unwrap_or_default();
389
390 let builder = project
391 .update(cx, move |_, cx| {
392 let (shell, env) = {
393 match remote_client {
394 Some(remote_client) => {
395 create_remote_shell(None, env, path, remote_client, cx)?
396 }
397 None => (settings.shell, env),
398 }
399 };
400 anyhow::Ok(TerminalBuilder::new(
401 local_path.map(|path| path.to_path_buf()),
402 None,
403 shell,
404 env,
405 settings.cursor_shape,
406 settings.alternate_scroll,
407 settings.max_scroll_history_lines,
408 settings.path_hyperlink_regexes,
409 settings.path_hyperlink_timeout_ms,
410 is_via_remote,
411 cx.entity_id().as_u64(),
412 None,
413 cx,
414 activation_script,
415 ))
416 })??
417 .await?;
418 project.update(cx, move |this, cx| {
419 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
420
421 this.terminals
422 .local_handles
423 .push(terminal_handle.downgrade());
424
425 let id = terminal_handle.entity_id();
426 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
427 let handles = &mut project.terminals.local_handles;
428
429 if let Some(index) = handles
430 .iter()
431 .position(|terminal| terminal.entity_id() == id)
432 {
433 handles.remove(index);
434 cx.notify();
435 }
436 })
437 .detach();
438
439 terminal_handle
440 })
441 })
442 }
443
444 pub fn clone_terminal(
445 &mut self,
446 terminal: &Entity<Terminal>,
447 cx: &mut Context<'_, Project>,
448 cwd: Option<PathBuf>,
449 ) -> Task<Result<Entity<Terminal>>> {
450 // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
451 // For now, create a new shell instead.
452 if terminal.read(cx).task().is_some() {
453 return self.create_terminal_shell(cwd, cx);
454 }
455 let local_path = if self.is_via_remote_server() {
456 None
457 } else {
458 cwd
459 };
460
461 let builder = terminal.read(cx).clone_builder(cx, local_path);
462 cx.spawn(async |project, cx| {
463 let terminal = builder.await?;
464 project.update(cx, |project, cx| {
465 let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
466
467 project
468 .terminals
469 .local_handles
470 .push(terminal_handle.downgrade());
471
472 let id = terminal_handle.entity_id();
473 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
474 let handles = &mut project.terminals.local_handles;
475
476 if let Some(index) = handles
477 .iter()
478 .position(|terminal| terminal.entity_id() == id)
479 {
480 handles.remove(index);
481 cx.notify();
482 }
483 })
484 .detach();
485
486 terminal_handle
487 })
488 })
489 }
490
491 pub fn terminal_settings<'a>(
492 &'a self,
493 path: &'a Option<PathBuf>,
494 cx: &'a App,
495 ) -> &'a TerminalSettings {
496 let mut settings_location = None;
497 if let Some(path) = path.as_ref()
498 && let Some((worktree, _)) = self.find_worktree(path, cx)
499 {
500 settings_location = Some(SettingsLocation {
501 worktree_id: worktree.read(cx).id(),
502 path: RelPath::empty(),
503 });
504 }
505 TerminalSettings::get(settings_location, cx)
506 }
507
508 pub fn exec_in_shell(
509 &self,
510 command: String,
511 cx: &mut Context<Self>,
512 ) -> Task<Result<smol::process::Command>> {
513 let path = self.first_project_directory(cx);
514 let remote_client = self.remote_client.clone();
515 let settings = self.terminal_settings(&path, cx).clone();
516 let shell = remote_client
517 .as_ref()
518 .and_then(|remote_client| remote_client.read(cx).shell())
519 .map(Shell::Program)
520 .unwrap_or_else(|| settings.shell.clone());
521 let is_windows = self.path_style(cx).is_windows();
522 let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
523 let (command, args) = builder.build(Some(command), &Vec::new());
524
525 let env_task = self.resolve_directory_environment(
526 &shell.program(),
527 path.as_ref().map(|p| Arc::from(&**p)),
528 remote_client.clone(),
529 cx,
530 );
531
532 cx.spawn(async move |project, cx| {
533 let mut env = env_task.await.unwrap_or_default();
534 env.extend(settings.env);
535
536 project.update(cx, move |_, cx| {
537 match remote_client {
538 Some(remote_client) => {
539 let command_template = remote_client.read(cx).build_command(
540 Some(command),
541 &args,
542 &env,
543 None,
544 None,
545 )?;
546 let mut command = new_std_command(command_template.program);
547 command.args(command_template.args);
548 command.envs(command_template.env);
549 Ok(command)
550 }
551 None => {
552 let mut command = new_std_command(command);
553 command.args(args);
554 command.envs(env);
555 if let Some(path) = path {
556 command.current_dir(path);
557 }
558 Ok(command)
559 }
560 }
561 .map(|mut process| {
562 util::set_pre_exec_to_start_new_session(&mut process);
563 smol::process::Command::from(process)
564 })
565 })?
566 })
567 }
568
569 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
570 &self.terminals.local_handles
571 }
572
573 fn resolve_directory_environment(
574 &self,
575 shell: &str,
576 path: Option<Arc<Path>>,
577 remote_client: Option<Entity<RemoteClient>>,
578 cx: &mut App,
579 ) -> Shared<Task<Option<HashMap<String, String>>>> {
580 if let Some(path) = &path {
581 let shell = Shell::Program(shell.to_string());
582 self.environment
583 .update(cx, |project_env, cx| match &remote_client {
584 Some(remote_client) => project_env.remote_directory_environment(
585 &shell,
586 path.clone(),
587 remote_client.clone(),
588 cx,
589 ),
590 None => project_env.local_directory_environment(&shell, path.clone(), cx),
591 })
592 } else {
593 Task::ready(None).shared()
594 }
595 }
596}
597
598fn create_remote_shell(
599 spawn_command: Option<(&String, &Vec<String>)>,
600 mut env: HashMap<String, String>,
601 working_directory: Option<Arc<Path>>,
602 remote_client: Entity<RemoteClient>,
603 cx: &mut App,
604) -> Result<(Shell, HashMap<String, String>)> {
605 insert_zed_terminal_env(&mut env, &release_channel::AppVersion::global(cx));
606
607 let (program, args) = match spawn_command {
608 Some((program, args)) => (Some(program.clone()), args),
609 None => (None, &Vec::new()),
610 };
611
612 let command = remote_client.read(cx).build_command(
613 program,
614 args.as_slice(),
615 &env,
616 working_directory.map(|path| path.display().to_string()),
617 None,
618 )?;
619
620 log::debug!("Connecting to a remote server: {:?}", command.program);
621 let host = remote_client.read(cx).connection_options().display_name();
622
623 Ok((
624 Shell::WithArguments {
625 program: command.program,
626 args: command.args,
627 title_override: Some(format!("{} — Terminal", host)),
628 },
629 command.env,
630 ))
631}