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