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