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