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