terminal.rs

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