terminal_container_view.rs

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