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, 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 => 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(RelPath::empty()),
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 // todo(lw): Alacritty uses `CreateProcessW` on windows with the entire command and arg sequence merged into a single string,
215 // without quoting the arguments
216 #[cfg(windows)]
217 let arg =
218 quote_arg(&format!("{activation_script}; {to_run}"), true);
219 #[cfg(not(windows))]
220 let arg = format!("{activation_script}; {to_run}");
221
222 (
223 Shell::WithArguments {
224 program: shell,
225 args: vec!["-c".to_owned(), arg],
226 title_override: None,
227 },
228 env,
229 )
230 }
231 _ => (
232 if let Some(program) = spawn_task.command {
233 Shell::WithArguments {
234 program,
235 args: spawn_task.args,
236 title_override: None,
237 }
238 } else {
239 Shell::System
240 },
241 env,
242 ),
243 },
244 }
245 };
246 TerminalBuilder::new(
247 local_path.map(|path| path.to_path_buf()),
248 task_state,
249 shell,
250 env,
251 settings.cursor_shape.unwrap_or_default(),
252 settings.alternate_scroll,
253 settings.max_scroll_history_lines,
254 is_via_remote,
255 cx.entity_id().as_u64(),
256 Some(completion_tx),
257 cx,
258 activation_script,
259 )
260 .map(|builder| {
261 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
262
263 this.terminals
264 .local_handles
265 .push(terminal_handle.downgrade());
266
267 let id = terminal_handle.entity_id();
268 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
269 let handles = &mut project.terminals.local_handles;
270
271 if let Some(index) = handles
272 .iter()
273 .position(|terminal| terminal.entity_id() == id)
274 {
275 handles.remove(index);
276 cx.notify();
277 }
278 })
279 .detach();
280
281 terminal_handle
282 })
283 })?
284 })
285 }
286
287 pub fn create_terminal_shell(
288 &mut self,
289 cwd: Option<PathBuf>,
290 cx: &mut Context<Self>,
291 ) -> Task<Result<Entity<Terminal>>> {
292 let path = cwd.map(|p| Arc::from(&*p));
293 let is_via_remote = self.remote_client.is_some();
294
295 let mut settings_location = None;
296 if let Some(path) = path.as_ref()
297 && let Some((worktree, _)) = self.find_worktree(path, cx)
298 {
299 settings_location = Some(SettingsLocation {
300 worktree_id: worktree.read(cx).id(),
301 path: RelPath::empty(),
302 });
303 }
304 let settings = TerminalSettings::get(settings_location, cx).clone();
305 let detect_venv = settings.detect_venv.as_option().is_some();
306
307 // Start with the environment that we might have inherited from the Zed CLI.
308 let mut env = self
309 .environment
310 .read(cx)
311 .get_cli_environment()
312 .unwrap_or_default();
313 // Then extend it with the explicit env variables from the settings, so they take
314 // precedence.
315 env.extend(settings.env);
316
317 let local_path = if is_via_remote { None } else { path.clone() };
318
319 let project_path_contexts = self
320 .active_entry()
321 .and_then(|entry_id| self.path_for_entry(entry_id, cx))
322 .into_iter()
323 .chain(
324 self.visible_worktrees(cx)
325 .map(|wt| wt.read(cx).id())
326 .map(|worktree_id| ProjectPath {
327 worktree_id,
328 path: RelPath::empty().into(),
329 }),
330 );
331 let toolchains = project_path_contexts
332 .filter(|_| detect_venv)
333 .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
334 .collect::<Vec<_>>();
335 let remote_client = self.remote_client.clone();
336 let shell_kind = ShellKind::new(&match &remote_client {
337 Some(remote_client) => remote_client
338 .read(cx)
339 .shell()
340 .unwrap_or_else(get_default_system_shell),
341 None => match &settings.shell {
342 Shell::Program(program) => program.clone(),
343 Shell::WithArguments {
344 program,
345 args: _,
346 title_override: _,
347 } => program.clone(),
348 Shell::System => get_system_shell(),
349 },
350 });
351
352 let lang_registry = self.languages.clone();
353 let fs = self.fs.clone();
354 cx.spawn(async move |project, cx| {
355 let activation_script = maybe!(async {
356 for toolchain in toolchains {
357 let Some(toolchain) = toolchain.await else {
358 continue;
359 };
360 let language = lang_registry
361 .language_for_name(&toolchain.language_name.0)
362 .await
363 .ok();
364 let lister = language?.toolchain_lister();
365 return Some(
366 lister?
367 .activation_script(&toolchain, shell_kind, fs.as_ref())
368 .await,
369 );
370 }
371 None
372 })
373 .await
374 .unwrap_or_default();
375 project.update(cx, move |this, cx| {
376 let (shell, env) = {
377 match remote_client {
378 Some(remote_client) => {
379 create_remote_shell(None, env, path, remote_client, cx)?
380 }
381 None => (settings.shell, env),
382 }
383 };
384 TerminalBuilder::new(
385 local_path.map(|path| path.to_path_buf()),
386 None,
387 shell,
388 env,
389 settings.cursor_shape.unwrap_or_default(),
390 settings.alternate_scroll,
391 settings.max_scroll_history_lines,
392 is_via_remote,
393 cx.entity_id().as_u64(),
394 None,
395 cx,
396 activation_script,
397 )
398 .map(|builder| {
399 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
400
401 this.terminals
402 .local_handles
403 .push(terminal_handle.downgrade());
404
405 let id = terminal_handle.entity_id();
406 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
407 let handles = &mut project.terminals.local_handles;
408
409 if let Some(index) = handles
410 .iter()
411 .position(|terminal| terminal.entity_id() == id)
412 {
413 handles.remove(index);
414 cx.notify();
415 }
416 })
417 .detach();
418
419 terminal_handle
420 })
421 })?
422 })
423 }
424
425 pub fn clone_terminal(
426 &mut self,
427 terminal: &Entity<Terminal>,
428 cx: &mut Context<'_, Project>,
429 cwd: impl FnOnce() -> Option<PathBuf>,
430 ) -> Result<Entity<Terminal>> {
431 terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
432 let terminal_handle = cx.new(|cx| builder.subscribe(cx));
433
434 self.terminals
435 .local_handles
436 .push(terminal_handle.downgrade());
437
438 let id = terminal_handle.entity_id();
439 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
440 let handles = &mut project.terminals.local_handles;
441
442 if let Some(index) = handles
443 .iter()
444 .position(|terminal| terminal.entity_id() == id)
445 {
446 handles.remove(index);
447 cx.notify();
448 }
449 })
450 .detach();
451
452 terminal_handle
453 })
454 }
455
456 pub fn terminal_settings<'a>(
457 &'a self,
458 path: &'a Option<PathBuf>,
459 cx: &'a App,
460 ) -> &'a TerminalSettings {
461 let mut settings_location = None;
462 if let Some(path) = path.as_ref()
463 && let Some((worktree, _)) = self.find_worktree(path, cx)
464 {
465 settings_location = Some(SettingsLocation {
466 worktree_id: worktree.read(cx).id(),
467 path: RelPath::empty(),
468 });
469 }
470 TerminalSettings::get(settings_location, cx)
471 }
472
473 pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<smol::process::Command> {
474 let path = self.first_project_directory(cx);
475 let remote_client = self.remote_client.as_ref();
476 let settings = self.terminal_settings(&path, cx).clone();
477 let remote_shell = remote_client
478 .as_ref()
479 .and_then(|remote_client| remote_client.read(cx).shell());
480 let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
481 let (command, args) = builder.build(Some(command), &Vec::new());
482
483 let mut env = self
484 .environment
485 .read(cx)
486 .get_cli_environment()
487 .unwrap_or_default();
488 env.extend(settings.env);
489
490 match remote_client {
491 Some(remote_client) => {
492 let command_template =
493 remote_client
494 .read(cx)
495 .build_command(Some(command), &args, &env, None, None)?;
496 let mut command = std::process::Command::new(command_template.program);
497 command.args(command_template.args);
498 command.envs(command_template.env);
499 Ok(command)
500 }
501 None => {
502 let mut command = std::process::Command::new(command);
503 command.args(args);
504 command.envs(env);
505 if let Some(path) = path {
506 command.current_dir(path);
507 }
508 Ok(command)
509 }
510 }
511 .map(|mut process| {
512 util::set_pre_exec_to_start_new_session(&mut process);
513 smol::process::Command::from(process)
514 })
515 }
516
517 pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
518 &self.terminals.local_handles
519 }
520}
521
522/// We're not using shlex for windows as it is overly eager with escaping some of the special characters (^) we need for nu. Hence, we took
523/// that quote impl straight from Rust stdlib (Command API).
524#[cfg(windows)]
525fn quote_arg(argument: &str, quote: bool) -> String {
526 let mut arg = String::new();
527 if quote {
528 arg.push('"');
529 }
530
531 let mut backslashes: usize = 0;
532 for x in argument.chars() {
533 if x == '\\' {
534 backslashes += 1;
535 } else {
536 if x == '"' {
537 // Add n+1 backslashes to total 2n+1 before internal '"'.
538 arg.extend((0..=backslashes).map(|_| '\\'));
539 }
540 backslashes = 0;
541 }
542 arg.push(x);
543 }
544
545 if quote {
546 // Add n backslashes to total 2n before ending '"'.
547 arg.extend((0..backslashes).map(|_| '\\'));
548 arg.push('"');
549 }
550 arg
551}
552
553fn create_remote_shell(
554 spawn_command: Option<(&String, &Vec<String>)>,
555 mut env: HashMap<String, String>,
556 working_directory: Option<Arc<Path>>,
557 remote_client: Entity<RemoteClient>,
558 cx: &mut App,
559) -> Result<(Shell, HashMap<String, String>)> {
560 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
561 // to properly display colors.
562 // We do not have the luxury of assuming the host has it installed,
563 // so we set it to a default that does not break the highlighting via ssh.
564 env.entry("TERM".to_string())
565 .or_insert_with(|| "xterm-256color".to_string());
566
567 let (program, args) = match spawn_command {
568 Some((program, args)) => (Some(program.clone()), args),
569 None => (None, &Vec::new()),
570 };
571
572 let command = remote_client.read(cx).build_command(
573 program,
574 args.as_slice(),
575 &env,
576 working_directory.map(|path| path.display().to_string()),
577 None,
578 )?;
579
580 log::debug!("Connecting to a remote server: {:?}", command.program);
581 let host = remote_client.read(cx).connection_options().display_name();
582
583 Ok((
584 Shell::WithArguments {
585 program: command.program,
586 args: command.args,
587 title_override: Some(format!("{} — Terminal", host).into()),
588 },
589 command.env,
590 ))
591}