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}