terminal.rs

  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}