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 path::{Path, PathBuf},
12 sync::Arc,
13};
14use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
15use terminal::{
16 TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
17};
18use util::{get_default_system_shell, maybe, rel_path::RelPath};
19
20use crate::{Project, ProjectPath};
21
22pub struct Terminals {
23 pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
24}
25
26impl Project {
27 pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
28 self.active_entry()
29 .and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
30 .into_iter()
31 .chain(self.worktrees(cx))
32 .find_map(|tree| tree.read(cx).root_dir())
33 }
34
35 pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
36 let worktree = self.worktrees(cx).next()?;
37 let worktree = worktree.read(cx);
38 if worktree.root_entry()?.is_dir() {
39 Some(worktree.abs_path().to_path_buf())
40 } else {
41 None
42 }
43 }
44
45 pub fn create_terminal_task(
46 &mut self,
47 spawn_task: SpawnInTerminal,
48 cx: &mut Context<Self>,
49 ) -> Task<Result<Entity<Terminal>>> {
50 let is_via_remote = self.remote_client.is_some();
51
52 let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
53 if is_via_remote {
54 Some(Arc::from(cwd.as_ref()))
55 } else {
56 let cwd = cwd.to_string_lossy();
57 let tilde_substituted = shellexpand::tilde(&cwd);
58 Some(Arc::from(Path::new(tilde_substituted.as_ref())))
59 }
60 } else {
61 self.active_project_directory(cx)
62 };
63
64 let mut settings_location = None;
65 if let Some(path) = path.as_ref()
66 && let Some((worktree, _)) = self.find_worktree(path, cx)
67 {
68 settings_location = Some(SettingsLocation {
69 worktree_id: worktree.read(cx).id(),
70 path: RelPath::empty(),
71 });
72 }
73 let settings = TerminalSettings::get(settings_location, cx).clone();
74 let detect_venv = settings.detect_venv.as_option().is_some();
75
76 let (completion_tx, completion_rx) = bounded(1);
77
78 // Start with the environment that we might have inherited from the Zed CLI.
79 let mut env = self
80 .environment
81 .read(cx)
82 .get_cli_environment()
83 .unwrap_or_default();
84 // Then extend it with the explicit env variables from the settings, so they take
85 // precedence.
86 env.extend(settings.env);
87
88 let local_path = if is_via_remote { None } else { path.clone() };
89 let task_state = Some(TaskState {
90 spawned_task: spawn_task.clone(),
91 status: TaskStatus::Running,
92 completion_rx,
93 });
94 let remote_client = self.remote_client.clone();
95 let shell = match &remote_client {
96 Some(remote_client) => remote_client
97 .read(cx)
98 .shell()
99 .unwrap_or_else(get_default_system_shell),
100 None => settings.shell.program(),
101 };
102
103 let is_windows = self.path_style(cx).is_windows();
104
105 let project_path_contexts = self
106 .active_entry()
107 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
108 .into_iter()
109 .chain(
110 self.visible_worktrees(cx)
111 .map(|wt| wt.read(cx).id())
112 .map(|worktree_id| ProjectPath {
113 worktree_id,
114 path: Arc::from(RelPath::empty()),
115 }),
116 );
117 let toolchains = project_path_contexts
118 .filter(|_| detect_venv)
119 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
120 .collect::<Vec<_>>();
121 let lang_registry = self.languages.clone();
122 cx.spawn(async move |project, cx| {
123 let shell_kind = ShellKind::new(&shell, is_windows);
124
125 let activation_script = maybe!(async {
126 for toolchain in toolchains {
127 let Some(toolchain) = toolchain.await else {
128 continue;
129 };
130 let language = lang_registry
131 .language_for_name(&toolchain.language_name.0)
132 .await
133 .ok();
134 let lister = language?.toolchain_lister();
135 return Some(lister?.activation_script(&toolchain, shell_kind));
136 }
137 None
138 })
139 .await
140 .unwrap_or_default();
141
142 let builder = project
143 .update(cx, move |_, cx| {
144 let format_to_run = || {
145 if let Some(command) = &spawn_task.command {
146 let command = shell_kind.prepend_command_prefix(command);
147 let command = shell_kind.try_quote_prefix_aware(&command);
148 let args = spawn_task
149 .args
150 .iter()
151 .filter_map(|arg| shell_kind.try_quote(&arg));
152
153 command.into_iter().chain(args).join(" ")
154 } else {
155 // todo: this breaks for remotes to windows
156 format!("exec {shell} -l")
157 }
158 };
159
160 let (shell, env) = {
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 separator = shell_kind.sequential_commands_separator();
166 let activation_script =
167 activation_script.join(&format!("{separator} "));
168 let to_run = format_to_run();
169
170 let arg = format!("{activation_script}{separator} {to_run}");
171 let args = shell_kind.args_for_shell(false, arg);
172 let shell = remote_client
173 .read(cx)
174 .shell()
175 .unwrap_or_else(get_default_system_shell);
176
177 create_remote_shell(
178 Some((&shell, &args)),
179 env,
180 path,
181 remote_client,
182 cx,
183 )?
184 }
185 _ => create_remote_shell(
186 spawn_task
187 .command
188 .as_ref()
189 .map(|command| (command, &spawn_task.args)),
190 env,
191 path,
192 remote_client,
193 cx,
194 )?,
195 },
196 None => match activation_script.clone() {
197 activation_script if !activation_script.is_empty() => {
198 let separator = shell_kind.sequential_commands_separator();
199 let activation_script =
200 activation_script.join(&format!("{separator} "));
201 let to_run = format_to_run();
202
203 let mut arg =
204 format!("{activation_script}{separator} {to_run}");
205 if shell_kind == ShellKind::Cmd {
206 // We need to put the entire command in quotes since otherwise CMD tries to execute them
207 // as separate commands rather than chaining one after another.
208 arg = format!("\"{arg}\"");
209 }
210
211 let args = shell_kind.args_for_shell(false, arg);
212
213 (
214 Shell::WithArguments {
215 program: shell,
216 args,
217 title_override: None,
218 },
219 env,
220 )
221 }
222 _ => (
223 if let Some(program) = spawn_task.command {
224 Shell::WithArguments {
225 program,
226 args: spawn_task.args,
227 title_override: None,
228 }
229 } else {
230 Shell::System
231 },
232 env,
233 ),
234 },
235 }
236 };
237 anyhow::Ok(TerminalBuilder::new(
238 local_path.map(|path| path.to_path_buf()),
239 task_state,
240 shell,
241 env,
242 settings.cursor_shape,
243 settings.alternate_scroll,
244 settings.max_scroll_history_lines,
245 is_via_remote,
246 cx.entity_id().as_u64(),
247 Some(completion_tx),
248 cx,
249 activation_script,
250 ))
251 })??
252 .await?;
253 project.update(cx, move |this, cx| {
254 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
255
256 this.terminals
257 .local_handles
258 .push(terminal_handle.downgrade());
259
260 let id = terminal_handle.entity_id();
261 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
262 let handles = &mut project.terminals.local_handles;
263
264 if let Some(index) = handles
265 .iter()
266 .position(|terminal| terminal.entity_id() == id)
267 {
268 handles.remove(index);
269 cx.notify();
270 }
271 })
272 .detach();
273
274 terminal_handle
275 })
276 })
277 }
278
279 pub fn create_terminal_shell(
280 &mut self,
281 cwd: Option<PathBuf>,
282 cx: &mut Context<Self>,
283 ) -> Task<Result<Entity<Terminal>>> {
284 let path = cwd.map(|p| Arc::from(&*p));
285 let is_via_remote = self.remote_client.is_some();
286
287 let mut settings_location = None;
288 if let Some(path) = path.as_ref()
289 && let Some((worktree, _)) = self.find_worktree(path, cx)
290 {
291 settings_location = Some(SettingsLocation {
292 worktree_id: worktree.read(cx).id(),
293 path: RelPath::empty(),
294 });
295 }
296 let settings = TerminalSettings::get(settings_location, cx).clone();
297 let detect_venv = settings.detect_venv.as_option().is_some();
298
299 // Start with the environment that we might have inherited from the Zed CLI.
300 let mut env = self
301 .environment
302 .read(cx)
303 .get_cli_environment()
304 .unwrap_or_default();
305 // Then extend it with the explicit env variables from the settings, so they take
306 // precedence.
307 env.extend(settings.env);
308
309 let local_path = if is_via_remote { None } else { path.clone() };
310
311 let project_path_contexts = self
312 .active_entry()
313 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
314 .into_iter()
315 .chain(
316 self.visible_worktrees(cx)
317 .map(|wt| wt.read(cx).id())
318 .map(|worktree_id| ProjectPath {
319 worktree_id,
320 path: RelPath::empty().into(),
321 }),
322 );
323 let toolchains = project_path_contexts
324 .filter(|_| detect_venv)
325 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
326 .collect::<Vec<_>>();
327 let remote_client = self.remote_client.clone();
328 let shell = match &remote_client {
329 Some(remote_client) => remote_client
330 .read(cx)
331 .shell()
332 .unwrap_or_else(get_default_system_shell),
333 None => settings.shell.program(),
334 };
335
336 let shell_kind = ShellKind::new(&shell, self.path_style(cx).is_windows());
337
338 let lang_registry = self.languages.clone();
339 cx.spawn(async move |project, cx| {
340 let activation_script = maybe!(async {
341 for toolchain in toolchains {
342 let Some(toolchain) = toolchain.await else {
343 continue;
344 };
345 let language = lang_registry
346 .language_for_name(&toolchain.language_name.0)
347 .await
348 .ok();
349 let lister = language?.toolchain_lister();
350 return Some(lister?.activation_script(&toolchain, shell_kind));
351 }
352 None
353 })
354 .await
355 .unwrap_or_default();
356 let builder = project
357 .update(cx, move |_, cx| {
358 let (shell, env) = {
359 match remote_client {
360 Some(remote_client) => {
361 create_remote_shell(None, env, path, remote_client, cx)?
362 }
363 None => (settings.shell, env),
364 }
365 };
366 anyhow::Ok(TerminalBuilder::new(
367 local_path.map(|path| path.to_path_buf()),
368 None,
369 shell,
370 env,
371 settings.cursor_shape,
372 settings.alternate_scroll,
373 settings.max_scroll_history_lines,
374 is_via_remote,
375 cx.entity_id().as_u64(),
376 None,
377 cx,
378 activation_script,
379 ))
380 })??
381 .await?;
382 project.update(cx, move |this, cx| {
383 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
384
385 this.terminals
386 .local_handles
387 .push(terminal_handle.downgrade());
388
389 let id = terminal_handle.entity_id();
390 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
391 let handles = &mut project.terminals.local_handles;
392
393 if let Some(index) = handles
394 .iter()
395 .position(|terminal| terminal.entity_id() == id)
396 {
397 handles.remove(index);
398 cx.notify();
399 }
400 })
401 .detach();
402
403 terminal_handle
404 })
405 })
406 }
407
408 pub fn clone_terminal(
409 &mut self,
410 terminal: &Entity<Terminal>,
411 cx: &mut Context<'_, Project>,
412 cwd: Option<PathBuf>,
413 ) -> Task<Result<Entity<Terminal>>> {
414 // We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
415 // For now, create a new shell instead.
416 if terminal.read(cx).task().is_some() {
417 return self.create_terminal_shell(cwd, cx);
418 }
419 let local_path = if self.is_via_remote_server() {
420 None
421 } else {
422 cwd
423 };
424
425 let builder = terminal.read(cx).clone_builder(cx, local_path);
426 cx.spawn(async |project, cx| {
427 let terminal = builder.await?;
428 project.update(cx, |project, cx| {
429 let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
430
431 project
432 .terminals
433 .local_handles
434 .push(terminal_handle.downgrade());
435
436 let id = terminal_handle.entity_id();
437 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
438 let handles = &mut project.terminals.local_handles;
439
440 if let Some(index) = handles
441 .iter()
442 .position(|terminal| terminal.entity_id() == id)
443 {
444 handles.remove(index);
445 cx.notify();
446 }
447 })
448 .detach();
449
450 terminal_handle
451 })
452 })
453 }
454
455 pub fn terminal_settings<'a>(
456 &'a self,
457 path: &'a Option<PathBuf>,
458 cx: &'a App,
459 ) -> &'a TerminalSettings {
460 let mut settings_location = None;
461 if let Some(path) = path.as_ref()
462 && let Some((worktree, _)) = self.find_worktree(path, cx)
463 {
464 settings_location = Some(SettingsLocation {
465 worktree_id: worktree.read(cx).id(),
466 path: RelPath::empty(),
467 });
468 }
469 TerminalSettings::get(settings_location, cx)
470 }
471
472 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<smol::process::Command> {
473 let path = self.first_project_directory(cx);
474 let remote_client = self.remote_client.as_ref();
475 let settings = self.terminal_settings(&path, cx).clone();
476 let shell = remote_client
477 .as_ref()
478 .and_then(|remote_client| remote_client.read(cx).shell())
479 .map(Shell::Program)
480 .unwrap_or_else(|| settings.shell.clone());
481 let is_windows = self.path_style(cx).is_windows();
482 let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
483 let (command, args) = builder.build(Some(command), &Vec::new());
484
485 let mut env = self
486 .environment
487 .read(cx)
488 .get_cli_environment()
489 .unwrap_or_default();
490 env.extend(settings.env);
491
492 match remote_client {
493 Some(remote_client) => {
494 let command_template =
495 remote_client
496 .read(cx)
497 .build_command(Some(command), &args, &env, None, None)?;
498 let mut command = std::process::Command::new(command_template.program);
499 command.args(command_template.args);
500 command.envs(command_template.env);
501 Ok(command)
502 }
503 None => {
504 let mut command = std::process::Command::new(command);
505 command.args(args);
506 command.envs(env);
507 if let Some(path) = path {
508 command.current_dir(path);
509 }
510 Ok(command)
511 }
512 }
513 .map(|mut process| {
514 util::set_pre_exec_to_start_new_session(&mut process);
515 smol::process::Command::from(process)
516 })
517 }
518
519 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
520 &self.terminals.local_handles
521 }
522}
523
524fn create_remote_shell(
525 spawn_command: Option<(&String, &Vec<String>)>,
526 mut env: HashMap<String, String>,
527 working_directory: Option<Arc<Path>>,
528 remote_client: Entity<RemoteClient>,
529 cx: &mut App,
530) -> Result<(Shell, HashMap<String, String>)> {
531 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
532 // to properly display colors.
533 // We do not have the luxury of assuming the host has it installed,
534 // so we set it to a default that does not break the highlighting via ssh.
535 env.entry("TERM".to_string())
536 .or_insert_with(|| "xterm-256color".to_string());
537
538 let (program, args) = match spawn_command {
539 Some((program, args)) => (Some(program.clone()), args),
540 None => (None, &Vec::new()),
541 };
542
543 let command = remote_client.read(cx).build_command(
544 program,
545 args.as_slice(),
546 &env,
547 working_directory.map(|path| path.display().to_string()),
548 None,
549 )?;
550
551 log::debug!("Connecting to a remote server: {:?}", command.program);
552 let host = remote_client.read(cx).connection_options().display_name();
553
554 Ok((
555 Shell::WithArguments {
556 program: command.program,
557 args: command.args,
558 title_override: Some(format!("{} — Terminal", host)),
559 },
560 command.env,
561 ))
562}