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