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