1mod color_translation;
2pub mod connection;
3mod modal;
4pub mod terminal_element;
5
6use alacritty_terminal::{
7 event::{Event as AlacTermEvent, EventListener},
8 term::SizeInfo,
9};
10
11use connection::{Event, TerminalConnection};
12use dirs::home_dir;
13use editor::Input;
14use futures::channel::mpsc::UnboundedSender;
15use gpui::{
16 actions, elements::*, keymap::Keystroke, AppContext, ClipboardItem, Entity, ModelHandle,
17 MutableAppContext, View, ViewContext,
18};
19use modal::deploy_modal;
20
21use project::{LocalWorktree, Project, ProjectPath};
22use settings::{Settings, WorkingDirectory};
23use smallvec::SmallVec;
24use std::path::{Path, PathBuf};
25use workspace::{Item, Workspace};
26
27use crate::terminal_element::TerminalEl;
28
29const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
30const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
31const DEBUG_CELL_WIDTH: f32 = 5.;
32const DEBUG_LINE_HEIGHT: f32 = 5.;
33
34//For bel, use a yellow dot. (equivalent to dirty file with conflict)
35//For title, introduce max title length and
36
37///Event to transmit the scroll from the element to the view
38#[derive(Clone, Debug, PartialEq)]
39pub struct ScrollTerminal(pub i32);
40
41actions!(
42 terminal,
43 [
44 Deploy,
45 Up,
46 Down,
47 CtrlC,
48 Escape,
49 Enter,
50 Clear,
51 Copy,
52 Paste,
53 DeployModal
54 ]
55);
56
57///Initialize and register all of our action handlers
58pub fn init(cx: &mut MutableAppContext) {
59 //Global binding overrrides
60 cx.add_action(Terminal::ctrl_c);
61 cx.add_action(Terminal::up);
62 cx.add_action(Terminal::down);
63 cx.add_action(Terminal::escape);
64 cx.add_action(Terminal::enter);
65 //Useful terminal actions
66 cx.add_action(Terminal::deploy);
67 cx.add_action(deploy_modal);
68 cx.add_action(Terminal::copy);
69 cx.add_action(Terminal::paste);
70 cx.add_action(Terminal::input);
71 cx.add_action(Terminal::clear);
72}
73
74///A translation struct for Alacritty to communicate with us from their event loop
75#[derive(Clone)]
76pub struct ZedListener(UnboundedSender<AlacTermEvent>);
77
78impl EventListener for ZedListener {
79 fn send_event(&self, event: AlacTermEvent) {
80 self.0.unbounded_send(event).ok();
81 }
82}
83
84///A terminal view, maintains the PTY's file handles and communicates with the terminal
85pub struct Terminal {
86 connection: ModelHandle<TerminalConnection>,
87 has_new_content: bool,
88 //Currently using iTerm bell, show bell emoji in tab until input is received
89 has_bell: bool,
90 // Only for styling purposes. Doesn't effect behavior
91 modal: bool,
92}
93
94impl Entity for Terminal {
95 type Event = Event;
96}
97
98impl Terminal {
99 ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
100 ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
101 fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
102 //The details here don't matter, the terminal will be resized on the first layout
103 let size_info = SizeInfo::new(
104 DEBUG_TERMINAL_WIDTH,
105 DEBUG_TERMINAL_HEIGHT,
106 DEBUG_CELL_WIDTH,
107 DEBUG_LINE_HEIGHT,
108 0.,
109 0.,
110 false,
111 );
112
113 let (shell, envs) = {
114 let settings = cx.global::<Settings>();
115 let shell = settings.terminal_overrides.shell.clone();
116 let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
117 (shell, envs)
118 };
119
120 let connection = cx
121 .add_model(|cx| TerminalConnection::new(working_directory, shell, envs, size_info, cx));
122
123 Terminal::from_connection(connection, modal, cx)
124 }
125
126 fn from_connection(
127 connection: ModelHandle<TerminalConnection>,
128 modal: bool,
129 cx: &mut ViewContext<Self>,
130 ) -> Terminal {
131 cx.observe(&connection, |_, _, cx| cx.notify()).detach();
132 cx.subscribe(&connection, |this, _, event, cx| match event {
133 Event::Wakeup => {
134 if cx.is_self_focused() {
135 cx.notify()
136 } else {
137 this.has_new_content = true;
138 cx.emit(Event::TitleChanged);
139 }
140 }
141 Event::Bell => {
142 this.has_bell = true;
143 cx.emit(Event::TitleChanged);
144 }
145 _ => cx.emit(*event),
146 })
147 .detach();
148
149 Terminal {
150 connection,
151 has_new_content: true,
152 has_bell: false,
153 modal,
154 }
155 }
156
157 fn input(&mut self, Input(text): &Input, cx: &mut ViewContext<Self>) {
158 self.connection.update(cx, |connection, _| {
159 //TODO: This is probably not encoding UTF8 correctly (see alacritty/src/input.rs:L825-837)
160 connection.write_to_pty(text.clone());
161 });
162
163 if self.has_bell {
164 self.has_bell = false;
165 cx.emit(Event::TitleChanged);
166 }
167 }
168
169 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
170 self.connection
171 .update(cx, |connection, _| connection.clear());
172 }
173
174 ///Create a new Terminal in the current working directory or the user's home directory
175 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
176 let wd = get_wd_for_workspace(workspace, cx);
177 workspace.add_item(Box::new(cx.add_view(|cx| Terminal::new(wd, false, cx))), cx);
178 }
179
180 ///Attempt to paste the clipboard into the terminal
181 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
182 let term = self.connection.read(cx).term.lock();
183 let copy_text = term.selection_to_string();
184 match copy_text {
185 Some(s) => cx.write_to_clipboard(ClipboardItem::new(s)),
186 None => (),
187 }
188 }
189
190 ///Attempt to paste the clipboard into the terminal
191 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
192 if let Some(item) = cx.read_from_clipboard() {
193 self.connection.update(cx, |connection, _| {
194 connection.paste(item.text());
195 })
196 }
197 }
198
199 ///Synthesize the keyboard event corresponding to 'up'
200 fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
201 self.connection.update(cx, |connection, _| {
202 connection.try_keystroke(&Keystroke::parse("up").unwrap());
203 });
204 }
205
206 ///Synthesize the keyboard event corresponding to 'down'
207 fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
208 self.connection.update(cx, |connection, _| {
209 connection.try_keystroke(&Keystroke::parse("down").unwrap());
210 });
211 }
212
213 ///Synthesize the keyboard event corresponding to 'ctrl-c'
214 fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext<Self>) {
215 self.connection.update(cx, |connection, _| {
216 connection.try_keystroke(&Keystroke::parse("ctrl-c").unwrap());
217 });
218 }
219
220 ///Synthesize the keyboard event corresponding to 'escape'
221 fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
222 self.connection.update(cx, |connection, _| {
223 connection.try_keystroke(&Keystroke::parse("escape").unwrap());
224 });
225 }
226
227 ///Synthesize the keyboard event corresponding to 'enter'
228 fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
229 self.connection.update(cx, |connection, _| {
230 connection.try_keystroke(&Keystroke::parse("enter").unwrap());
231 });
232 }
233}
234
235impl View for Terminal {
236 fn ui_name() -> &'static str {
237 "Terminal"
238 }
239
240 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
241 let element = {
242 let connection_handle = self.connection.clone().downgrade();
243 let view_id = cx.view_id();
244 TerminalEl::new(view_id, connection_handle, self.modal).contained()
245 };
246
247 if self.modal {
248 let settings = cx.global::<Settings>();
249 let container_style = settings.theme.terminal.modal_container;
250 element.with_style(container_style).boxed()
251 } else {
252 element.boxed()
253 }
254 }
255
256 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
257 cx.emit(Event::Activate);
258 self.has_new_content = false;
259 }
260
261 fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
262 let mut context = Self::default_keymap_context();
263 if self.modal {
264 context.set.insert("ModalTerminal".into());
265 }
266 context
267 }
268}
269
270impl Item for Terminal {
271 fn tab_content(
272 &self,
273 _detail: Option<usize>,
274 tab_theme: &theme::Tab,
275 cx: &gpui::AppContext,
276 ) -> ElementBox {
277 let settings = cx.global::<Settings>();
278 let search_theme = &settings.theme.search; //TODO properly integrate themes
279
280 let mut flex = Flex::row();
281
282 if self.has_bell {
283 flex.add_child(
284 Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
285 .with_color(tab_theme.label.text.color)
286 .constrained()
287 .with_width(search_theme.tab_icon_width)
288 .aligned()
289 .boxed(),
290 );
291 };
292
293 flex.with_child(
294 Label::new(
295 self.connection.read(cx).title.clone(),
296 tab_theme.label.clone(),
297 )
298 .aligned()
299 .contained()
300 .with_margin_left(if self.has_bell {
301 search_theme.tab_icon_spacing
302 } else {
303 0.
304 })
305 .boxed(),
306 )
307 .boxed()
308 }
309
310 fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
311 //From what I can tell, there's no way to tell the current working
312 //Directory of the terminal from outside the terminal. There might be
313 //solutions to this, but they are non-trivial and require more IPC
314 Some(Terminal::new(
315 self.connection.read(cx).associated_directory.clone(),
316 false,
317 cx,
318 ))
319 }
320
321 fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
322 None
323 }
324
325 fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
326 SmallVec::new()
327 }
328
329 fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
330 false
331 }
332
333 fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
334
335 fn can_save(&self, _cx: &gpui::AppContext) -> bool {
336 false
337 }
338
339 fn save(
340 &mut self,
341 _project: gpui::ModelHandle<Project>,
342 _cx: &mut ViewContext<Self>,
343 ) -> gpui::Task<gpui::anyhow::Result<()>> {
344 unreachable!("save should not have been called");
345 }
346
347 fn save_as(
348 &mut self,
349 _project: gpui::ModelHandle<Project>,
350 _abs_path: std::path::PathBuf,
351 _cx: &mut ViewContext<Self>,
352 ) -> gpui::Task<gpui::anyhow::Result<()>> {
353 unreachable!("save_as should not have been called");
354 }
355
356 fn reload(
357 &mut self,
358 _project: gpui::ModelHandle<Project>,
359 _cx: &mut ViewContext<Self>,
360 ) -> gpui::Task<gpui::anyhow::Result<()>> {
361 gpui::Task::ready(Ok(()))
362 }
363
364 fn is_dirty(&self, _: &gpui::AppContext) -> bool {
365 self.has_new_content
366 }
367
368 fn should_update_tab_on_event(event: &Self::Event) -> bool {
369 matches!(event, &Event::TitleChanged)
370 }
371
372 fn should_close_item_on_event(event: &Self::Event) -> bool {
373 matches!(event, &Event::CloseTerminal)
374 }
375
376 fn should_activate_item_on_event(event: &Self::Event) -> bool {
377 matches!(event, &Event::Activate)
378 }
379}
380
381///Get's the working directory for the given workspace, respecting the user's settings.
382fn get_wd_for_workspace(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
383 let wd_setting = cx
384 .global::<Settings>()
385 .terminal_overrides
386 .working_directory
387 .clone()
388 .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
389 let res = match wd_setting {
390 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
391 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
392 WorkingDirectory::AlwaysHome => None,
393 WorkingDirectory::Always { directory } => shellexpand::full(&directory)
394 .ok()
395 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
396 .filter(|dir| dir.is_dir()),
397 };
398 res.or_else(|| home_dir())
399}
400
401///Get's the first project's home directory, or the home directory
402fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
403 workspace
404 .worktrees(cx)
405 .next()
406 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
407 .and_then(get_path_from_wt)
408}
409
410///Gets the intuitively correct working directory from the given workspace
411///If there is an active entry for this project, returns that entry's worktree root.
412///If there's no active entry but there is a worktree, returns that worktrees root.
413///If either of these roots are files, or if there are any other query failures,
414/// returns the user's home directory
415fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
416 let project = workspace.project().read(cx);
417
418 project
419 .active_entry()
420 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
421 .or_else(|| workspace.worktrees(cx).next())
422 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
423 .and_then(get_path_from_wt)
424}
425
426fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
427 wt.root_entry()
428 .filter(|re| re.is_dir())
429 .map(|_| wt.abs_path().to_path_buf())
430}
431
432#[cfg(test)]
433mod tests {
434
435 use crate::tests::terminal_test_context::TerminalTestContext;
436
437 use super::*;
438 use gpui::TestAppContext;
439
440 use std::path::Path;
441 use workspace::AppState;
442
443 mod terminal_test_context;
444
445 ///Basic integration test, can we get the terminal to show up, execute a command,
446 //and produce noticable output?
447 #[gpui::test(retries = 5)]
448 async fn test_terminal(cx: &mut TestAppContext) {
449 let mut cx = TerminalTestContext::new(cx);
450
451 cx.execute_and_wait("expr 3 + 4", |content, _cx| content.contains("7"))
452 .await;
453 }
454
455 ///Working directory calculation tests
456
457 ///No Worktrees in project -> home_dir()
458 #[gpui::test]
459 async fn no_worktree(cx: &mut TestAppContext) {
460 //Setup variables
461 let params = cx.update(AppState::test);
462 let project = Project::test(params.fs.clone(), [], cx).await;
463 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
464
465 //Test
466 cx.read(|cx| {
467 let workspace = workspace.read(cx);
468 let active_entry = project.read(cx).active_entry();
469
470 //Make sure enviroment is as expeted
471 assert!(active_entry.is_none());
472 assert!(workspace.worktrees(cx).next().is_none());
473
474 let res = current_project_directory(workspace, cx);
475 assert_eq!(res, None);
476 let res = first_project_directory(workspace, cx);
477 assert_eq!(res, None);
478 });
479 }
480
481 ///No active entry, but a worktree, worktree is a file -> home_dir()
482 #[gpui::test]
483 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
484 //Setup variables
485 let params = cx.update(AppState::test);
486 let project = Project::test(params.fs.clone(), [], cx).await;
487 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
488 let (wt, _) = project
489 .update(cx, |project, cx| {
490 project.find_or_create_local_worktree("/root.txt", true, cx)
491 })
492 .await
493 .unwrap();
494
495 cx.update(|cx| {
496 wt.update(cx, |wt, cx| {
497 wt.as_local()
498 .unwrap()
499 .create_entry(Path::new(""), false, cx)
500 })
501 })
502 .await
503 .unwrap();
504
505 //Test
506 cx.read(|cx| {
507 let workspace = workspace.read(cx);
508 let active_entry = project.read(cx).active_entry();
509
510 //Make sure enviroment is as expeted
511 assert!(active_entry.is_none());
512 assert!(workspace.worktrees(cx).next().is_some());
513
514 let res = current_project_directory(workspace, cx);
515 assert_eq!(res, None);
516 let res = first_project_directory(workspace, cx);
517 assert_eq!(res, None);
518 });
519 }
520
521 //No active entry, but a worktree, worktree is a folder -> worktree_folder
522 #[gpui::test]
523 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
524 //Setup variables
525 let params = cx.update(AppState::test);
526 let project = Project::test(params.fs.clone(), [], cx).await;
527 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
528 let (wt, _) = project
529 .update(cx, |project, cx| {
530 project.find_or_create_local_worktree("/root/", true, cx)
531 })
532 .await
533 .unwrap();
534
535 //Setup root folder
536 cx.update(|cx| {
537 wt.update(cx, |wt, cx| {
538 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
539 })
540 })
541 .await
542 .unwrap();
543
544 //Test
545 cx.update(|cx| {
546 let workspace = workspace.read(cx);
547 let active_entry = project.read(cx).active_entry();
548
549 assert!(active_entry.is_none());
550 assert!(workspace.worktrees(cx).next().is_some());
551
552 let res = current_project_directory(workspace, cx);
553 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
554 let res = first_project_directory(workspace, cx);
555 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
556 });
557 }
558
559 //Active entry with a work tree, worktree is a file -> home_dir()
560 #[gpui::test]
561 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
562 //Setup variables
563 let params = cx.update(AppState::test);
564 let project = Project::test(params.fs.clone(), [], cx).await;
565 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
566 let (wt1, _) = project
567 .update(cx, |project, cx| {
568 project.find_or_create_local_worktree("/root1/", true, cx)
569 })
570 .await
571 .unwrap();
572
573 let (wt2, _) = project
574 .update(cx, |project, cx| {
575 project.find_or_create_local_worktree("/root2.txt", true, cx)
576 })
577 .await
578 .unwrap();
579
580 //Setup root
581 let _ = cx
582 .update(|cx| {
583 wt1.update(cx, |wt, cx| {
584 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
585 })
586 })
587 .await
588 .unwrap();
589 let entry2 = cx
590 .update(|cx| {
591 wt2.update(cx, |wt, cx| {
592 wt.as_local()
593 .unwrap()
594 .create_entry(Path::new(""), false, cx)
595 })
596 })
597 .await
598 .unwrap();
599
600 cx.update(|cx| {
601 let p = ProjectPath {
602 worktree_id: wt2.read(cx).id(),
603 path: entry2.path,
604 };
605 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
606 });
607
608 //Test
609 cx.update(|cx| {
610 let workspace = workspace.read(cx);
611 let active_entry = project.read(cx).active_entry();
612
613 assert!(active_entry.is_some());
614
615 let res = current_project_directory(workspace, cx);
616 assert_eq!(res, None);
617 let res = first_project_directory(workspace, cx);
618 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
619 });
620 }
621
622 //Active entry, with a worktree, worktree is a folder -> worktree_folder
623 #[gpui::test]
624 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
625 //Setup variables
626 let params = cx.update(AppState::test);
627 let project = Project::test(params.fs.clone(), [], cx).await;
628 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
629 let (wt1, _) = project
630 .update(cx, |project, cx| {
631 project.find_or_create_local_worktree("/root1/", true, cx)
632 })
633 .await
634 .unwrap();
635
636 let (wt2, _) = project
637 .update(cx, |project, cx| {
638 project.find_or_create_local_worktree("/root2/", true, cx)
639 })
640 .await
641 .unwrap();
642
643 //Setup root
644 let _ = cx
645 .update(|cx| {
646 wt1.update(cx, |wt, cx| {
647 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
648 })
649 })
650 .await
651 .unwrap();
652 let entry2 = cx
653 .update(|cx| {
654 wt2.update(cx, |wt, cx| {
655 wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
656 })
657 })
658 .await
659 .unwrap();
660
661 cx.update(|cx| {
662 let p = ProjectPath {
663 worktree_id: wt2.read(cx).id(),
664 path: entry2.path,
665 };
666 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
667 });
668
669 //Test
670 cx.update(|cx| {
671 let workspace = workspace.read(cx);
672 let active_entry = project.read(cx).active_entry();
673
674 assert!(active_entry.is_some());
675
676 let res = current_project_directory(workspace, cx);
677 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
678 let res = first_project_directory(workspace, cx);
679 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
680 });
681 }
682}