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 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 => match &settings.shell {
102 Shell::Program(program) => program.clone(),
103 Shell::WithArguments {
104 program,
105 args: _,
106 title_override: _,
107 } => program.clone(),
108 Shell::System => get_system_shell(),
109 },
110 };
111
112 let project_path_contexts = self
113 .active_entry()
114 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
115 .into_iter()
116 .chain(
117 self.visible_worktrees(cx)
118 .map(|wt| wt.read(cx).id())
119 .map(|worktree_id| ProjectPath {
120 worktree_id,
121 path: Arc::from(Path::new("")),
122 }),
123 );
124 let toolchains = project_path_contexts
125 .filter(|_| detect_venv)
126 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
127 .collect::<Vec<_>>();
128 let lang_registry = self.languages.clone();
129 let fs = self.fs.clone();
130 cx.spawn(async move |project, cx| {
131 let shell_kind = ShellKind::new(&shell);
132 let activation_script = maybe!(async {
133 for toolchain in toolchains {
134 let Some(toolchain) = toolchain.await else {
135 continue;
136 };
137 let language = lang_registry
138 .language_for_name(&toolchain.language_name.0)
139 .await
140 .ok();
141 let lister = language?.toolchain_lister();
142 return Some(
143 lister?
144 .activation_script(&toolchain, shell_kind, fs.as_ref())
145 .await,
146 );
147 }
148 None
149 })
150 .await
151 .unwrap_or_default();
152
153 project.update(cx, move |this, cx| {
154 let format_to_run = || {
155 if let Some(command) = &spawn_task.command {
156 let mut command: Option<Cow<str>> = shlex::try_quote(command).ok();
157 if let Some(command) = &mut command
158 && command.starts_with('"')
159 && let Some(prefix) = shell_kind.command_prefix()
160 {
161 *command = Cow::Owned(format!("{prefix}{command}"));
162 }
163
164 let args = spawn_task
165 .args
166 .iter()
167 .filter_map(|arg| shlex::try_quote(arg).ok());
168 command.into_iter().chain(args).join(" ")
169 } else {
170 // todo: this breaks for remotes to windows
171 format!("exec {shell} -l")
172 }
173 };
174
175 let (shell, env) = {
176 env.extend(spawn_task.env);
177 match remote_client {
178 Some(remote_client) => match activation_script.clone() {
179 activation_script if !activation_script.is_empty() => {
180 let activation_script = activation_script.join("; ");
181 let to_run = format_to_run();
182 let args =
183 vec!["-c".to_owned(), format!("{activation_script}; {to_run}")];
184 create_remote_shell(
185 Some((
186 &remote_client
187 .read(cx)
188 .shell()
189 .unwrap_or_else(get_default_system_shell),
190 &args,
191 )),
192 env,
193 path,
194 remote_client,
195 cx,
196 )?
197 }
198 _ => create_remote_shell(
199 spawn_task
200 .command
201 .as_ref()
202 .map(|command| (command, &spawn_task.args)),
203 env,
204 path,
205 remote_client,
206 cx,
207 )?,
208 },
209 None => match activation_script.clone() {
210 activation_script if !activation_script.is_empty() => {
211 let activation_script = activation_script.join("; ");
212 let to_run = format_to_run();
213
214 let arg = format!("{activation_script}; {to_run}");
215
216 (
217 Shell::WithArguments {
218 program: shell,
219 args: vec!["-c".to_owned(), arg],
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.unwrap_or_default(),
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,
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: Arc::from(Path::new("")),
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_kind = ShellKind::new(&match &remote_client {
331 Some(remote_client) => remote_client
332 .read(cx)
333 .shell()
334 .unwrap_or_else(get_default_system_shell),
335 None => match &settings.shell {
336 Shell::Program(program) => program.clone(),
337 Shell::WithArguments {
338 program,
339 args: _,
340 title_override: _,
341 } => program.clone(),
342 Shell::System => get_system_shell(),
343 },
344 });
345
346 let lang_registry = self.languages.clone();
347 let fs = self.fs.clone();
348 cx.spawn(async move |project, cx| {
349 let activation_script = maybe!(async {
350 for toolchain in toolchains {
351 let Some(toolchain) = toolchain.await else {
352 continue;
353 };
354 let language = lang_registry
355 .language_for_name(&toolchain.language_name.0)
356 .await
357 .ok();
358 let lister = language?.toolchain_lister();
359 return Some(
360 lister?
361 .activation_script(&toolchain, shell_kind, fs.as_ref())
362 .await,
363 );
364 }
365 None
366 })
367 .await
368 .unwrap_or_default();
369 project.update(cx, move |this, cx| {
370 let (shell, env) = {
371 match remote_client {
372 Some(remote_client) => {
373 create_remote_shell(None, env, path, remote_client, cx)?
374 }
375 None => (settings.shell, env),
376 }
377 };
378 TerminalBuilder::new(
379 local_path.map(|path| path.to_path_buf()),
380 None,
381 shell,
382 env,
383 settings.cursor_shape.unwrap_or_default(),
384 settings.alternate_scroll,
385 settings.max_scroll_history_lines,
386 is_via_remote,
387 cx.entity_id().as_u64(),
388 None,
389 cx,
390 activation_script,
391 )
392 .map(|builder| {
393 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
394
395 this.terminals
396 .local_handles
397 .push(terminal_handle.downgrade());
398
399 let id = terminal_handle.entity_id();
400 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
401 let handles = &mut project.terminals.local_handles;
402
403 if let Some(index) = handles
404 .iter()
405 .position(|terminal| terminal.entity_id() == id)
406 {
407 handles.remove(index);
408 cx.notify();
409 }
410 })
411 .detach();
412
413 terminal_handle
414 })
415 })?
416 })
417 }
418
419 pub fn clone_terminal(
420 &mut self,
421 terminal: &Entity<Terminal>,
422 cx: &mut Context<'_, Project>,
423 cwd: impl FnOnce() -> Option<PathBuf>,
424 ) -> Result<Entity<Terminal>> {
425 terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
426 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
427
428 self.terminals
429 .local_handles
430 .push(terminal_handle.downgrade());
431
432 let id = terminal_handle.entity_id();
433 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
434 let handles = &mut project.terminals.local_handles;
435
436 if let Some(index) = handles
437 .iter()
438 .position(|terminal| terminal.entity_id() == id)
439 {
440 handles.remove(index);
441 cx.notify();
442 }
443 })
444 .detach();
445
446 terminal_handle
447 })
448 }
449
450 pub fn terminal_settings<'a>(
451 &'a self,
452 path: &'a Option<PathBuf>,
453 cx: &'a App,
454 ) -> &'a TerminalSettings {
455 let mut settings_location = None;
456 if let Some(path) = path.as_ref()
457 && let Some((worktree, _)) = self.find_worktree(path, cx)
458 {
459 settings_location = Some(SettingsLocation {
460 worktree_id: worktree.read(cx).id(),
461 path,
462 });
463 }
464 TerminalSettings::get(settings_location, cx)
465 }
466
467 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
468 let path = self.first_project_directory(cx);
469 let remote_client = self.remote_client.as_ref();
470 let settings = self.terminal_settings(&path, cx).clone();
471 let remote_shell = remote_client
472 .as_ref()
473 .and_then(|remote_client| remote_client.read(cx).shell());
474 let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
475 let (command, args) = builder.build(Some(command), &Vec::new());
476
477 let mut env = self
478 .environment
479 .read(cx)
480 .get_cli_environment()
481 .unwrap_or_default();
482 env.extend(settings.env);
483
484 match remote_client {
485 Some(remote_client) => {
486 let command_template =
487 remote_client
488 .read(cx)
489 .build_command(Some(command), &args, &env, None, None)?;
490 let mut command = std::process::Command::new(command_template.program);
491 command.args(command_template.args);
492 command.envs(command_template.env);
493 Ok(command)
494 }
495 None => {
496 let mut command = std::process::Command::new(command);
497 command.args(args);
498 command.envs(env);
499 if let Some(path) = path {
500 command.current_dir(path);
501 }
502 Ok(command)
503 }
504 }
505 }
506
507 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
508 &self.terminals.local_handles
509 }
510}
511
512fn create_remote_shell(
513 spawn_command: Option<(&String, &Vec<String>)>,
514 mut env: HashMap<String, String>,
515 working_directory: Option<Arc<Path>>,
516 remote_client: Entity<RemoteClient>,
517 cx: &mut App,
518) -> Result<(Shell, HashMap<String, String>)> {
519 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
520 // to properly display colors.
521 // We do not have the luxury of assuming the host has it installed,
522 // so we set it to a default that does not break the highlighting via ssh.
523 env.entry("TERM".to_string())
524 .or_insert_with(|| "xterm-256color".to_string());
525
526 let (program, args) = match spawn_command {
527 Some((program, args)) => (Some(program.clone()), args),
528 None => (None, &Vec::new()),
529 };
530
531 let command = remote_client.read(cx).build_command(
532 program,
533 args.as_slice(),
534 &env,
535 working_directory.map(|path| path.display().to_string()),
536 None,
537 )?;
538
539 log::debug!("Connecting to a remote server: {:?}", command.program);
540 let host = remote_client.read(cx).connection_options().display_name();
541
542 Ok((
543 Shell::WithArguments {
544 program: command.program,
545 args: command.args,
546 title_override: Some(format!("{} — Terminal", host).into()),
547 },
548 command.env,
549 ))
550}