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 = {
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((&shell, &args)),
193 &mut env,
194 path,
195 remote_client,
196 cx,
197 )?
198 }
199 _ => create_remote_shell(
200 spawn_task
201 .command
202 .as_ref()
203 .map(|command| (command, &spawn_task.args)),
204 &mut env,
205 path,
206 remote_client,
207 cx,
208 )?,
209 },
210 None => match activation_script.clone() {
211 activation_script if !activation_script.is_empty() => {
212 let activation_script = activation_script.join("; ");
213 let to_run = format_to_run();
214
215 // todo(lw): Alacritty uses `CreateProcessW` on windows with the entire command and arg sequence merged into a single string,
216 // without quoting the arguments
217 #[cfg(windows)]
218 let arg =
219 quote_arg(&format!("{activation_script}; {to_run}"), true);
220 #[cfg(not(windows))]
221 let arg = format!("{activation_script}; {to_run}");
222
223 Shell::WithArguments {
224 program: shell,
225 args: vec!["-c".to_owned(), arg],
226 title_override: None,
227 }
228 }
229 _ => {
230 if let Some(program) = spawn_task.command {
231 Shell::WithArguments {
232 program,
233 args: spawn_task.args,
234 title_override: None,
235 }
236 } else {
237 Shell::System
238 }
239 }
240 },
241 }
242 };
243 TerminalBuilder::new(
244 local_path.map(|path| path.to_path_buf()),
245 task_state,
246 shell,
247 env,
248 settings.cursor_shape.unwrap_or_default(),
249 settings.alternate_scroll,
250 settings.max_scroll_history_lines,
251 is_via_remote,
252 cx.entity_id().as_u64(),
253 Some(completion_tx),
254 cx,
255 activation_script,
256 )
257 .map(|builder| {
258 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
259
260 this.terminals
261 .local_handles
262 .push(terminal_handle.downgrade());
263
264 let id = terminal_handle.entity_id();
265 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
266 let handles = &mut project.terminals.local_handles;
267
268 if let Some(index) = handles
269 .iter()
270 .position(|terminal| terminal.entity_id() == id)
271 {
272 handles.remove(index);
273 cx.notify();
274 }
275 })
276 .detach();
277
278 terminal_handle
279 })
280 })?
281 })
282 }
283
284 pub fn create_terminal_shell(
285 &mut self,
286 cwd: Option<PathBuf>,
287 cx: &mut Context<Self>,
288 ) -> Task<Result<Entity<Terminal>>> {
289 let path = cwd.map(|p| Arc::from(&*p));
290 let is_via_remote = self.remote_client.is_some();
291
292 let mut settings_location = None;
293 if let Some(path) = path.as_ref()
294 && let Some((worktree, _)) = self.find_worktree(path, cx)
295 {
296 settings_location = Some(SettingsLocation {
297 worktree_id: worktree.read(cx).id(),
298 path,
299 });
300 }
301 let settings = TerminalSettings::get(settings_location, cx).clone();
302 let detect_venv = settings.detect_venv.as_option().is_some();
303
304 // Start with the environment that we might have inherited from the Zed CLI.
305 let mut env = self
306 .environment
307 .read(cx)
308 .get_cli_environment()
309 .unwrap_or_default();
310 // Then extend it with the explicit env variables from the settings, so they take
311 // precedence.
312 env.extend(settings.env);
313
314 let local_path = if is_via_remote { None } else { path.clone() };
315
316 let project_path_contexts = self
317 .active_entry()
318 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
319 .into_iter()
320 .chain(
321 self.visible_worktrees(cx)
322 .map(|wt| wt.read(cx).id())
323 .map(|worktree_id| ProjectPath {
324 worktree_id,
325 path: Arc::from(Path::new("")),
326 }),
327 );
328 let toolchains = project_path_contexts
329 .filter(|_| detect_venv)
330 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
331 .collect::<Vec<_>>();
332 let remote_client = self.remote_client.clone();
333 let shell = match &remote_client {
334 Some(remote_client) => remote_client
335 .read(cx)
336 .shell()
337 .unwrap_or_else(get_default_system_shell),
338 None => match &settings.shell {
339 Shell::Program(program) => program.clone(),
340 Shell::WithArguments {
341 program,
342 args: _,
343 title_override: _,
344 } => program.clone(),
345 Shell::System => get_system_shell(),
346 },
347 };
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, ShellKind::new(&shell), fs.as_ref())
365 .await,
366 );
367 }
368 None
369 })
370 .await
371 .unwrap_or_default();
372 project.update(cx, move |this, cx| {
373 let shell = {
374 match remote_client {
375 Some(remote_client) => {
376 create_remote_shell(None, &mut env, path, remote_client, cx)?
377 }
378 None => settings.shell,
379 }
380 };
381 TerminalBuilder::new(
382 local_path.map(|path| path.to_path_buf()),
383 None,
384 shell,
385 env,
386 settings.cursor_shape.unwrap_or_default(),
387 settings.alternate_scroll,
388 settings.max_scroll_history_lines,
389 is_via_remote,
390 cx.entity_id().as_u64(),
391 None,
392 cx,
393 activation_script,
394 )
395 .map(|builder| {
396 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
397
398 this.terminals
399 .local_handles
400 .push(terminal_handle.downgrade());
401
402 let id = terminal_handle.entity_id();
403 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
404 let handles = &mut project.terminals.local_handles;
405
406 if let Some(index) = handles
407 .iter()
408 .position(|terminal| terminal.entity_id() == id)
409 {
410 handles.remove(index);
411 cx.notify();
412 }
413 })
414 .detach();
415
416 terminal_handle
417 })
418 })?
419 })
420 }
421
422 pub fn clone_terminal(
423 &mut self,
424 terminal: &Entity<Terminal>,
425 cx: &mut Context<'_, Project>,
426 cwd: impl FnOnce() -> Option<PathBuf>,
427 ) -> Result<Entity<Terminal>> {
428 terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
429 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
430
431 self.terminals
432 .local_handles
433 .push(terminal_handle.downgrade());
434
435 let id = terminal_handle.entity_id();
436 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
437 let handles = &mut project.terminals.local_handles;
438
439 if let Some(index) = handles
440 .iter()
441 .position(|terminal| terminal.entity_id() == id)
442 {
443 handles.remove(index);
444 cx.notify();
445 }
446 })
447 .detach();
448
449 terminal_handle
450 })
451 }
452
453 pub fn terminal_settings<'a>(
454 &'a self,
455 path: &'a Option<PathBuf>,
456 cx: &'a App,
457 ) -> &'a TerminalSettings {
458 let mut settings_location = None;
459 if let Some(path) = path.as_ref()
460 && let Some((worktree, _)) = self.find_worktree(path, cx)
461 {
462 settings_location = Some(SettingsLocation {
463 worktree_id: worktree.read(cx).id(),
464 path,
465 });
466 }
467 TerminalSettings::get(settings_location, cx)
468 }
469
470 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
471 let path = self.first_project_directory(cx);
472 let remote_client = self.remote_client.as_ref();
473 let settings = self.terminal_settings(&path, cx).clone();
474 let remote_shell = remote_client
475 .as_ref()
476 .and_then(|remote_client| remote_client.read(cx).shell());
477 let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
478 let (command, args) = builder.build(Some(command), &Vec::new());
479
480 let mut env = self
481 .environment
482 .read(cx)
483 .get_cli_environment()
484 .unwrap_or_default();
485 env.extend(settings.env);
486
487 match remote_client {
488 Some(remote_client) => {
489 let command_template =
490 remote_client
491 .read(cx)
492 .build_command(Some(command), &args, &env, None, None)?;
493 let mut command = std::process::Command::new(command_template.program);
494 command.args(command_template.args);
495 command.envs(command_template.env);
496 Ok(command)
497 }
498 None => {
499 let mut command = std::process::Command::new(command);
500 command.args(args);
501 command.envs(env);
502 if let Some(path) = path {
503 command.current_dir(path);
504 }
505 Ok(command)
506 }
507 }
508 }
509
510 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
511 &self.terminals.local_handles
512 }
513}
514
515/// 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
516/// that quote impl straight from Rust stdlib (Command API).
517#[cfg(windows)]
518fn quote_arg(argument: &str, quote: bool) -> String {
519 let mut arg = String::new();
520 if quote {
521 arg.push('"');
522 }
523
524 let mut backslashes: usize = 0;
525 for x in argument.chars() {
526 if x == '\\' {
527 backslashes += 1;
528 } else {
529 if x == '"' {
530 // Add n+1 backslashes to total 2n+1 before internal '"'.
531 arg.extend((0..=backslashes).map(|_| '\\'));
532 }
533 backslashes = 0;
534 }
535 arg.push(x);
536 }
537
538 if quote {
539 // Add n backslashes to total 2n before ending '"'.
540 arg.extend((0..backslashes).map(|_| '\\'));
541 arg.push('"');
542 }
543 arg
544}
545
546fn create_remote_shell(
547 spawn_command: Option<(&String, &Vec<String>)>,
548 env: &mut HashMap<String, String>,
549 working_directory: Option<Arc<Path>>,
550 remote_client: Entity<RemoteClient>,
551 cx: &mut App,
552) -> Result<Shell> {
553 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
554 // to properly display colors.
555 // We do not have the luxury of assuming the host has it installed,
556 // so we set it to a default that does not break the highlighting via ssh.
557 env.entry("TERM".to_string())
558 .or_insert_with(|| "xterm-256color".to_string());
559
560 let (program, args) = match spawn_command {
561 Some((program, args)) => (Some(program.clone()), args),
562 None => (None, &Vec::new()),
563 };
564
565 let command = remote_client.read(cx).build_command(
566 program,
567 args.as_slice(),
568 env,
569 working_directory.map(|path| path.display().to_string()),
570 None,
571 )?;
572 *env = command.env;
573
574 log::debug!("Connecting to a remote server: {:?}", command.program);
575 let host = remote_client.read(cx).connection_options().display_name();
576
577 Ok(Shell::WithArguments {
578 program: command.program,
579 args: command.args,
580 title_override: Some(format!("{} — Terminal", host).into()),
581 })
582}