1mod persistence;
2pub mod terminal_button;
3pub mod terminal_element;
4
5use std::{
6 ops::RangeInclusive,
7 path::{Path, PathBuf},
8 time::Duration,
9};
10
11use context_menu::{ContextMenu, ContextMenuItem};
12use dirs::home_dir;
13use gpui::{
14 actions,
15 elements::{AnchorCorner, ChildView, Flex, Label, ParentElement, Stack, Text},
16 geometry::vector::Vector2F,
17 impl_actions, impl_internal_actions,
18 keymap_matcher::{KeymapContext, Keystroke},
19 platform::KeyDownEvent,
20 AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, Task, View, ViewContext,
21 ViewHandle, WeakViewHandle,
22};
23use project::{LocalWorktree, Project};
24use serde::Deserialize;
25use settings::{Settings, TerminalBlink, WorkingDirectory};
26use smallvec::{smallvec, SmallVec};
27use smol::Timer;
28use terminal::{
29 alacritty_terminal::{
30 index::Point,
31 term::{search::RegexSearch, TermMode},
32 },
33 Event, Terminal,
34};
35use util::ResultExt;
36use workspace::{
37 item::{Item, ItemEvent},
38 notifications::NotifyResultExt,
39 pane, register_deserializable_item,
40 searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
41 Pane, ToolbarItemLocation, Workspace, WorkspaceId,
42};
43
44use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement};
45
46const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
47
48///Event to transmit the scroll from the element to the view
49#[derive(Clone, Debug, PartialEq)]
50pub struct ScrollTerminal(pub i32);
51
52#[derive(Clone, PartialEq)]
53pub struct DeployContextMenu {
54 pub position: Vector2F,
55}
56
57#[derive(Clone, Default, Deserialize, PartialEq)]
58pub struct SendText(String);
59
60#[derive(Clone, Default, Deserialize, PartialEq)]
61pub struct SendKeystroke(String);
62
63actions!(
64 terminal,
65 [Clear, Copy, Paste, ShowCharacterPalette, SearchTest]
66);
67
68impl_actions!(terminal, [SendText, SendKeystroke]);
69
70impl_internal_actions!(project_panel, [DeployContextMenu]);
71
72pub fn init(cx: &mut AppContext) {
73 cx.add_action(TerminalView::deploy);
74
75 register_deserializable_item::<TerminalView>(cx);
76
77 //Useful terminal views
78 cx.add_action(TerminalView::send_text);
79 cx.add_action(TerminalView::send_keystroke);
80 cx.add_action(TerminalView::deploy_context_menu);
81 cx.add_action(TerminalView::copy);
82 cx.add_action(TerminalView::paste);
83 cx.add_action(TerminalView::clear);
84 cx.add_action(TerminalView::show_character_palette);
85}
86
87///A terminal view, maintains the PTY's file handles and communicates with the terminal
88pub struct TerminalView {
89 terminal: ModelHandle<Terminal>,
90 has_new_content: bool,
91 //Currently using iTerm bell, show bell emoji in tab until input is received
92 has_bell: bool,
93 context_menu: ViewHandle<ContextMenu>,
94 blink_state: bool,
95 blinking_on: bool,
96 blinking_paused: bool,
97 blink_epoch: usize,
98 workspace_id: WorkspaceId,
99}
100
101impl Entity for TerminalView {
102 type Event = Event;
103}
104
105impl TerminalView {
106 ///Create a new Terminal in the current working directory or the user's home directory
107 pub fn deploy(
108 workspace: &mut Workspace,
109 _: &workspace::NewTerminal,
110 cx: &mut ViewContext<Workspace>,
111 ) {
112 let strategy = cx.global::<Settings>().terminal_strategy();
113
114 let working_directory = get_working_directory(workspace, cx, strategy);
115
116 let window_id = cx.window_id();
117 let terminal = workspace
118 .project()
119 .update(cx, |project, cx| {
120 project.create_terminal(working_directory, window_id, cx)
121 })
122 .notify_err(workspace, cx);
123
124 if let Some(terminal) = terminal {
125 let view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx));
126 workspace.add_item(Box::new(view), cx)
127 }
128 }
129
130 pub fn new(
131 terminal: ModelHandle<Terminal>,
132 workspace_id: WorkspaceId,
133 cx: &mut ViewContext<Self>,
134 ) -> Self {
135 cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
136 cx.subscribe(&terminal, |this, _, event, cx| match event {
137 Event::Wakeup => {
138 if !cx.is_self_focused() {
139 this.has_new_content = true;
140 cx.notify();
141 }
142 cx.emit(Event::Wakeup);
143 }
144 Event::Bell => {
145 this.has_bell = true;
146 cx.emit(Event::Wakeup);
147 }
148 Event::BlinkChanged => this.blinking_on = !this.blinking_on,
149 Event::TitleChanged => {
150 if let Some(foreground_info) = &this.terminal().read(cx).foreground_process_info {
151 let cwd = foreground_info.cwd.clone();
152
153 let item_id = cx.view_id();
154 let workspace_id = this.workspace_id;
155 cx.background()
156 .spawn(async move {
157 TERMINAL_DB
158 .save_working_directory(item_id, workspace_id, cwd)
159 .await
160 .log_err();
161 })
162 .detach();
163 }
164 }
165 _ => cx.emit(*event),
166 })
167 .detach();
168
169 Self {
170 terminal,
171 has_new_content: true,
172 has_bell: false,
173 context_menu: cx.add_view(ContextMenu::new),
174 blink_state: true,
175 blinking_on: false,
176 blinking_paused: false,
177 blink_epoch: 0,
178 workspace_id,
179 }
180 }
181
182 pub fn model(&self) -> &ModelHandle<Terminal> {
183 &self.terminal
184 }
185
186 pub fn has_new_content(&self) -> bool {
187 self.has_new_content
188 }
189
190 pub fn has_bell(&self) -> bool {
191 self.has_bell
192 }
193
194 pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
195 self.has_bell = false;
196 cx.emit(Event::Wakeup);
197 }
198
199 pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
200 let menu_entries = vec![
201 ContextMenuItem::item("Clear", Clear),
202 ContextMenuItem::item("Close", pane::CloseActiveItem),
203 ];
204
205 self.context_menu.update(cx, |menu, cx| {
206 menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
207 });
208
209 cx.notify();
210 }
211
212 fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
213 if !self
214 .terminal
215 .read(cx)
216 .last_content
217 .mode
218 .contains(TermMode::ALT_SCREEN)
219 {
220 cx.show_character_palette();
221 } else {
222 self.terminal.update(cx, |term, cx| {
223 term.try_keystroke(
224 &Keystroke::parse("ctrl-cmd-space").unwrap(),
225 cx.global::<Settings>()
226 .terminal_overrides
227 .option_as_meta
228 .unwrap_or(false),
229 )
230 });
231 }
232 }
233
234 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
235 self.terminal.update(cx, |term, _| term.clear());
236 cx.notify();
237 }
238
239 pub fn should_show_cursor(
240 &self,
241 focused: bool,
242 cx: &mut gpui::RenderContext<'_, Self>,
243 ) -> bool {
244 //Don't blink the cursor when not focused, blinking is disabled, or paused
245 if !focused
246 || !self.blinking_on
247 || self.blinking_paused
248 || self
249 .terminal
250 .read(cx)
251 .last_content
252 .mode
253 .contains(TermMode::ALT_SCREEN)
254 {
255 return true;
256 }
257
258 let setting = {
259 let settings = cx.global::<Settings>();
260 settings
261 .terminal_overrides
262 .blinking
263 .clone()
264 .unwrap_or(TerminalBlink::TerminalControlled)
265 };
266
267 match setting {
268 //If the user requested to never blink, don't blink it.
269 TerminalBlink::Off => true,
270 //If the terminal is controlling it, check terminal mode
271 TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
272 }
273 }
274
275 fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
276 if epoch == self.blink_epoch && !self.blinking_paused {
277 self.blink_state = !self.blink_state;
278 cx.notify();
279
280 let epoch = self.next_blink_epoch();
281 cx.spawn(|this, mut cx| {
282 let this = this.downgrade();
283 async move {
284 Timer::after(CURSOR_BLINK_INTERVAL).await;
285 if let Some(this) = this.upgrade(&cx) {
286 this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
287 }
288 }
289 })
290 .detach();
291 }
292 }
293
294 pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
295 self.blink_state = true;
296 cx.notify();
297
298 let epoch = self.next_blink_epoch();
299 cx.spawn(|this, mut cx| {
300 let this = this.downgrade();
301 async move {
302 Timer::after(CURSOR_BLINK_INTERVAL).await;
303 if let Some(this) = this.upgrade(&cx) {
304 this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
305 }
306 }
307 })
308 .detach();
309 }
310
311 pub fn find_matches(
312 &mut self,
313 query: project::search::SearchQuery,
314 cx: &mut ViewContext<Self>,
315 ) -> Task<Vec<RangeInclusive<Point>>> {
316 let searcher = regex_search_for_query(query);
317
318 if let Some(searcher) = searcher {
319 self.terminal
320 .update(cx, |term, cx| term.find_matches(searcher, cx))
321 } else {
322 cx.background().spawn(async { Vec::new() })
323 }
324 }
325
326 pub fn terminal(&self) -> &ModelHandle<Terminal> {
327 &self.terminal
328 }
329
330 fn next_blink_epoch(&mut self) -> usize {
331 self.blink_epoch += 1;
332 self.blink_epoch
333 }
334
335 fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
336 if epoch == self.blink_epoch {
337 self.blinking_paused = false;
338 self.blink_cursors(epoch, cx);
339 }
340 }
341
342 ///Attempt to paste the clipboard into the terminal
343 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
344 self.terminal.update(cx, |term, _| term.copy())
345 }
346
347 ///Attempt to paste the clipboard into the terminal
348 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
349 if let Some(item) = cx.read_from_clipboard() {
350 self.terminal
351 .update(cx, |terminal, _cx| terminal.paste(item.text()));
352 }
353 }
354
355 fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
356 self.clear_bel(cx);
357 self.terminal.update(cx, |term, _| {
358 term.input(text.0.to_string());
359 });
360 }
361
362 fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
363 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
364 self.clear_bel(cx);
365 self.terminal.update(cx, |term, cx| {
366 term.try_keystroke(
367 &keystroke,
368 cx.global::<Settings>()
369 .terminal_overrides
370 .option_as_meta
371 .unwrap_or(false),
372 );
373 });
374 }
375 }
376}
377
378pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option<RegexSearch> {
379 let searcher = match query {
380 project::search::SearchQuery::Text { query, .. } => RegexSearch::new(&query),
381 project::search::SearchQuery::Regex { query, .. } => RegexSearch::new(&query),
382 };
383 searcher.ok()
384}
385
386impl View for TerminalView {
387 fn ui_name() -> &'static str {
388 "Terminal"
389 }
390
391 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
392 let terminal_handle = self.terminal.clone().downgrade();
393
394 let self_id = cx.view_id();
395 let focused = cx
396 .focused_view_id(cx.window_id())
397 .filter(|view_id| *view_id == self_id)
398 .is_some();
399
400 Stack::new()
401 .with_child(
402 TerminalElement::new(
403 cx.handle(),
404 terminal_handle,
405 focused,
406 self.should_show_cursor(focused, cx),
407 )
408 .contained()
409 .boxed(),
410 )
411 .with_child(ChildView::new(&self.context_menu, cx).boxed())
412 .boxed()
413 }
414
415 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
416 self.has_new_content = false;
417 self.terminal.read(cx).focus_in();
418 self.blink_cursors(self.blink_epoch, cx);
419 cx.notify();
420 }
421
422 fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
423 self.terminal.update(cx, |terminal, _| {
424 terminal.focus_out();
425 });
426 cx.notify();
427 }
428
429 fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
430 self.clear_bel(cx);
431 self.pause_cursor_blinking(cx);
432
433 self.terminal.update(cx, |term, cx| {
434 term.try_keystroke(
435 &event.keystroke,
436 cx.global::<Settings>()
437 .terminal_overrides
438 .option_as_meta
439 .unwrap_or(false),
440 )
441 })
442 }
443
444 //IME stuff
445 fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
446 if self
447 .terminal
448 .read(cx)
449 .last_content
450 .mode
451 .contains(TermMode::ALT_SCREEN)
452 {
453 None
454 } else {
455 Some(0..0)
456 }
457 }
458
459 fn replace_text_in_range(
460 &mut self,
461 _: Option<std::ops::Range<usize>>,
462 text: &str,
463 cx: &mut ViewContext<Self>,
464 ) {
465 self.terminal.update(cx, |terminal, _| {
466 terminal.input(text.into());
467 });
468 }
469
470 fn keymap_context(&self, cx: &gpui::AppContext) -> KeymapContext {
471 let mut context = Self::default_keymap_context();
472
473 let mode = self.terminal.read(cx).last_content.mode;
474 context.add_key(
475 "screen",
476 if mode.contains(TermMode::ALT_SCREEN) {
477 "alt"
478 } else {
479 "normal"
480 },
481 );
482
483 if mode.contains(TermMode::APP_CURSOR) {
484 context.add_identifier("DECCKM");
485 }
486 if mode.contains(TermMode::APP_KEYPAD) {
487 context.add_identifier("DECPAM");
488 } else {
489 context.add_identifier("DECPNM");
490 }
491 if mode.contains(TermMode::SHOW_CURSOR) {
492 context.add_identifier("DECTCEM");
493 }
494 if mode.contains(TermMode::LINE_WRAP) {
495 context.add_identifier("DECAWM");
496 }
497 if mode.contains(TermMode::ORIGIN) {
498 context.add_identifier("DECOM");
499 }
500 if mode.contains(TermMode::INSERT) {
501 context.add_identifier("IRM");
502 }
503 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
504 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
505 context.add_identifier("LNM");
506 }
507 if mode.contains(TermMode::FOCUS_IN_OUT) {
508 context.add_identifier("report_focus");
509 }
510 if mode.contains(TermMode::ALTERNATE_SCROLL) {
511 context.add_identifier("alternate_scroll");
512 }
513 if mode.contains(TermMode::BRACKETED_PASTE) {
514 context.add_identifier("bracketed_paste");
515 }
516 if mode.intersects(TermMode::MOUSE_MODE) {
517 context.add_identifier("any_mouse_reporting");
518 }
519 {
520 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
521 "click"
522 } else if mode.contains(TermMode::MOUSE_DRAG) {
523 "drag"
524 } else if mode.contains(TermMode::MOUSE_MOTION) {
525 "motion"
526 } else {
527 "off"
528 };
529 context.add_key("mouse_reporting", mouse_reporting);
530 }
531 {
532 let format = if mode.contains(TermMode::SGR_MOUSE) {
533 "sgr"
534 } else if mode.contains(TermMode::UTF8_MOUSE) {
535 "utf8"
536 } else {
537 "normal"
538 };
539 context.add_key("mouse_format", format);
540 }
541 context
542 }
543}
544
545impl Item for TerminalView {
546 fn tab_content(
547 &self,
548 _detail: Option<usize>,
549 tab_theme: &theme::Tab,
550 cx: &gpui::AppContext,
551 ) -> ElementBox {
552 let title = self.terminal().read(cx).title();
553
554 Flex::row()
555 .with_child(
556 gpui::elements::Svg::new("icons/terminal_12.svg")
557 .with_color(tab_theme.label.text.color)
558 .constrained()
559 .with_width(tab_theme.type_icon_width)
560 .aligned()
561 .contained()
562 .with_margin_right(tab_theme.spacing)
563 .boxed(),
564 )
565 .with_child(Label::new(title, tab_theme.label.clone()).aligned().boxed())
566 .boxed()
567 }
568
569 fn clone_on_split(
570 &self,
571 _workspace_id: WorkspaceId,
572 _cx: &mut ViewContext<Self>,
573 ) -> Option<Self> {
574 //From what I can tell, there's no way to tell the current working
575 //Directory of the terminal from outside the shell. There might be
576 //solutions to this, but they are non-trivial and require more IPC
577
578 // Some(TerminalContainer::new(
579 // Err(anyhow::anyhow!("failed to instantiate terminal")),
580 // workspace_id,
581 // cx,
582 // ))
583
584 // TODO
585 None
586 }
587
588 fn is_dirty(&self, _cx: &gpui::AppContext) -> bool {
589 self.has_bell()
590 }
591
592 fn has_conflict(&self, _cx: &AppContext) -> bool {
593 false
594 }
595
596 fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
597 Some(Box::new(handle.clone()))
598 }
599
600 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
601 match event {
602 Event::BreadcrumbsChanged => smallvec![ItemEvent::UpdateBreadcrumbs],
603 Event::TitleChanged | Event::Wakeup => smallvec![ItemEvent::UpdateTab],
604 Event::CloseTerminal => smallvec![ItemEvent::CloseItem],
605 _ => smallvec![],
606 }
607 }
608
609 fn breadcrumb_location(&self) -> ToolbarItemLocation {
610 ToolbarItemLocation::PrimaryLeft { flex: None }
611 }
612
613 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
614 Some(vec![Text::new(
615 self.terminal().read(cx).breadcrumb_text.clone(),
616 theme.workspace.breadcrumbs.default.text.clone(),
617 )
618 .boxed()])
619 }
620
621 fn serialized_item_kind() -> Option<&'static str> {
622 Some("Terminal")
623 }
624
625 fn deserialize(
626 project: ModelHandle<Project>,
627 workspace: WeakViewHandle<Workspace>,
628 workspace_id: workspace::WorkspaceId,
629 item_id: workspace::ItemId,
630 cx: &mut ViewContext<Pane>,
631 ) -> Task<anyhow::Result<ViewHandle<Self>>> {
632 let window_id = cx.window_id();
633 cx.spawn(|pane, mut cx| async move {
634 let cwd = TERMINAL_DB
635 .get_working_directory(item_id, workspace_id)
636 .log_err()
637 .flatten()
638 .or_else(|| {
639 cx.read(|cx| {
640 let strategy = cx.global::<Settings>().terminal_strategy();
641 workspace
642 .upgrade(cx)
643 .map(|workspace| {
644 get_working_directory(workspace.read(cx), cx, strategy)
645 })
646 .flatten()
647 })
648 });
649
650 cx.update(|cx| {
651 let terminal = project.update(cx, |project, cx| {
652 project.create_terminal(cwd, window_id, cx)
653 })?;
654
655 Ok(cx.add_view(&pane, |cx| TerminalView::new(terminal, workspace_id, cx)))
656 })
657 })
658 }
659
660 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
661 cx.background()
662 .spawn(TERMINAL_DB.update_workspace_id(
663 workspace.database_id(),
664 self.workspace_id,
665 cx.view_id(),
666 ))
667 .detach();
668 self.workspace_id = workspace.database_id();
669 }
670}
671
672impl SearchableItem for TerminalView {
673 type Match = RangeInclusive<Point>;
674
675 fn supported_options() -> SearchOptions {
676 SearchOptions {
677 case: false,
678 word: false,
679 regex: false,
680 }
681 }
682
683 /// Convert events raised by this item into search-relevant events (if applicable)
684 fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
685 match event {
686 Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
687 Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
688 _ => None,
689 }
690 }
691
692 /// Clear stored matches
693 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
694 self.terminal().update(cx, |term, _| term.matches.clear())
695 }
696
697 /// Store matches returned from find_matches somewhere for rendering
698 fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
699 self.terminal().update(cx, |term, _| term.matches = matches)
700 }
701
702 /// Return the selection content to pre-load into this search
703 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
704 self.terminal()
705 .read(cx)
706 .last_content
707 .selection_text
708 .clone()
709 .unwrap_or_default()
710 }
711
712 /// Focus match at given index into the Vec of matches
713 fn activate_match(&mut self, index: usize, _: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
714 self.terminal()
715 .update(cx, |term, _| term.activate_match(index));
716 cx.notify();
717 }
718
719 /// Get all of the matches for this query, should be done on the background
720 fn find_matches(
721 &mut self,
722 query: project::search::SearchQuery,
723 cx: &mut ViewContext<Self>,
724 ) -> Task<Vec<Self::Match>> {
725 if let Some(searcher) = regex_search_for_query(query) {
726 self.terminal()
727 .update(cx, |term, cx| term.find_matches(searcher, cx))
728 } else {
729 Task::ready(vec![])
730 }
731 }
732
733 /// Reports back to the search toolbar what the active match should be (the selection)
734 fn active_match_index(
735 &mut self,
736 matches: Vec<Self::Match>,
737 cx: &mut ViewContext<Self>,
738 ) -> Option<usize> {
739 // Selection head might have a value if there's a selection that isn't
740 // associated with a match. Therefore, if there are no matches, we should
741 // report None, no matter the state of the terminal
742 let res = if matches.len() > 0 {
743 if let Some(selection_head) = self.terminal().read(cx).selection_head {
744 // If selection head is contained in a match. Return that match
745 if let Some(ix) = matches
746 .iter()
747 .enumerate()
748 .find(|(_, search_match)| {
749 search_match.contains(&selection_head)
750 || search_match.start() > &selection_head
751 })
752 .map(|(ix, _)| ix)
753 {
754 Some(ix)
755 } else {
756 // If no selection after selection head, return the last match
757 Some(matches.len().saturating_sub(1))
758 }
759 } else {
760 // Matches found but no active selection, return the first last one (closest to cursor)
761 Some(matches.len().saturating_sub(1))
762 }
763 } else {
764 None
765 };
766
767 res
768 }
769}
770
771///Get's the working directory for the given workspace, respecting the user's settings.
772pub fn get_working_directory(
773 workspace: &Workspace,
774 cx: &AppContext,
775 strategy: WorkingDirectory,
776) -> Option<PathBuf> {
777 let res = match strategy {
778 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
779 .or_else(|| first_project_directory(workspace, cx)),
780 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
781 WorkingDirectory::AlwaysHome => None,
782 WorkingDirectory::Always { directory } => {
783 shellexpand::full(&directory) //TODO handle this better
784 .ok()
785 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
786 .filter(|dir| dir.is_dir())
787 }
788 };
789 res.or_else(home_dir)
790}
791
792///Get's the first project's home directory, or the home directory
793fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
794 workspace
795 .worktrees(cx)
796 .next()
797 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
798 .and_then(get_path_from_wt)
799}
800
801///Gets the intuitively correct working directory from the given workspace
802///If there is an active entry for this project, returns that entry's worktree root.
803///If there's no active entry but there is a worktree, returns that worktrees root.
804///If either of these roots are files, or if there are any other query failures,
805/// returns the user's home directory
806fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
807 let project = workspace.project().read(cx);
808
809 project
810 .active_entry()
811 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
812 .or_else(|| workspace.worktrees(cx).next())
813 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
814 .and_then(get_path_from_wt)
815}
816
817fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
818 wt.root_entry()
819 .filter(|re| re.is_dir())
820 .map(|_| wt.abs_path().to_path_buf())
821}
822
823#[cfg(test)]
824mod tests {
825
826 use super::*;
827 use gpui::TestAppContext;
828 use project::{Entry, Project, ProjectPath, Worktree};
829 use workspace::AppState;
830
831 use std::path::Path;
832
833 ///Working directory calculation tests
834
835 ///No Worktrees in project -> home_dir()
836 #[gpui::test]
837 async fn no_worktree(cx: &mut TestAppContext) {
838 //Setup variables
839 let (project, workspace) = blank_workspace(cx).await;
840 //Test
841 cx.read(|cx| {
842 let workspace = workspace.read(cx);
843 let active_entry = project.read(cx).active_entry();
844
845 //Make sure enviroment is as expeted
846 assert!(active_entry.is_none());
847 assert!(workspace.worktrees(cx).next().is_none());
848
849 let res = current_project_directory(workspace, cx);
850 assert_eq!(res, None);
851 let res = first_project_directory(workspace, cx);
852 assert_eq!(res, None);
853 });
854 }
855
856 ///No active entry, but a worktree, worktree is a file -> home_dir()
857 #[gpui::test]
858 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
859 //Setup variables
860
861 let (project, workspace) = blank_workspace(cx).await;
862 create_file_wt(project.clone(), "/root.txt", cx).await;
863
864 cx.read(|cx| {
865 let workspace = workspace.read(cx);
866 let active_entry = project.read(cx).active_entry();
867
868 //Make sure enviroment is as expeted
869 assert!(active_entry.is_none());
870 assert!(workspace.worktrees(cx).next().is_some());
871
872 let res = current_project_directory(workspace, cx);
873 assert_eq!(res, None);
874 let res = first_project_directory(workspace, cx);
875 assert_eq!(res, None);
876 });
877 }
878
879 //No active entry, but a worktree, worktree is a folder -> worktree_folder
880 #[gpui::test]
881 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
882 //Setup variables
883 let (project, workspace) = blank_workspace(cx).await;
884 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
885
886 //Test
887 cx.update(|cx| {
888 let workspace = workspace.read(cx);
889 let active_entry = project.read(cx).active_entry();
890
891 assert!(active_entry.is_none());
892 assert!(workspace.worktrees(cx).next().is_some());
893
894 let res = current_project_directory(workspace, cx);
895 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
896 let res = first_project_directory(workspace, cx);
897 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
898 });
899 }
900
901 //Active entry with a work tree, worktree is a file -> home_dir()
902 #[gpui::test]
903 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
904 //Setup variables
905
906 let (project, workspace) = blank_workspace(cx).await;
907 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
908 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
909 insert_active_entry_for(wt2, entry2, project.clone(), cx);
910
911 //Test
912 cx.update(|cx| {
913 let workspace = workspace.read(cx);
914 let active_entry = project.read(cx).active_entry();
915
916 assert!(active_entry.is_some());
917
918 let res = current_project_directory(workspace, cx);
919 assert_eq!(res, None);
920 let res = first_project_directory(workspace, cx);
921 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
922 });
923 }
924
925 //Active entry, with a worktree, worktree is a folder -> worktree_folder
926 #[gpui::test]
927 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
928 //Setup variables
929 let (project, workspace) = blank_workspace(cx).await;
930 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
931 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
932 insert_active_entry_for(wt2, entry2, project.clone(), cx);
933
934 //Test
935 cx.update(|cx| {
936 let workspace = workspace.read(cx);
937 let active_entry = project.read(cx).active_entry();
938
939 assert!(active_entry.is_some());
940
941 let res = current_project_directory(workspace, cx);
942 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
943 let res = first_project_directory(workspace, cx);
944 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
945 });
946 }
947
948 ///Creates a worktree with 1 file: /root.txt
949 pub async fn blank_workspace(
950 cx: &mut TestAppContext,
951 ) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
952 let params = cx.update(AppState::test);
953
954 let project = Project::test(params.fs.clone(), [], cx).await;
955 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
956
957 (project, workspace)
958 }
959
960 ///Creates a worktree with 1 folder: /root{suffix}/
961 async fn create_folder_wt(
962 project: ModelHandle<Project>,
963 path: impl AsRef<Path>,
964 cx: &mut TestAppContext,
965 ) -> (ModelHandle<Worktree>, Entry) {
966 create_wt(project, true, path, cx).await
967 }
968
969 ///Creates a worktree with 1 file: /root{suffix}.txt
970 async fn create_file_wt(
971 project: ModelHandle<Project>,
972 path: impl AsRef<Path>,
973 cx: &mut TestAppContext,
974 ) -> (ModelHandle<Worktree>, Entry) {
975 create_wt(project, false, path, cx).await
976 }
977
978 async fn create_wt(
979 project: ModelHandle<Project>,
980 is_dir: bool,
981 path: impl AsRef<Path>,
982 cx: &mut TestAppContext,
983 ) -> (ModelHandle<Worktree>, Entry) {
984 let (wt, _) = project
985 .update(cx, |project, cx| {
986 project.find_or_create_local_worktree(path, true, cx)
987 })
988 .await
989 .unwrap();
990
991 let entry = cx
992 .update(|cx| {
993 wt.update(cx, |wt, cx| {
994 wt.as_local()
995 .unwrap()
996 .create_entry(Path::new(""), is_dir, cx)
997 })
998 })
999 .await
1000 .unwrap();
1001
1002 (wt, entry)
1003 }
1004
1005 pub fn insert_active_entry_for(
1006 wt: ModelHandle<Worktree>,
1007 entry: Entry,
1008 project: ModelHandle<Project>,
1009 cx: &mut TestAppContext,
1010 ) {
1011 cx.update(|cx| {
1012 let p = ProjectPath {
1013 worktree_id: wt.read(cx).id(),
1014 path: entry.path,
1015 };
1016 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
1017 });
1018 }
1019}