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