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