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