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
176impl View for ErrorView {
177    fn ui_name() -> &'static str {
178        "DisconnectedTerminal"
179    }
180
181    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
182        let settings = cx.global::<Settings>();
183        let style = TerminalEl::make_text_style(cx.font_cache(), settings);
184
185        //TODO:
186        //We want markdown style highlighting so we can format the program and working directory with ``
187        //We want a max-width of 75% with word-wrap
188        //We want to be able to select the text
189        //Want to be able to scroll if the error message is massive somehow (resiliency)
190
191        let program_text = {
192            match self.error.shell_to_string() {
193                Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
194                None => "No program specified".to_string(),
195            }
196        };
197
198        let directory_text = {
199            match self.error.directory.as_ref() {
200                Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
201                None => "No working directory specified".to_string(),
202            }
203        };
204
205        let error_text = self.error.source.to_string();
206
207        Flex::column()
208            .with_child(
209                Text::new("Failed to open the terminal.".to_string(), style.clone())
210                    .contained()
211                    .boxed(),
212            )
213            .with_child(Text::new(program_text, style.clone()).contained().boxed())
214            .with_child(Text::new(directory_text, style.clone()).contained().boxed())
215            .with_child(Text::new(error_text, style.clone()).contained().boxed())
216            .aligned()
217            .boxed()
218    }
219}
220
221impl Item for TerminalView {
222    fn tab_content(
223        &self,
224        _detail: Option<usize>,
225        tab_theme: &theme::Tab,
226        cx: &gpui::AppContext,
227    ) -> ElementBox {
228        let title = match &self.content {
229            TerminalContent::Connected(connected) => {
230                connected.read(cx).handle().read(cx).title.clone()
231            }
232            TerminalContent::Error(_) => "Terminal".to_string(),
233        };
234
235        Flex::row()
236            .with_child(
237                Label::new(title, tab_theme.label.clone())
238                    .aligned()
239                    .contained()
240                    .boxed(),
241            )
242            .boxed()
243    }
244
245    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
246        //From what I can tell, there's no  way to tell the current working
247        //Directory of the terminal from outside the shell. There might be
248        //solutions to this, but they are non-trivial and require more IPC
249        Some(TerminalView::new(
250            self.associated_directory.clone(),
251            false,
252            cx,
253        ))
254    }
255
256    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
257        None
258    }
259
260    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
261        SmallVec::new()
262    }
263
264    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
265        false
266    }
267
268    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
269
270    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
271        false
272    }
273
274    fn save(
275        &mut self,
276        _project: gpui::ModelHandle<Project>,
277        _cx: &mut ViewContext<Self>,
278    ) -> gpui::Task<gpui::anyhow::Result<()>> {
279        unreachable!("save should not have been called");
280    }
281
282    fn save_as(
283        &mut self,
284        _project: gpui::ModelHandle<Project>,
285        _abs_path: std::path::PathBuf,
286        _cx: &mut ViewContext<Self>,
287    ) -> gpui::Task<gpui::anyhow::Result<()>> {
288        unreachable!("save_as should not have been called");
289    }
290
291    fn reload(
292        &mut self,
293        _project: gpui::ModelHandle<Project>,
294        _cx: &mut ViewContext<Self>,
295    ) -> gpui::Task<gpui::anyhow::Result<()>> {
296        gpui::Task::ready(Ok(()))
297    }
298
299    fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
300        if let TerminalContent::Connected(connected) = &self.content {
301            connected.read(cx).has_new_content()
302        } else {
303            false
304        }
305    }
306
307    fn has_conflict(&self, cx: &AppContext) -> bool {
308        if let TerminalContent::Connected(connected) = &self.content {
309            connected.read(cx).has_bell()
310        } else {
311            false
312        }
313    }
314
315    fn should_update_tab_on_event(event: &Self::Event) -> bool {
316        matches!(event, &Event::TitleChanged)
317    }
318
319    fn should_close_item_on_event(event: &Self::Event) -> bool {
320        matches!(event, &Event::CloseTerminal)
321    }
322
323    fn should_activate_item_on_event(event: &Self::Event) -> bool {
324        matches!(event, &Event::Activate)
325    }
326}
327
328///Get's the working directory for the given workspace, respecting the user's settings.
329fn get_working_directory(
330    workspace: &Workspace,
331    cx: &AppContext,
332    strategy: WorkingDirectory,
333) -> Option<PathBuf> {
334    let res = match strategy {
335        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
336            .or_else(|| first_project_directory(workspace, cx)),
337        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
338        WorkingDirectory::AlwaysHome => None,
339        WorkingDirectory::Always { directory } => {
340            shellexpand::full(&directory) //TODO handle this better
341                .ok()
342                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
343                .filter(|dir| dir.is_dir())
344        }
345    };
346    res.or_else(|| home_dir())
347}
348
349///Get's the first project's home directory, or the home directory
350fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
351    workspace
352        .worktrees(cx)
353        .next()
354        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
355        .and_then(get_path_from_wt)
356}
357
358///Gets the intuitively correct working directory from the given workspace
359///If there is an active entry for this project, returns that entry's worktree root.
360///If there's no active entry but there is a worktree, returns that worktrees root.
361///If either of these roots are files, or if there are any other query failures,
362///  returns the user's home directory
363fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
364    let project = workspace.project().read(cx);
365
366    project
367        .active_entry()
368        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
369        .or_else(|| workspace.worktrees(cx).next())
370        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
371        .and_then(get_path_from_wt)
372}
373
374fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
375    wt.root_entry()
376        .filter(|re| re.is_dir())
377        .map(|_| wt.abs_path().to_path_buf())
378}
379
380#[cfg(test)]
381mod tests {
382
383    use crate::tests::terminal_test_context::TerminalTestContext;
384
385    use super::*;
386    use gpui::TestAppContext;
387
388    use std::path::Path;
389
390    mod terminal_test_context;
391
392    ///Basic integration test, can we get the terminal to show up, execute a command,
393    //and produce noticable output?
394    #[gpui::test(retries = 5)]
395    async fn test_terminal(cx: &mut TestAppContext) {
396        let mut cx = TerminalTestContext::new(cx, true);
397
398        cx.execute_and_wait("expr 3 + 4", |content, _cx| content.contains("7"))
399            .await;
400    }
401
402    ///Working directory calculation tests
403
404    ///No Worktrees in project -> home_dir()
405    #[gpui::test]
406    async fn no_worktree(cx: &mut TestAppContext) {
407        //Setup variables
408        let mut cx = TerminalTestContext::new(cx, true);
409        let (project, workspace) = cx.blank_workspace().await;
410        //Test
411        cx.cx.read(|cx| {
412            let workspace = workspace.read(cx);
413            let active_entry = project.read(cx).active_entry();
414
415            //Make sure enviroment is as expeted
416            assert!(active_entry.is_none());
417            assert!(workspace.worktrees(cx).next().is_none());
418
419            let res = current_project_directory(workspace, cx);
420            assert_eq!(res, None);
421            let res = first_project_directory(workspace, cx);
422            assert_eq!(res, None);
423        });
424    }
425
426    ///No active entry, but a worktree, worktree is a file -> home_dir()
427    #[gpui::test]
428    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
429        //Setup variables
430
431        let mut cx = TerminalTestContext::new(cx, true);
432        let (project, workspace) = cx.blank_workspace().await;
433        cx.create_file_wt(project.clone(), "/root.txt").await;
434
435        cx.cx.read(|cx| {
436            let workspace = workspace.read(cx);
437            let active_entry = project.read(cx).active_entry();
438
439            //Make sure enviroment is as expeted
440            assert!(active_entry.is_none());
441            assert!(workspace.worktrees(cx).next().is_some());
442
443            let res = current_project_directory(workspace, cx);
444            assert_eq!(res, None);
445            let res = first_project_directory(workspace, cx);
446            assert_eq!(res, None);
447        });
448    }
449
450    //No active entry, but a worktree, worktree is a folder -> worktree_folder
451    #[gpui::test]
452    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
453        //Setup variables
454        let mut cx = TerminalTestContext::new(cx, true);
455        let (project, workspace) = cx.blank_workspace().await;
456        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
457
458        //Test
459        cx.cx.update(|cx| {
460            let workspace = workspace.read(cx);
461            let active_entry = project.read(cx).active_entry();
462
463            assert!(active_entry.is_none());
464            assert!(workspace.worktrees(cx).next().is_some());
465
466            let res = current_project_directory(workspace, cx);
467            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
468            let res = first_project_directory(workspace, cx);
469            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
470        });
471    }
472
473    //Active entry with a work tree, worktree is a file -> home_dir()
474    #[gpui::test]
475    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
476        //Setup variables
477        let mut cx = TerminalTestContext::new(cx, true);
478        let (project, workspace) = cx.blank_workspace().await;
479        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
480        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
481        cx.insert_active_entry_for(wt2, entry2, project.clone());
482
483        //Test
484        cx.cx.update(|cx| {
485            let workspace = workspace.read(cx);
486            let active_entry = project.read(cx).active_entry();
487
488            assert!(active_entry.is_some());
489
490            let res = current_project_directory(workspace, cx);
491            assert_eq!(res, None);
492            let res = first_project_directory(workspace, cx);
493            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
494        });
495    }
496
497    //Active entry, with a worktree, worktree is a folder -> worktree_folder
498    #[gpui::test]
499    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
500        //Setup variables
501        let mut cx = TerminalTestContext::new(cx, true);
502        let (project, workspace) = cx.blank_workspace().await;
503        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
504        let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
505        cx.insert_active_entry_for(wt2, entry2, project.clone());
506
507        //Test
508        cx.cx.update(|cx| {
509            let workspace = workspace.read(cx);
510            let active_entry = project.read(cx).active_entry();
511
512            assert!(active_entry.is_some());
513
514            let res = current_project_directory(workspace, cx);
515            assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
516            let res = first_project_directory(workspace, cx);
517            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
518        });
519    }
520
521    //Active entry with a work tree, worktree is a file, integration test with the strategy interface
522    #[gpui::test]
523    async fn active_entry_worktree_is_file_int(cx: &mut TestAppContext) {
524        //Setup variables
525        let mut cx = TerminalTestContext::new(cx, true);
526        let (project, workspace) = cx.blank_workspace().await;
527        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
528        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
529        cx.insert_active_entry_for(wt2, entry2, project.clone());
530
531        //Test
532        cx.cx.update(|cx| {
533            let workspace = workspace.read(cx);
534            let active_entry = project.read(cx).active_entry();
535
536            assert!(active_entry.is_some());
537
538            let res =
539                get_working_directory(workspace, cx, WorkingDirectory::CurrentProjectDirectory);
540            let first = first_project_directory(workspace, cx);
541            assert_eq!(res, first);
542        });
543    }
544}