terminal.rs

  1pub mod connected_el;
  2pub mod connected_view;
  3pub mod mappings;
  4pub mod modal_view;
  5pub mod model;
  6
  7use connected_view::ConnectedView;
  8use dirs::home_dir;
  9use gpui::{
 10    actions, elements::*, geometry::vector::vec2f, AnyViewHandle, AppContext, Entity, ModelHandle,
 11    MutableAppContext, View, ViewContext, ViewHandle,
 12};
 13use modal_view::deploy_modal;
 14use model::{Event, Terminal, TerminalBuilder, TerminalError};
 15
 16use connected_el::TermDimensions;
 17use project::{LocalWorktree, Project, ProjectPath};
 18use settings::{Settings, WorkingDirectory};
 19use smallvec::SmallVec;
 20use std::path::{Path, PathBuf};
 21use workspace::{Item, Workspace};
 22
 23use crate::connected_el::TerminalEl;
 24
 25const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
 26const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
 27const DEBUG_CELL_WIDTH: f32 = 5.;
 28const DEBUG_LINE_HEIGHT: f32 = 5.;
 29
 30actions!(terminal, [Deploy, DeployModal]);
 31
 32///Initialize and register all of our action handlers
 33pub fn init(cx: &mut MutableAppContext) {
 34    cx.add_action(TerminalView::deploy);
 35    cx.add_action(deploy_modal);
 36
 37    connected_view::init(cx);
 38}
 39
 40//Make terminal view an enum, that can give you views for the error and non-error states
 41//Take away all the result unwrapping in the current TerminalView by making it 'infallible'
 42//Bubble up to deploy(_modal)() calls
 43
 44enum TerminalContent {
 45    Connected(ViewHandle<ConnectedView>),
 46    Error(ViewHandle<ErrorView>),
 47}
 48
 49impl TerminalContent {
 50    fn handle(&self) -> AnyViewHandle {
 51        match self {
 52            Self::Connected(handle) => handle.into(),
 53            Self::Error(handle) => handle.into(),
 54        }
 55    }
 56}
 57
 58pub struct TerminalView {
 59    modal: bool,
 60    content: TerminalContent,
 61    associated_directory: Option<PathBuf>,
 62}
 63
 64pub struct ErrorView {
 65    error: TerminalError,
 66}
 67
 68impl Entity for TerminalView {
 69    type Event = Event;
 70}
 71
 72impl Entity for ConnectedView {
 73    type Event = Event;
 74}
 75
 76impl Entity for ErrorView {
 77    type Event = Event;
 78}
 79
 80impl TerminalView {
 81    ///Create a new Terminal in the current working directory or the user's home directory
 82    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
 83        let wd_strategy = cx
 84            .global::<Settings>()
 85            .terminal_overrides
 86            .working_directory
 87            .clone()
 88            .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
 89
 90        let working_directory = get_working_directory(workspace, cx, wd_strategy);
 91        let view = cx.add_view(|cx| TerminalView::new(working_directory, false, cx));
 92        workspace.add_item(Box::new(view), cx);
 93    }
 94
 95    ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
 96    ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
 97    fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
 98        //The details here don't matter, the terminal will be resized on the first layout
 99        let size_info = TermDimensions::new(
100            DEBUG_LINE_HEIGHT,
101            DEBUG_CELL_WIDTH,
102            vec2f(DEBUG_TERMINAL_WIDTH, DEBUG_TERMINAL_HEIGHT),
103        );
104
105        let settings = cx.global::<Settings>();
106        let shell = settings.terminal_overrides.shell.clone();
107        let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
108
109        let content = match TerminalBuilder::new(working_directory.clone(), shell, envs, size_info)
110        {
111            Ok(terminal) => {
112                let terminal = cx.add_model(|cx| terminal.subscribe(cx));
113                let view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
114                cx.subscribe(&view, |_this, _content, event, cx| cx.emit(event.clone()))
115                    .detach();
116                TerminalContent::Connected(view)
117            }
118            Err(error) => {
119                let view = cx.add_view(|_| ErrorView {
120                    error: error.downcast::<TerminalError>().unwrap(),
121                });
122                TerminalContent::Error(view)
123            }
124        };
125        cx.focus(content.handle());
126
127        TerminalView {
128            modal,
129            content,
130            associated_directory: working_directory,
131        }
132    }
133
134    fn from_terminal(
135        terminal: ModelHandle<Terminal>,
136        modal: bool,
137        cx: &mut ViewContext<Self>,
138    ) -> Self {
139        let connected_view = cx.add_view(|cx| ConnectedView::from_terminal(terminal, modal, cx));
140        TerminalView {
141            modal,
142            content: TerminalContent::Connected(connected_view),
143            associated_directory: None,
144        }
145    }
146}
147
148impl View for TerminalView {
149    fn ui_name() -> &'static str {
150        "Terminal View"
151    }
152
153    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
154        let child_view = match &self.content {
155            TerminalContent::Connected(connected) => ChildView::new(connected),
156            TerminalContent::Error(error) => ChildView::new(error),
157        };
158
159        if self.modal {
160            let settings = cx.global::<Settings>();
161            let container_style = settings.theme.terminal.modal_container;
162            child_view.contained().with_style(container_style).boxed()
163        } else {
164            child_view.boxed()
165        }
166    }
167
168    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
169        cx.emit(Event::Activate);
170        cx.defer(|view, cx| {
171            cx.focus(view.content.handle());
172        });
173    }
174
175    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
176        let mut context = Self::default_keymap_context();
177        if self.modal {
178            context.set.insert("ModalTerminal".into());
179        }
180        context
181    }
182}
183
184impl View for ErrorView {
185    fn ui_name() -> &'static str {
186        "DisconnectedTerminal"
187    }
188
189    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
190        let settings = cx.global::<Settings>();
191        let style = TerminalEl::make_text_style(cx.font_cache(), settings);
192
193        //TODO:
194        //We want markdown style highlighting so we can format the program and working directory with ``
195        //We want a max-width of 75% with word-wrap
196        //We want to be able to select the text
197        //Want to be able to scroll if the error message is massive somehow (resiliency)
198
199        let program_text = {
200            match self.error.shell_to_string() {
201                Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
202                None => "No program specified".to_string(),
203            }
204        };
205
206        let directory_text = {
207            match self.error.directory.as_ref() {
208                Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
209                None => "No working directory specified".to_string(),
210            }
211        };
212
213        let error_text = self.error.source.to_string();
214
215        Flex::column()
216            .with_child(
217                Text::new("Failed to open the terminal.".to_string(), style.clone())
218                    .contained()
219                    .boxed(),
220            )
221            .with_child(Text::new(program_text, style.clone()).contained().boxed())
222            .with_child(Text::new(directory_text, style.clone()).contained().boxed())
223            .with_child(Text::new(error_text, style.clone()).contained().boxed())
224            .aligned()
225            .boxed()
226    }
227}
228
229impl Item for TerminalView {
230    fn tab_content(
231        &self,
232        _detail: Option<usize>,
233        tab_theme: &theme::Tab,
234        cx: &gpui::AppContext,
235    ) -> ElementBox {
236        let title = match &self.content {
237            TerminalContent::Connected(connected) => {
238                connected.read(cx).handle().read(cx).title.clone()
239            }
240            TerminalContent::Error(_) => "Terminal".to_string(),
241        };
242
243        Flex::row()
244            .with_child(
245                Label::new(title, tab_theme.label.clone())
246                    .aligned()
247                    .contained()
248                    .boxed(),
249            )
250            .boxed()
251    }
252
253    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
254        //From what I can tell, there's no  way to tell the current working
255        //Directory of the terminal from outside the shell. There might be
256        //solutions to this, but they are non-trivial and require more IPC
257        Some(TerminalView::new(
258            self.associated_directory.clone(),
259            false,
260            cx,
261        ))
262    }
263
264    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
265        None
266    }
267
268    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
269        SmallVec::new()
270    }
271
272    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
273        false
274    }
275
276    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
277
278    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
279        false
280    }
281
282    fn save(
283        &mut self,
284        _project: gpui::ModelHandle<Project>,
285        _cx: &mut ViewContext<Self>,
286    ) -> gpui::Task<gpui::anyhow::Result<()>> {
287        unreachable!("save should not have been called");
288    }
289
290    fn save_as(
291        &mut self,
292        _project: gpui::ModelHandle<Project>,
293        _abs_path: std::path::PathBuf,
294        _cx: &mut ViewContext<Self>,
295    ) -> gpui::Task<gpui::anyhow::Result<()>> {
296        unreachable!("save_as should not have been called");
297    }
298
299    fn reload(
300        &mut self,
301        _project: gpui::ModelHandle<Project>,
302        _cx: &mut ViewContext<Self>,
303    ) -> gpui::Task<gpui::anyhow::Result<()>> {
304        gpui::Task::ready(Ok(()))
305    }
306
307    fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
308        if let TerminalContent::Connected(connected) = &self.content {
309            connected.read(cx).has_new_content()
310        } else {
311            false
312        }
313    }
314
315    fn has_conflict(&self, cx: &AppContext) -> bool {
316        if let TerminalContent::Connected(connected) = &self.content {
317            connected.read(cx).has_bell()
318        } else {
319            false
320        }
321    }
322
323    fn should_update_tab_on_event(event: &Self::Event) -> bool {
324        matches!(event, &Event::TitleChanged)
325    }
326
327    fn should_close_item_on_event(event: &Self::Event) -> bool {
328        matches!(event, &Event::CloseTerminal)
329    }
330
331    fn should_activate_item_on_event(event: &Self::Event) -> bool {
332        matches!(event, &Event::Activate)
333    }
334}
335
336///Get's the working directory for the given workspace, respecting the user's settings.
337fn get_working_directory(
338    workspace: &Workspace,
339    cx: &AppContext,
340    strategy: WorkingDirectory,
341) -> Option<PathBuf> {
342    let res = match strategy {
343        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
344            .or_else(|| first_project_directory(workspace, cx)),
345        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
346        WorkingDirectory::AlwaysHome => None,
347        WorkingDirectory::Always { directory } => {
348            shellexpand::full(&directory) //TODO handle this better
349                .ok()
350                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
351                .filter(|dir| dir.is_dir())
352        }
353    };
354    res.or_else(|| home_dir())
355}
356
357///Get's the first project's home directory, or the home directory
358fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
359    workspace
360        .worktrees(cx)
361        .next()
362        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
363        .and_then(get_path_from_wt)
364}
365
366///Gets the intuitively correct working directory from the given workspace
367///If there is an active entry for this project, returns that entry's worktree root.
368///If there's no active entry but there is a worktree, returns that worktrees root.
369///If either of these roots are files, or if there are any other query failures,
370///  returns the user's home directory
371fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
372    let project = workspace.project().read(cx);
373
374    project
375        .active_entry()
376        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
377        .or_else(|| workspace.worktrees(cx).next())
378        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
379        .and_then(get_path_from_wt)
380}
381
382fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
383    wt.root_entry()
384        .filter(|re| re.is_dir())
385        .map(|_| wt.abs_path().to_path_buf())
386}
387
388#[cfg(test)]
389mod tests {
390
391    use crate::tests::terminal_test_context::TerminalTestContext;
392
393    use super::*;
394    use gpui::TestAppContext;
395
396    use std::path::Path;
397
398    mod terminal_test_context;
399
400    ///Basic integration test, can we get the terminal to show up, execute a command,
401    //and produce noticable output?
402    #[gpui::test(retries = 5)]
403    async fn test_terminal(cx: &mut TestAppContext) {
404        let mut cx = TerminalTestContext::new(cx, true);
405
406        cx.execute_and_wait("expr 3 + 4", |content, _cx| content.contains("7"))
407            .await;
408    }
409
410    ///Working directory calculation tests
411
412    ///No Worktrees in project -> home_dir()
413    #[gpui::test]
414    async fn no_worktree(cx: &mut TestAppContext) {
415        //Setup variables
416        let mut cx = TerminalTestContext::new(cx, true);
417        let (project, workspace) = cx.blank_workspace().await;
418        //Test
419        cx.cx.read(|cx| {
420            let workspace = workspace.read(cx);
421            let active_entry = project.read(cx).active_entry();
422
423            //Make sure enviroment is as expeted
424            assert!(active_entry.is_none());
425            assert!(workspace.worktrees(cx).next().is_none());
426
427            let res = current_project_directory(workspace, cx);
428            assert_eq!(res, None);
429            let res = first_project_directory(workspace, cx);
430            assert_eq!(res, None);
431        });
432    }
433
434    ///No active entry, but a worktree, worktree is a file -> home_dir()
435    #[gpui::test]
436    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
437        //Setup variables
438
439        let mut cx = TerminalTestContext::new(cx, true);
440        let (project, workspace) = cx.blank_workspace().await;
441        cx.create_file_wt(project.clone(), "/root.txt").await;
442
443        cx.cx.read(|cx| {
444            let workspace = workspace.read(cx);
445            let active_entry = project.read(cx).active_entry();
446
447            //Make sure enviroment is as expeted
448            assert!(active_entry.is_none());
449            assert!(workspace.worktrees(cx).next().is_some());
450
451            let res = current_project_directory(workspace, cx);
452            assert_eq!(res, None);
453            let res = first_project_directory(workspace, cx);
454            assert_eq!(res, None);
455        });
456    }
457
458    //No active entry, but a worktree, worktree is a folder -> worktree_folder
459    #[gpui::test]
460    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
461        //Setup variables
462        let mut cx = TerminalTestContext::new(cx, true);
463        let (project, workspace) = cx.blank_workspace().await;
464        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
465
466        //Test
467        cx.cx.update(|cx| {
468            let workspace = workspace.read(cx);
469            let active_entry = project.read(cx).active_entry();
470
471            assert!(active_entry.is_none());
472            assert!(workspace.worktrees(cx).next().is_some());
473
474            let res = current_project_directory(workspace, cx);
475            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
476            let res = first_project_directory(workspace, cx);
477            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
478        });
479    }
480
481    //Active entry with a work tree, worktree is a file -> home_dir()
482    #[gpui::test]
483    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
484        //Setup variables
485        let mut cx = TerminalTestContext::new(cx, true);
486        let (project, workspace) = cx.blank_workspace().await;
487        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
488        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
489        cx.insert_active_entry_for(wt2, entry2, project.clone());
490
491        //Test
492        cx.cx.update(|cx| {
493            let workspace = workspace.read(cx);
494            let active_entry = project.read(cx).active_entry();
495
496            assert!(active_entry.is_some());
497
498            let res = current_project_directory(workspace, cx);
499            assert_eq!(res, None);
500            let res = first_project_directory(workspace, cx);
501            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
502        });
503    }
504
505    //Active entry, with a worktree, worktree is a folder -> worktree_folder
506    #[gpui::test]
507    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
508        //Setup variables
509        let mut cx = TerminalTestContext::new(cx, true);
510        let (project, workspace) = cx.blank_workspace().await;
511        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
512        let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
513        cx.insert_active_entry_for(wt2, entry2, project.clone());
514
515        //Test
516        cx.cx.update(|cx| {
517            let workspace = workspace.read(cx);
518            let active_entry = project.read(cx).active_entry();
519
520            assert!(active_entry.is_some());
521
522            let res = current_project_directory(workspace, cx);
523            assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
524            let res = first_project_directory(workspace, cx);
525            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
526        });
527    }
528
529    //Active entry with a work tree, worktree is a file, integration test with the strategy interface
530    #[gpui::test]
531    async fn active_entry_worktree_is_file_int(cx: &mut TestAppContext) {
532        //Setup variables
533        let mut cx = TerminalTestContext::new(cx, true);
534        let (project, workspace) = cx.blank_workspace().await;
535        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
536        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
537        cx.insert_active_entry_for(wt2, entry2, project.clone());
538
539        //Test
540        cx.cx.update(|cx| {
541            let workspace = workspace.read(cx);
542            let active_entry = project.read(cx).active_entry();
543
544            assert!(active_entry.is_some());
545
546            let res =
547                get_working_directory(workspace, cx, WorkingDirectory::CurrentProjectDirectory);
548            let first = first_project_directory(workspace, cx);
549            assert_eq!(res, first);
550        });
551    }
552}