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