terminal_tab.rs

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