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