terminal_container_view.rs

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