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