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