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