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    fn connected(&self) -> Option<ViewHandle<TerminalView>> {
154        match &self.content {
155            TerminalContainerContent::Connected(vh) => Some(vh.clone()),
156            TerminalContainerContent::Error(_) => None,
157        }
158    }
159}
160
161impl View for TerminalContainer {
162    fn ui_name() -> &'static str {
163        "Terminal"
164    }
165
166    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
167        let child_view = match &self.content {
168            TerminalContainerContent::Connected(connected) => ChildView::new(connected),
169            TerminalContainerContent::Error(error) => ChildView::new(error),
170        };
171        if self.modal {
172            let settings = cx.global::<Settings>();
173            let container_style = settings.theme.terminal.modal_container;
174            child_view.contained().with_style(container_style).boxed()
175        } else {
176            child_view.boxed()
177        }
178    }
179
180    fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
181        if cx.is_self_focused() {
182            cx.focus(self.content.handle());
183        }
184    }
185
186    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
187        let mut context = Self::default_keymap_context();
188        if self.modal {
189            context.set.insert("ModalTerminal".into());
190        }
191        context
192    }
193}
194
195impl View for ErrorView {
196    fn ui_name() -> &'static str {
197        "Terminal Error"
198    }
199
200    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
201        let settings = cx.global::<Settings>();
202        let style = TerminalElement::make_text_style(cx.font_cache(), settings);
203
204        //TODO:
205        //We want markdown style highlighting so we can format the program and working directory with ``
206        //We want a max-width of 75% with word-wrap
207        //We want to be able to select the text
208        //Want to be able to scroll if the error message is massive somehow (resiliency)
209
210        let program_text = {
211            match self.error.shell_to_string() {
212                Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
213                None => "No program specified".to_string(),
214            }
215        };
216
217        let directory_text = {
218            match self.error.directory.as_ref() {
219                Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
220                None => "No working directory specified".to_string(),
221            }
222        };
223
224        let error_text = self.error.source.to_string();
225
226        Flex::column()
227            .with_child(
228                Text::new("Failed to open the terminal.".to_string(), style.clone())
229                    .contained()
230                    .boxed(),
231            )
232            .with_child(Text::new(program_text, style.clone()).contained().boxed())
233            .with_child(Text::new(directory_text, style.clone()).contained().boxed())
234            .with_child(Text::new(error_text, style).contained().boxed())
235            .aligned()
236            .boxed()
237    }
238}
239
240impl Item for TerminalContainer {
241    fn tab_content(
242        &self,
243        _detail: Option<usize>,
244        tab_theme: &theme::Tab,
245        cx: &gpui::AppContext,
246    ) -> ElementBox {
247        let title = match &self.content {
248            TerminalContainerContent::Connected(connected) => connected
249                .read(cx)
250                .handle()
251                .read(cx)
252                .foreground_process_info
253                .as_ref()
254                .map(|fpi| {
255                    format!(
256                        "{} - {} {}",
257                        fpi.cwd
258                            .file_name()
259                            .map(|name| name.to_string_lossy().to_string())
260                            .unwrap_or_default(),
261                        fpi.name,
262                        (&fpi.argv[1..]).join(" ")
263                    )
264                })
265                .unwrap_or_else(|| "Terminal".to_string()),
266            TerminalContainerContent::Error(_) => "Terminal".to_string(),
267        };
268
269        Flex::row()
270            .with_child(
271                Label::new(title, tab_theme.label.clone())
272                    .aligned()
273                    .contained()
274                    .boxed(),
275            )
276            .boxed()
277    }
278
279    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
280        //From what I can tell, there's no  way to tell the current working
281        //Directory of the terminal from outside the shell. There might be
282        //solutions to this, but they are non-trivial and require more IPC
283        Some(TerminalContainer::new(
284            self.associated_directory.clone(),
285            false,
286            cx,
287        ))
288    }
289
290    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
291        None
292    }
293
294    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
295        SmallVec::new()
296    }
297
298    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
299        false
300    }
301
302    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
303
304    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
305        false
306    }
307
308    fn save(
309        &mut self,
310        _project: gpui::ModelHandle<Project>,
311        _cx: &mut ViewContext<Self>,
312    ) -> gpui::Task<gpui::anyhow::Result<()>> {
313        unreachable!("save should not have been called");
314    }
315
316    fn save_as(
317        &mut self,
318        _project: gpui::ModelHandle<Project>,
319        _abs_path: std::path::PathBuf,
320        _cx: &mut ViewContext<Self>,
321    ) -> gpui::Task<gpui::anyhow::Result<()>> {
322        unreachable!("save_as should not have been called");
323    }
324
325    fn reload(
326        &mut self,
327        _project: gpui::ModelHandle<Project>,
328        _cx: &mut ViewContext<Self>,
329    ) -> gpui::Task<gpui::anyhow::Result<()>> {
330        gpui::Task::ready(Ok(()))
331    }
332
333    fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
334        if let TerminalContainerContent::Connected(connected) = &self.content {
335            connected.read(cx).has_bell()
336        } else {
337            false
338        }
339    }
340
341    fn has_conflict(&self, _cx: &AppContext) -> bool {
342        false
343    }
344
345    fn should_update_tab_on_event(event: &Self::Event) -> bool {
346        matches!(event, &Event::TitleChanged | &Event::Wakeup)
347    }
348
349    fn should_close_item_on_event(event: &Self::Event) -> bool {
350        matches!(event, &Event::CloseTerminal)
351    }
352
353    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
354        Some(Box::new(handle.clone()))
355    }
356}
357
358impl SearchableItem for TerminalContainer {
359    type Match = RangeInclusive<Point>;
360
361    fn supported_options() -> SearchOptions {
362        SearchOptions {
363            case: false,
364            word: false,
365            regex: false,
366        }
367    }
368
369    /// Convert events raised by this item into search-relevant events (if applicable)
370    fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
371        match event {
372            Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
373            Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
374            _ => None,
375        }
376    }
377
378    /// Clear stored matches
379    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
380        if let TerminalContainerContent::Connected(connected) = &self.content {
381            let terminal = connected.read(cx).terminal().clone();
382            terminal.update(cx, |term, _| term.matches.clear())
383        }
384    }
385
386    /// Store matches returned from find_matches somewhere for rendering
387    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
388        if let TerminalContainerContent::Connected(connected) = &self.content {
389            let terminal = connected.read(cx).terminal().clone();
390            terminal.update(cx, |term, _| term.matches = matches)
391        }
392    }
393
394    /// Return the selection content to pre-load into this search
395    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
396        if let TerminalContainerContent::Connected(connected) = &self.content {
397            let terminal = connected.read(cx).terminal().clone();
398            terminal
399                .read(cx)
400                .last_content
401                .selection_text
402                .clone()
403                .unwrap_or_default()
404        } else {
405            Default::default()
406        }
407    }
408
409    /// Focus match at given index into the Vec of matches
410    fn activate_match(&mut self, index: usize, _: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
411        if let TerminalContainerContent::Connected(connected) = &self.content {
412            let terminal = connected.read(cx).terminal().clone();
413            terminal.update(cx, |term, _| term.activate_match(index));
414            cx.notify();
415        }
416    }
417
418    /// Get all of the matches for this query, should be done on the background
419    fn find_matches(
420        &mut self,
421        query: project::search::SearchQuery,
422        cx: &mut ViewContext<Self>,
423    ) -> Task<Vec<Self::Match>> {
424        if let TerminalContainerContent::Connected(connected) = &self.content {
425            let terminal = connected.read(cx).terminal().clone();
426            terminal.update(cx, |term, cx| term.find_matches(query, cx))
427        } else {
428            Task::ready(Vec::new())
429        }
430    }
431
432    /// Reports back to the search toolbar what the active match should be (the selection)
433    fn active_match_index(
434        &mut self,
435        matches: Vec<Self::Match>,
436        cx: &mut ViewContext<Self>,
437    ) -> Option<usize> {
438        let connected = self.connected();
439        // Selection head might have a value if there's a selection that isn't
440        // associated with a match. Therefore, if there are no matches, we should
441        // report None, no matter the state of the terminal
442        let res = if matches.len() > 0 && connected.is_some() {
443            if let Some(selection_head) = connected
444                .unwrap()
445                .read(cx)
446                .terminal()
447                .read(cx)
448                .selection_head
449            {
450                // If selection head is contained in a match. Return that match
451                if let Some(ix) = matches
452                    .iter()
453                    .enumerate()
454                    .find(|(_, search_match)| {
455                        search_match.contains(&selection_head)
456                            || search_match.start() > &selection_head
457                    })
458                    .map(|(ix, _)| ix)
459                {
460                    Some(ix)
461                } else {
462                    // If no selection after selection head, return the last match
463                    Some(matches.len().saturating_sub(1))
464                }
465            } else {
466                // Matches found but no active selection, return the first one
467                Some(0)
468            }
469        } else {
470            None
471        };
472
473        res
474    }
475}
476
477///Get's the working directory for the given workspace, respecting the user's settings.
478pub fn get_working_directory(
479    workspace: &Workspace,
480    cx: &AppContext,
481    strategy: WorkingDirectory,
482) -> Option<PathBuf> {
483    let res = match strategy {
484        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
485            .or_else(|| first_project_directory(workspace, cx)),
486        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
487        WorkingDirectory::AlwaysHome => None,
488        WorkingDirectory::Always { directory } => {
489            shellexpand::full(&directory) //TODO handle this better
490                .ok()
491                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
492                .filter(|dir| dir.is_dir())
493        }
494    };
495    res.or_else(home_dir)
496}
497
498///Get's the first project's home directory, or the home directory
499fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
500    workspace
501        .worktrees(cx)
502        .next()
503        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
504        .and_then(get_path_from_wt)
505}
506
507///Gets the intuitively correct working directory from the given workspace
508///If there is an active entry for this project, returns that entry's worktree root.
509///If there's no active entry but there is a worktree, returns that worktrees root.
510///If either of these roots are files, or if there are any other query failures,
511///  returns the user's home directory
512fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
513    let project = workspace.project().read(cx);
514
515    project
516        .active_entry()
517        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
518        .or_else(|| workspace.worktrees(cx).next())
519        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
520        .and_then(get_path_from_wt)
521}
522
523fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
524    wt.root_entry()
525        .filter(|re| re.is_dir())
526        .map(|_| wt.abs_path().to_path_buf())
527}
528
529#[cfg(test)]
530mod tests {
531
532    use super::*;
533    use gpui::TestAppContext;
534
535    use std::path::Path;
536
537    use crate::tests::terminal_test_context::TerminalTestContext;
538
539    ///Working directory calculation tests
540
541    ///No Worktrees in project -> home_dir()
542    #[gpui::test]
543    async fn no_worktree(cx: &mut TestAppContext) {
544        //Setup variables
545        let mut cx = TerminalTestContext::new(cx);
546        let (project, workspace) = cx.blank_workspace().await;
547        //Test
548        cx.cx.read(|cx| {
549            let workspace = workspace.read(cx);
550            let active_entry = project.read(cx).active_entry();
551
552            //Make sure enviroment is as expeted
553            assert!(active_entry.is_none());
554            assert!(workspace.worktrees(cx).next().is_none());
555
556            let res = current_project_directory(workspace, cx);
557            assert_eq!(res, None);
558            let res = first_project_directory(workspace, cx);
559            assert_eq!(res, None);
560        });
561    }
562
563    ///No active entry, but a worktree, worktree is a file -> home_dir()
564    #[gpui::test]
565    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
566        //Setup variables
567
568        let mut cx = TerminalTestContext::new(cx);
569        let (project, workspace) = cx.blank_workspace().await;
570        cx.create_file_wt(project.clone(), "/root.txt").await;
571
572        cx.cx.read(|cx| {
573            let workspace = workspace.read(cx);
574            let active_entry = project.read(cx).active_entry();
575
576            //Make sure enviroment is as expeted
577            assert!(active_entry.is_none());
578            assert!(workspace.worktrees(cx).next().is_some());
579
580            let res = current_project_directory(workspace, cx);
581            assert_eq!(res, None);
582            let res = first_project_directory(workspace, cx);
583            assert_eq!(res, None);
584        });
585    }
586
587    //No active entry, but a worktree, worktree is a folder -> worktree_folder
588    #[gpui::test]
589    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
590        //Setup variables
591        let mut cx = TerminalTestContext::new(cx);
592        let (project, workspace) = cx.blank_workspace().await;
593        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
594
595        //Test
596        cx.cx.update(|cx| {
597            let workspace = workspace.read(cx);
598            let active_entry = project.read(cx).active_entry();
599
600            assert!(active_entry.is_none());
601            assert!(workspace.worktrees(cx).next().is_some());
602
603            let res = current_project_directory(workspace, cx);
604            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
605            let res = first_project_directory(workspace, cx);
606            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
607        });
608    }
609
610    //Active entry with a work tree, worktree is a file -> home_dir()
611    #[gpui::test]
612    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
613        //Setup variables
614        let mut cx = TerminalTestContext::new(cx);
615        let (project, workspace) = cx.blank_workspace().await;
616        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
617        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
618        cx.insert_active_entry_for(wt2, entry2, project.clone());
619
620        //Test
621        cx.cx.update(|cx| {
622            let workspace = workspace.read(cx);
623            let active_entry = project.read(cx).active_entry();
624
625            assert!(active_entry.is_some());
626
627            let res = current_project_directory(workspace, cx);
628            assert_eq!(res, None);
629            let res = first_project_directory(workspace, cx);
630            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
631        });
632    }
633
634    //Active entry, with a worktree, worktree is a folder -> worktree_folder
635    #[gpui::test]
636    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
637        //Setup variables
638        let mut cx = TerminalTestContext::new(cx);
639        let (project, workspace) = cx.blank_workspace().await;
640        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
641        let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
642        cx.insert_active_entry_for(wt2, entry2, project.clone());
643
644        //Test
645        cx.cx.update(|cx| {
646            let workspace = workspace.read(cx);
647            let active_entry = project.read(cx).active_entry();
648
649            assert!(active_entry.is_some());
650
651            let res = current_project_directory(workspace, cx);
652            assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
653            let res = first_project_directory(workspace, cx);
654            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
655        });
656    }
657}