terminal.rs

  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}