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, get_system_shell, maybe};
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,
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 id: spawn_task.id,
92 full_label: spawn_task.full_label,
93 label: spawn_task.label,
94 command_label: spawn_task.command_label,
95 hide: spawn_task.hide,
96 status: TaskStatus::Running,
97 show_summary: spawn_task.show_summary,
98 show_command: spawn_task.show_command,
99 show_rerun: spawn_task.show_rerun,
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 => match &settings.shell {
109 Shell::Program(program) => program.clone(),
110 Shell::WithArguments {
111 program,
112 args: _,
113 title_override: _,
114 } => program.clone(),
115 Shell::System => get_system_shell(),
116 },
117 };
118
119 let project_path_contexts = self
120 .active_entry()
121 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
122 .into_iter()
123 .chain(
124 self.visible_worktrees(cx)
125 .map(|wt| wt.read(cx).id())
126 .map(|worktree_id| ProjectPath {
127 worktree_id,
128 path: Arc::from(Path::new("")),
129 }),
130 );
131 let toolchains = project_path_contexts
132 .filter(|_| detect_venv)
133 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
134 .collect::<Vec<_>>();
135 let lang_registry = self.languages.clone();
136 let fs = self.fs.clone();
137 cx.spawn(async move |project, cx| {
138 let shell_kind = ShellKind::new(&shell);
139 let activation_script = maybe!(async {
140 for toolchain in toolchains {
141 let Some(toolchain) = toolchain.await else {
142 continue;
143 };
144 let language = lang_registry
145 .language_for_name(&toolchain.language_name.0)
146 .await
147 .ok();
148 let lister = language?.toolchain_lister();
149 return Some(
150 lister?
151 .activation_script(&toolchain, shell_kind, fs.as_ref())
152 .await,
153 );
154 }
155 None
156 })
157 .await
158 .unwrap_or_default();
159
160 project.update(cx, move |this, cx| {
161 let format_to_run = || {
162 if let Some(command) = &spawn_task.command {
163 let mut command: Option<Cow<str>> = shlex::try_quote(command).ok();
164 if let Some(command) = &mut command
165 && command.starts_with('"')
166 && let Some(prefix) = shell_kind.command_prefix()
167 {
168 *command = Cow::Owned(format!("{prefix}{command}"));
169 }
170
171 let args = spawn_task
172 .args
173 .iter()
174 .filter_map(|arg| shlex::try_quote(arg).ok());
175 command.into_iter().chain(args).join(" ")
176 } else {
177 // todo: this breaks for remotes to windows
178 format!("exec {shell} -l")
179 }
180 };
181
182 let (shell, env) = {
183 env.extend(spawn_task.env);
184 match remote_client {
185 Some(remote_client) => match activation_script.clone() {
186 activation_script if !activation_script.is_empty() => {
187 let activation_script = activation_script.join("; ");
188 let to_run = format_to_run();
189 let args =
190 vec!["-c".to_owned(), format!("{activation_script}; {to_run}")];
191 create_remote_shell(
192 Some((
193 &remote_client
194 .read(cx)
195 .shell()
196 .unwrap_or_else(get_default_system_shell),
197 &args,
198 )),
199 env,
200 path,
201 remote_client,
202 cx,
203 )?
204 }
205 _ => create_remote_shell(
206 spawn_task
207 .command
208 .as_ref()
209 .map(|command| (command, &spawn_task.args)),
210 env,
211 path,
212 remote_client,
213 cx,
214 )?,
215 },
216 None => match activation_script.clone() {
217 activation_script if !activation_script.is_empty() => {
218 let activation_script = activation_script.join("; ");
219 let to_run = format_to_run();
220
221 // todo(lw): Alacritty uses `CreateProcessW` on windows with the entire command and arg sequence merged into a single string,
222 // without quoting the arguments
223 #[cfg(windows)]
224 let arg =
225 quote_arg(&format!("{activation_script}; {to_run}"), true);
226 #[cfg(not(windows))]
227 let arg = format!("{activation_script}; {to_run}");
228
229 (
230 Shell::WithArguments {
231 program: shell,
232 args: vec!["-c".to_owned(), arg],
233 title_override: None,
234 },
235 env,
236 )
237 }
238 _ => (
239 if let Some(program) = spawn_task.command {
240 Shell::WithArguments {
241 program,
242 args: spawn_task.args,
243 title_override: None,
244 }
245 } else {
246 Shell::System
247 },
248 env,
249 ),
250 },
251 }
252 };
253 TerminalBuilder::new(
254 local_path.map(|path| path.to_path_buf()),
255 task_state,
256 shell,
257 env,
258 settings.cursor_shape.unwrap_or_default(),
259 settings.alternate_scroll,
260 settings.max_scroll_history_lines,
261 is_via_remote,
262 cx.entity_id().as_u64(),
263 Some(completion_tx),
264 cx,
265 activation_script,
266 )
267 .map(|builder| {
268 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
269
270 this.terminals
271 .local_handles
272 .push(terminal_handle.downgrade());
273
274 let id = terminal_handle.entity_id();
275 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
276 let handles = &mut project.terminals.local_handles;
277
278 if let Some(index) = handles
279 .iter()
280 .position(|terminal| terminal.entity_id() == id)
281 {
282 handles.remove(index);
283 cx.notify();
284 }
285 })
286 .detach();
287
288 terminal_handle
289 })
290 })?
291 })
292 }
293
294 pub fn create_terminal_shell(
295 &mut self,
296 cwd: Option<PathBuf>,
297 cx: &mut Context<Self>,
298 ) -> Task<Result<Entity<Terminal>>> {
299 let path = cwd.map(|p| Arc::from(&*p));
300 let is_via_remote = self.remote_client.is_some();
301
302 let mut settings_location = None;
303 if let Some(path) = path.as_ref()
304 && let Some((worktree, _)) = self.find_worktree(path, cx)
305 {
306 settings_location = Some(SettingsLocation {
307 worktree_id: worktree.read(cx).id(),
308 path,
309 });
310 }
311 let settings = TerminalSettings::get(settings_location, cx).clone();
312 let detect_venv = settings.detect_venv.as_option().is_some();
313
314 // Start with the environment that we might have inherited from the Zed CLI.
315 let mut env = self
316 .environment
317 .read(cx)
318 .get_cli_environment()
319 .unwrap_or_default();
320 // Then extend it with the explicit env variables from the settings, so they take
321 // precedence.
322 env.extend(settings.env);
323
324 let local_path = if is_via_remote { None } else { path.clone() };
325
326 let project_path_contexts = self
327 .active_entry()
328 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
329 .into_iter()
330 .chain(
331 self.visible_worktrees(cx)
332 .map(|wt| wt.read(cx).id())
333 .map(|worktree_id| ProjectPath {
334 worktree_id,
335 path: Arc::from(Path::new("")),
336 }),
337 );
338 let toolchains = project_path_contexts
339 .filter(|_| detect_venv)
340 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
341 .collect::<Vec<_>>();
342 let remote_client = self.remote_client.clone();
343 let shell_kind = ShellKind::new(&match &remote_client {
344 Some(remote_client) => remote_client
345 .read(cx)
346 .shell()
347 .unwrap_or_else(get_default_system_shell),
348 None => match &settings.shell {
349 Shell::Program(program) => program.clone(),
350 Shell::WithArguments {
351 program,
352 args: _,
353 title_override: _,
354 } => program.clone(),
355 Shell::System => get_system_shell(),
356 },
357 });
358
359 let lang_registry = self.languages.clone();
360 let fs = self.fs.clone();
361 cx.spawn(async move |project, cx| {
362 let activation_script = maybe!(async {
363 for toolchain in toolchains {
364 let Some(toolchain) = toolchain.await else {
365 continue;
366 };
367 let language = lang_registry
368 .language_for_name(&toolchain.language_name.0)
369 .await
370 .ok();
371 let lister = language?.toolchain_lister();
372 return Some(
373 lister?
374 .activation_script(&toolchain, shell_kind, fs.as_ref())
375 .await,
376 );
377 }
378 None
379 })
380 .await
381 .unwrap_or_default();
382 project.update(cx, move |this, cx| {
383 let (shell, env) = {
384 match remote_client {
385 Some(remote_client) => {
386 create_remote_shell(None, env, path, remote_client, cx)?
387 }
388 None => (settings.shell, env),
389 }
390 };
391 TerminalBuilder::new(
392 local_path.map(|path| path.to_path_buf()),
393 None,
394 shell,
395 env,
396 settings.cursor_shape.unwrap_or_default(),
397 settings.alternate_scroll,
398 settings.max_scroll_history_lines,
399 is_via_remote,
400 cx.entity_id().as_u64(),
401 None,
402 cx,
403 activation_script,
404 )
405 .map(|builder| {
406 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
407
408 this.terminals
409 .local_handles
410 .push(terminal_handle.downgrade());
411
412 let id = terminal_handle.entity_id();
413 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
414 let handles = &mut project.terminals.local_handles;
415
416 if let Some(index) = handles
417 .iter()
418 .position(|terminal| terminal.entity_id() == id)
419 {
420 handles.remove(index);
421 cx.notify();
422 }
423 })
424 .detach();
425
426 terminal_handle
427 })
428 })?
429 })
430 }
431
432 pub fn clone_terminal(
433 &mut self,
434 terminal: &Entity<Terminal>,
435 cx: &mut Context<'_, Project>,
436 cwd: impl FnOnce() -> Option<PathBuf>,
437 ) -> Result<Entity<Terminal>> {
438 terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
439 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
440
441 self.terminals
442 .local_handles
443 .push(terminal_handle.downgrade());
444
445 let id = terminal_handle.entity_id();
446 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
447 let handles = &mut project.terminals.local_handles;
448
449 if let Some(index) = handles
450 .iter()
451 .position(|terminal| terminal.entity_id() == id)
452 {
453 handles.remove(index);
454 cx.notify();
455 }
456 })
457 .detach();
458
459 terminal_handle
460 })
461 }
462
463 pub fn terminal_settings<'a>(
464 &'a self,
465 path: &'a Option<PathBuf>,
466 cx: &'a App,
467 ) -> &'a TerminalSettings {
468 let mut settings_location = None;
469 if let Some(path) = path.as_ref()
470 && let Some((worktree, _)) = self.find_worktree(path, cx)
471 {
472 settings_location = Some(SettingsLocation {
473 worktree_id: worktree.read(cx).id(),
474 path,
475 });
476 }
477 TerminalSettings::get(settings_location, cx)
478 }
479
480 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
481 let path = self.first_project_directory(cx);
482 let remote_client = self.remote_client.as_ref();
483 let settings = self.terminal_settings(&path, cx).clone();
484 let remote_shell = remote_client
485 .as_ref()
486 .and_then(|remote_client| remote_client.read(cx).shell());
487 let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
488 let (command, args) = builder.build(Some(command), &Vec::new());
489
490 let mut env = self
491 .environment
492 .read(cx)
493 .get_cli_environment()
494 .unwrap_or_default();
495 env.extend(settings.env);
496
497 match remote_client {
498 Some(remote_client) => {
499 let command_template =
500 remote_client
501 .read(cx)
502 .build_command(Some(command), &args, &env, None, None)?;
503 let mut command = std::process::Command::new(command_template.program);
504 command.args(command_template.args);
505 command.envs(command_template.env);
506 Ok(command)
507 }
508 None => {
509 let mut command = std::process::Command::new(command);
510 command.args(args);
511 command.envs(env);
512 if let Some(path) = path {
513 command.current_dir(path);
514 }
515 Ok(command)
516 }
517 }
518 }
519
520 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
521 &self.terminals.local_handles
522 }
523}
524
525/// We're not using shlex for windows as it is overly eager with escaping some of the special characters (^) we need for nu. Hence, we took
526/// that quote impl straight from Rust stdlib (Command API).
527#[cfg(windows)]
528fn quote_arg(argument: &str, quote: bool) -> String {
529 let mut arg = String::new();
530 if quote {
531 arg.push('"');
532 }
533
534 let mut backslashes: usize = 0;
535 for x in argument.chars() {
536 if x == '\\' {
537 backslashes += 1;
538 } else {
539 if x == '"' {
540 // Add n+1 backslashes to total 2n+1 before internal '"'.
541 arg.extend((0..=backslashes).map(|_| '\\'));
542 }
543 backslashes = 0;
544 }
545 arg.push(x);
546 }
547
548 if quote {
549 // Add n backslashes to total 2n before ending '"'.
550 arg.extend((0..backslashes).map(|_| '\\'));
551 arg.push('"');
552 }
553 arg
554}
555
556fn create_remote_shell(
557 spawn_command: Option<(&String, &Vec<String>)>,
558 mut env: HashMap<String, String>,
559 working_directory: Option<Arc<Path>>,
560 remote_client: Entity<RemoteClient>,
561 cx: &mut App,
562) -> Result<(Shell, HashMap<String, String>)> {
563 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
564 // to properly display colors.
565 // We do not have the luxury of assuming the host has it installed,
566 // so we set it to a default that does not break the highlighting via ssh.
567 env.entry("TERM".to_string())
568 .or_insert_with(|| "xterm-256color".to_string());
569
570 let (program, args) = match spawn_command {
571 Some((program, args)) => (Some(program.clone()), args),
572 None => (None, &Vec::new()),
573 };
574
575 let command = remote_client.read(cx).build_command(
576 program,
577 args.as_slice(),
578 &env,
579 working_directory.map(|path| path.display().to_string()),
580 None,
581 )?;
582
583 log::debug!("Connecting to a remote server: {:?}", command.program);
584 let host = remote_client.read(cx).connection_options().display_name();
585
586 Ok((
587 Shell::WithArguments {
588 program: command.program,
589 args: command.args,
590 title_override: Some(format!("{} — Terminal", host).into()),
591 },
592 command.env,
593 ))
594}