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, TabContentParams},
33 notifications::NotifyResultExt,
34 register_deserializable_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_deserializable_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 let terminal = this.terminal().read(cx);
616 if terminal.task().is_none() {
617 if let Some(cwd) = terminal.get_cwd() {
618 let item_id = cx.entity_id();
619 if let Some(workspace_id) = this.workspace_id {
620 cx.background_executor()
621 .spawn(async move {
622 TERMINAL_DB
623 .save_working_directory(item_id.as_u64(), workspace_id, cwd)
624 .await
625 .log_err();
626 })
627 .detach();
628 }
629 }
630 }
631 }
632
633 Event::NewNavigationTarget(maybe_navigation_target) => {
634 this.can_navigate_to_selected_word = match maybe_navigation_target {
635 Some(MaybeNavigationTarget::Url(_)) => true,
636 Some(MaybeNavigationTarget::PathLike(path_like_target)) => {
637 if let Ok(fs) = workspace.update(cx, |workspace, cx| {
638 workspace.project().read(cx).fs().clone()
639 }) {
640 let valid_files_to_open_task = possible_open_targets(
641 fs,
642 &workspace,
643 &path_like_target.terminal_dir,
644 &path_like_target.maybe_path,
645 cx,
646 );
647 smol::block_on(valid_files_to_open_task).len() > 0
648 } else {
649 false
650 }
651 }
652 None => false,
653 }
654 }
655
656 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
657 MaybeNavigationTarget::Url(url) => cx.open_url(url),
658
659 MaybeNavigationTarget::PathLike(path_like_target) => {
660 if !this.can_navigate_to_selected_word {
661 return;
662 }
663 let task_workspace = workspace.clone();
664 let Some(fs) = workspace
665 .update(cx, |workspace, cx| {
666 workspace.project().read(cx).fs().clone()
667 })
668 .ok()
669 else {
670 return;
671 };
672
673 let path_like_target = path_like_target.clone();
674 cx.spawn(|terminal_view, mut cx| async move {
675 let valid_files_to_open = terminal_view
676 .update(&mut cx, |_, cx| {
677 possible_open_targets(
678 fs,
679 &task_workspace,
680 &path_like_target.terminal_dir,
681 &path_like_target.maybe_path,
682 cx,
683 )
684 })?
685 .await;
686 let paths_to_open = valid_files_to_open
687 .iter()
688 .map(|(p, _)| p.path_like.clone())
689 .collect();
690 let opened_items = task_workspace
691 .update(&mut cx, |workspace, cx| {
692 workspace.open_paths(
693 paths_to_open,
694 OpenVisible::OnlyDirectories,
695 None,
696 cx,
697 )
698 })
699 .context("workspace update")?
700 .await;
701
702 let mut has_dirs = false;
703 for ((path, metadata), opened_item) in valid_files_to_open
704 .into_iter()
705 .zip(opened_items.into_iter())
706 {
707 if metadata.is_dir {
708 has_dirs = true;
709 } else if let Some(Ok(opened_item)) = opened_item {
710 if let Some(row) = path.row {
711 let col = path.column.unwrap_or(0);
712 if let Some(active_editor) = opened_item.downcast::<Editor>() {
713 active_editor
714 .downgrade()
715 .update(&mut cx, |editor, cx| {
716 let snapshot = editor.snapshot(cx).display_snapshot;
717 let point = snapshot.buffer_snapshot.clip_point(
718 language::Point::new(
719 row.saturating_sub(1),
720 col.saturating_sub(1),
721 ),
722 Bias::Left,
723 );
724 editor.change_selections(
725 Some(Autoscroll::center()),
726 cx,
727 |s| s.select_ranges([point..point]),
728 );
729 })
730 .log_err();
731 }
732 }
733 }
734 }
735
736 if has_dirs {
737 task_workspace.update(&mut cx, |workspace, cx| {
738 workspace.project().update(cx, |_, cx| {
739 cx.emit(project::Event::ActivateProjectPanel);
740 })
741 })?;
742 }
743
744 anyhow::Ok(())
745 })
746 .detach_and_log_err(cx)
747 }
748 },
749 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
750 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
751 Event::SelectionsChanged => cx.emit(SearchEvent::ActiveMatchChanged),
752 });
753 vec![terminal_subscription, terminal_events_subscription]
754}
755
756fn possible_open_paths_metadata(
757 fs: Arc<dyn Fs>,
758 row: Option<u32>,
759 column: Option<u32>,
760 potential_paths: HashSet<PathBuf>,
761 cx: &mut ViewContext<TerminalView>,
762) -> Task<Vec<(PathLikeWithPosition<PathBuf>, Metadata)>> {
763 cx.background_executor().spawn(async move {
764 let mut paths_with_metadata = Vec::with_capacity(potential_paths.len());
765
766 let mut fetch_metadata_tasks = potential_paths
767 .into_iter()
768 .map(|potential_path| async {
769 let metadata = fs.metadata(&potential_path).await.ok().flatten();
770 (
771 PathLikeWithPosition {
772 path_like: potential_path,
773 row,
774 column,
775 },
776 metadata,
777 )
778 })
779 .collect::<FuturesUnordered<_>>();
780
781 while let Some((path, metadata)) = fetch_metadata_tasks.next().await {
782 if let Some(metadata) = metadata {
783 paths_with_metadata.push((path, metadata));
784 }
785 }
786
787 paths_with_metadata
788 })
789}
790
791fn possible_open_targets(
792 fs: Arc<dyn Fs>,
793 workspace: &WeakView<Workspace>,
794 cwd: &Option<PathBuf>,
795 maybe_path: &String,
796 cx: &mut ViewContext<TerminalView>,
797) -> Task<Vec<(PathLikeWithPosition<PathBuf>, Metadata)>> {
798 let path_like = PathLikeWithPosition::parse_str(maybe_path.as_str(), |_, path_str| {
799 Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf())
800 })
801 .expect("infallible");
802 let row = path_like.row;
803 let column = path_like.column;
804 let maybe_path = path_like.path_like;
805 let potential_abs_paths = if maybe_path.is_absolute() {
806 HashSet::from_iter([maybe_path])
807 } else if maybe_path.starts_with("~") {
808 if let Some(abs_path) = maybe_path
809 .strip_prefix("~")
810 .ok()
811 .and_then(|maybe_path| Some(dirs::home_dir()?.join(maybe_path)))
812 {
813 HashSet::from_iter([abs_path])
814 } else {
815 HashSet::default()
816 }
817 } else {
818 // First check cwd and then workspace
819 let mut potential_cwd_and_workspace_paths = HashSet::default();
820 if let Some(cwd) = cwd {
821 potential_cwd_and_workspace_paths.insert(Path::join(cwd, &maybe_path));
822 }
823 if let Some(workspace) = workspace.upgrade() {
824 workspace.update(cx, |workspace, cx| {
825 for potential_worktree_path in workspace
826 .worktrees(cx)
827 .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path))
828 {
829 potential_cwd_and_workspace_paths.insert(potential_worktree_path);
830 }
831 });
832 }
833 potential_cwd_and_workspace_paths
834 };
835
836 possible_open_paths_metadata(fs, row, column, potential_abs_paths, cx)
837}
838
839fn regex_to_literal(regex: &str) -> String {
840 regex
841 .chars()
842 .flat_map(|c| {
843 if REGEX_SPECIAL_CHARS.contains(&c) {
844 vec!['\\', c]
845 } else {
846 vec![c]
847 }
848 })
849 .collect()
850}
851
852pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
853 let query = query.as_str();
854 if query == "." {
855 return None;
856 }
857 let searcher = RegexSearch::new(&query);
858 searcher.ok()
859}
860
861impl TerminalView {
862 fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) {
863 self.clear_bell(cx);
864 self.pause_cursor_blinking(cx);
865
866 self.terminal.update(cx, |term, cx| {
867 let handled = term.try_keystroke(
868 &event.keystroke,
869 TerminalSettings::get_global(cx).option_as_meta,
870 );
871 if handled {
872 cx.stop_propagation();
873 }
874 });
875 }
876
877 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
878 self.terminal.read(cx).focus_in();
879 self.blink_cursors(self.blink_epoch, cx);
880 cx.notify();
881 }
882
883 fn focus_out(&mut self, cx: &mut ViewContext<Self>) {
884 self.terminal.update(cx, |terminal, _| {
885 terminal.focus_out();
886 });
887 cx.notify();
888 }
889}
890
891impl Render for TerminalView {
892 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
893 let terminal_handle = self.terminal.clone();
894 let terminal_view_handle = cx.view().clone();
895
896 let focused = self.focus_handle.is_focused(cx);
897
898 div()
899 .size_full()
900 .relative()
901 .track_focus(&self.focus_handle)
902 .key_context(self.dispatch_context(cx))
903 .on_action(cx.listener(TerminalView::send_text))
904 .on_action(cx.listener(TerminalView::send_keystroke))
905 .on_action(cx.listener(TerminalView::copy))
906 .on_action(cx.listener(TerminalView::paste))
907 .on_action(cx.listener(TerminalView::clear))
908 .on_action(cx.listener(TerminalView::scroll_line_up))
909 .on_action(cx.listener(TerminalView::scroll_line_down))
910 .on_action(cx.listener(TerminalView::scroll_page_up))
911 .on_action(cx.listener(TerminalView::scroll_page_down))
912 .on_action(cx.listener(TerminalView::scroll_to_top))
913 .on_action(cx.listener(TerminalView::scroll_to_bottom))
914 .on_action(cx.listener(TerminalView::show_character_palette))
915 .on_action(cx.listener(TerminalView::select_all))
916 .on_key_down(cx.listener(Self::key_down))
917 .on_mouse_down(
918 MouseButton::Right,
919 cx.listener(|this, event: &MouseDownEvent, cx| {
920 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
921 this.deploy_context_menu(event.position, cx);
922 cx.notify();
923 }
924 }),
925 )
926 .child(
927 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
928 div().size_full().child(TerminalElement::new(
929 terminal_handle,
930 terminal_view_handle,
931 self.workspace.clone(),
932 self.focus_handle.clone(),
933 focused,
934 self.should_show_cursor(focused, cx),
935 self.can_navigate_to_selected_word,
936 self.block_below_cursor.clone(),
937 )),
938 )
939 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
940 deferred(
941 anchored()
942 .position(*position)
943 .anchor(gpui::AnchorCorner::TopLeft)
944 .child(menu.clone()),
945 )
946 .with_priority(1)
947 }))
948 }
949}
950
951impl Item for TerminalView {
952 type Event = ItemEvent;
953
954 fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString> {
955 Some(self.terminal().read(cx).title(false).into())
956 }
957
958 fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
959 let terminal = self.terminal().read(cx);
960 let title = terminal.title(true);
961
962 let (icon, icon_color, rerun_btn) = match terminal.task() {
963 Some(terminal_task) => match &terminal_task.status {
964 TaskStatus::Unknown => (IconName::ExclamationTriangle, Color::Warning, None),
965 TaskStatus::Running => (IconName::Play, Color::Disabled, None),
966 TaskStatus::Completed { success } => {
967 let task_id = terminal_task.id.clone();
968 let rerun_btn = IconButton::new("rerun-icon", IconName::Rerun)
969 .icon_size(IconSize::Small)
970 .size(ButtonSize::Compact)
971 .icon_color(Color::Default)
972 .shape(ui::IconButtonShape::Square)
973 .tooltip(|cx| Tooltip::text("Rerun task", cx))
974 .on_click(move |_, cx| {
975 cx.dispatch_action(Box::new(tasks_ui::Rerun {
976 task_id: Some(task_id.clone()),
977 ..Default::default()
978 }));
979 });
980
981 if *success {
982 (IconName::Check, Color::Success, Some(rerun_btn))
983 } else {
984 (IconName::XCircle, Color::Error, Some(rerun_btn))
985 }
986 }
987 },
988 None => (IconName::Terminal, Color::Muted, None),
989 };
990
991 h_flex()
992 .gap_2()
993 .group("term-tab-icon")
994 .child(
995 h_flex()
996 .group("term-tab-icon")
997 .child(
998 div()
999 .when(rerun_btn.is_some(), |this| {
1000 this.hover(|style| style.invisible().w_0())
1001 })
1002 .child(Icon::new(icon).color(icon_color)),
1003 )
1004 .when_some(rerun_btn, |this, rerun_btn| {
1005 this.child(
1006 div()
1007 .absolute()
1008 .visible_on_hover("term-tab-icon")
1009 .child(rerun_btn),
1010 )
1011 }),
1012 )
1013 .child(Label::new(title).color(if params.selected {
1014 Color::Default
1015 } else {
1016 Color::Muted
1017 }))
1018 .into_any()
1019 }
1020
1021 fn telemetry_event_text(&self) -> Option<&'static str> {
1022 None
1023 }
1024
1025 fn clone_on_split(
1026 &self,
1027 _workspace_id: Option<WorkspaceId>,
1028 _cx: &mut ViewContext<Self>,
1029 ) -> Option<View<Self>> {
1030 //From what I can tell, there's no way to tell the current working
1031 //Directory of the terminal from outside the shell. There might be
1032 //solutions to this, but they are non-trivial and require more IPC
1033
1034 // Some(TerminalContainer::new(
1035 // Err(anyhow::anyhow!("failed to instantiate terminal")),
1036 // workspace_id,
1037 // cx,
1038 // ))
1039
1040 // TODO
1041 None
1042 }
1043
1044 fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
1045 match self.terminal.read(cx).task() {
1046 Some(task) => task.status == TaskStatus::Running,
1047 None => self.has_bell(),
1048 }
1049 }
1050
1051 fn has_conflict(&self, _cx: &AppContext) -> bool {
1052 false
1053 }
1054
1055 fn as_searchable(&self, handle: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
1056 Some(Box::new(handle.clone()))
1057 }
1058
1059 fn breadcrumb_location(&self) -> ToolbarItemLocation {
1060 if self.show_title {
1061 ToolbarItemLocation::PrimaryLeft
1062 } else {
1063 ToolbarItemLocation::Hidden
1064 }
1065 }
1066
1067 fn breadcrumbs(&self, _: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
1068 Some(vec![BreadcrumbText {
1069 text: self.terminal().read(cx).breadcrumb_text.clone(),
1070 highlights: None,
1071 font: None,
1072 }])
1073 }
1074
1075 fn serialized_item_kind() -> Option<&'static str> {
1076 Some("Terminal")
1077 }
1078
1079 fn deserialize(
1080 project: Model<Project>,
1081 workspace: WeakView<Workspace>,
1082 workspace_id: workspace::WorkspaceId,
1083 item_id: workspace::ItemId,
1084 cx: &mut ViewContext<Pane>,
1085 ) -> Task<anyhow::Result<View<Self>>> {
1086 let window = cx.window_handle();
1087 cx.spawn(|pane, mut cx| async move {
1088 let cwd = cx
1089 .update(|cx| {
1090 let from_db = TERMINAL_DB
1091 .get_working_directory(item_id, workspace_id)
1092 .log_err()
1093 .flatten();
1094 if from_db
1095 .as_ref()
1096 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1097 {
1098 project
1099 .read(cx)
1100 .terminal_work_dir_for(from_db.as_deref(), cx)
1101 } else {
1102 let strategy = TerminalSettings::get_global(cx).working_directory.clone();
1103 workspace.upgrade().and_then(|workspace| {
1104 get_working_directory(workspace.read(cx), cx, strategy)
1105 })
1106 }
1107 })
1108 .ok()
1109 .flatten();
1110
1111 let terminal = project.update(&mut cx, |project, cx| {
1112 project.create_terminal(cwd, None, window, cx)
1113 })??;
1114 pane.update(&mut cx, |_, cx| {
1115 cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx))
1116 })
1117 })
1118 }
1119
1120 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
1121 if self.terminal().read(cx).task().is_none() {
1122 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1123 cx.background_executor()
1124 .spawn(TERMINAL_DB.update_workspace_id(new_id, old_id, cx.entity_id().as_u64()))
1125 .detach();
1126 }
1127 self.workspace_id = workspace.database_id();
1128 }
1129 }
1130
1131 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1132 f(*event)
1133 }
1134}
1135
1136impl SearchableItem for TerminalView {
1137 type Match = RangeInclusive<Point>;
1138
1139 fn supported_options() -> SearchOptions {
1140 SearchOptions {
1141 case: false,
1142 word: false,
1143 regex: true,
1144 replacement: false,
1145 selection: false,
1146 }
1147 }
1148
1149 /// Clear stored matches
1150 fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
1151 self.terminal().update(cx, |term, _| term.matches.clear())
1152 }
1153
1154 /// Store matches returned from find_matches somewhere for rendering
1155 fn update_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>) {
1156 self.terminal()
1157 .update(cx, |term, _| term.matches = matches.to_vec())
1158 }
1159
1160 /// Returns the selection content to pre-load into this search
1161 fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
1162 self.terminal()
1163 .read(cx)
1164 .last_content
1165 .selection_text
1166 .clone()
1167 .unwrap_or_default()
1168 }
1169
1170 /// Focus match at given index into the Vec of matches
1171 fn activate_match(&mut self, index: usize, _: &[Self::Match], cx: &mut ViewContext<Self>) {
1172 self.terminal()
1173 .update(cx, |term, _| term.activate_match(index));
1174 cx.notify();
1175 }
1176
1177 /// Add selections for all matches given.
1178 fn select_matches(&mut self, matches: &[Self::Match], cx: &mut ViewContext<Self>) {
1179 self.terminal()
1180 .update(cx, |term, _| term.select_matches(matches));
1181 cx.notify();
1182 }
1183
1184 /// Get all of the matches for this query, should be done on the background
1185 fn find_matches(
1186 &mut self,
1187 query: Arc<SearchQuery>,
1188 cx: &mut ViewContext<Self>,
1189 ) -> Task<Vec<Self::Match>> {
1190 let searcher = match &*query {
1191 SearchQuery::Text { .. } => regex_search_for_query(
1192 &(SearchQuery::text(
1193 regex_to_literal(&query.as_str()),
1194 query.whole_word(),
1195 query.case_sensitive(),
1196 query.include_ignored(),
1197 query.files_to_include().clone(),
1198 query.files_to_exclude().clone(),
1199 )
1200 .unwrap()),
1201 ),
1202 SearchQuery::Regex { .. } => regex_search_for_query(&query),
1203 };
1204
1205 if let Some(s) = searcher {
1206 self.terminal()
1207 .update(cx, |term, cx| term.find_matches(s, cx))
1208 } else {
1209 Task::ready(vec![])
1210 }
1211 }
1212
1213 /// Reports back to the search toolbar what the active match should be (the selection)
1214 fn active_match_index(
1215 &mut self,
1216 matches: &[Self::Match],
1217 cx: &mut ViewContext<Self>,
1218 ) -> Option<usize> {
1219 // Selection head might have a value if there's a selection that isn't
1220 // associated with a match. Therefore, if there are no matches, we should
1221 // report None, no matter the state of the terminal
1222 let res = if matches.len() > 0 {
1223 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1224 // If selection head is contained in a match. Return that match
1225 if let Some(ix) = matches
1226 .iter()
1227 .enumerate()
1228 .find(|(_, search_match)| {
1229 search_match.contains(&selection_head)
1230 || search_match.start() > &selection_head
1231 })
1232 .map(|(ix, _)| ix)
1233 {
1234 Some(ix)
1235 } else {
1236 // If no selection after selection head, return the last match
1237 Some(matches.len().saturating_sub(1))
1238 }
1239 } else {
1240 // Matches found but no active selection, return the first last one (closest to cursor)
1241 Some(matches.len().saturating_sub(1))
1242 }
1243 } else {
1244 None
1245 };
1246
1247 res
1248 }
1249 fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>) {
1250 // Replacement is not supported in terminal view, so this is a no-op.
1251 }
1252}
1253
1254///Gets the working directory for the given workspace, respecting the user's settings.
1255pub fn get_working_directory(
1256 workspace: &Workspace,
1257 cx: &AppContext,
1258 strategy: WorkingDirectory,
1259) -> Option<TerminalWorkDir> {
1260 if workspace.project().read(cx).is_local() {
1261 let res = match strategy {
1262 WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
1263 .or_else(|| first_project_directory(workspace, cx)),
1264 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1265 WorkingDirectory::AlwaysHome => None,
1266 WorkingDirectory::Always { directory } => {
1267 shellexpand::full(&directory) //TODO handle this better
1268 .ok()
1269 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1270 .filter(|dir| dir.is_dir())
1271 }
1272 };
1273 res.or_else(home_dir).map(|cwd| TerminalWorkDir::Local(cwd))
1274 } else {
1275 workspace.project().read(cx).terminal_work_dir_for(None, cx)
1276 }
1277}
1278
1279///Gets the first project's home directory, or the home directory
1280fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
1281 workspace
1282 .worktrees(cx)
1283 .next()
1284 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
1285 .and_then(get_path_from_wt)
1286}
1287
1288///Gets the intuitively correct working directory from the given workspace
1289///If there is an active entry for this project, returns that entry's worktree root.
1290///If there's no active entry but there is a worktree, returns that worktrees root.
1291///If either of these roots are files, or if there are any other query failures,
1292/// returns the user's home directory
1293fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
1294 let project = workspace.project().read(cx);
1295
1296 project
1297 .active_entry()
1298 .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
1299 .or_else(|| workspace.worktrees(cx).next())
1300 .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
1301 .and_then(get_path_from_wt)
1302}
1303
1304fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
1305 wt.root_entry()
1306 .filter(|re| re.is_dir())
1307 .map(|_| wt.abs_path().to_path_buf())
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313 use gpui::TestAppContext;
1314 use project::{Entry, Project, ProjectPath, Worktree};
1315 use std::path::Path;
1316 use workspace::AppState;
1317
1318 // Working directory calculation tests
1319
1320 // No Worktrees in project -> home_dir()
1321 #[gpui::test]
1322 async fn no_worktree(cx: &mut TestAppContext) {
1323 let (project, workspace) = init_test(cx).await;
1324 cx.read(|cx| {
1325 let workspace = workspace.read(cx);
1326 let active_entry = project.read(cx).active_entry();
1327
1328 //Make sure environment is as expected
1329 assert!(active_entry.is_none());
1330 assert!(workspace.worktrees(cx).next().is_none());
1331
1332 let res = current_project_directory(workspace, cx);
1333 assert_eq!(res, None);
1334 let res = first_project_directory(workspace, cx);
1335 assert_eq!(res, None);
1336 });
1337 }
1338
1339 // No active entry, but a worktree, worktree is a file -> home_dir()
1340 #[gpui::test]
1341 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1342 let (project, workspace) = init_test(cx).await;
1343
1344 create_file_wt(project.clone(), "/root.txt", cx).await;
1345 cx.read(|cx| {
1346 let workspace = workspace.read(cx);
1347 let active_entry = project.read(cx).active_entry();
1348
1349 //Make sure environment is as expected
1350 assert!(active_entry.is_none());
1351 assert!(workspace.worktrees(cx).next().is_some());
1352
1353 let res = current_project_directory(workspace, cx);
1354 assert_eq!(res, None);
1355 let res = first_project_directory(workspace, cx);
1356 assert_eq!(res, None);
1357 });
1358 }
1359
1360 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1361 #[gpui::test]
1362 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1363 let (project, workspace) = init_test(cx).await;
1364
1365 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1366 cx.update(|cx| {
1367 let workspace = workspace.read(cx);
1368 let active_entry = project.read(cx).active_entry();
1369
1370 assert!(active_entry.is_none());
1371 assert!(workspace.worktrees(cx).next().is_some());
1372
1373 let res = current_project_directory(workspace, cx);
1374 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1375 let res = first_project_directory(workspace, cx);
1376 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1377 });
1378 }
1379
1380 // Active entry with a work tree, worktree is a file -> home_dir()
1381 #[gpui::test]
1382 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1383 let (project, workspace) = init_test(cx).await;
1384
1385 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1386 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1387 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1388
1389 cx.update(|cx| {
1390 let workspace = workspace.read(cx);
1391 let active_entry = project.read(cx).active_entry();
1392
1393 assert!(active_entry.is_some());
1394
1395 let res = current_project_directory(workspace, cx);
1396 assert_eq!(res, None);
1397 let res = first_project_directory(workspace, cx);
1398 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1399 });
1400 }
1401
1402 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1403 #[gpui::test]
1404 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1405 let (project, workspace) = init_test(cx).await;
1406
1407 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1408 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1409 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1410
1411 cx.update(|cx| {
1412 let workspace = workspace.read(cx);
1413 let active_entry = project.read(cx).active_entry();
1414
1415 assert!(active_entry.is_some());
1416
1417 let res = current_project_directory(workspace, cx);
1418 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
1419 let res = first_project_directory(workspace, cx);
1420 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1421 });
1422 }
1423
1424 /// Creates a worktree with 1 file: /root.txt
1425 pub async fn init_test(cx: &mut TestAppContext) -> (Model<Project>, View<Workspace>) {
1426 let params = cx.update(AppState::test);
1427 cx.update(|cx| {
1428 theme::init(theme::LoadThemes::JustBase, cx);
1429 Project::init_settings(cx);
1430 language::init(cx);
1431 });
1432
1433 let project = Project::test(params.fs.clone(), [], cx).await;
1434 let workspace = cx
1435 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1436 .root_view(cx)
1437 .unwrap();
1438
1439 (project, workspace)
1440 }
1441
1442 /// Creates a worktree with 1 folder: /root{suffix}/
1443 async fn create_folder_wt(
1444 project: Model<Project>,
1445 path: impl AsRef<Path>,
1446 cx: &mut TestAppContext,
1447 ) -> (Model<Worktree>, Entry) {
1448 create_wt(project, true, path, cx).await
1449 }
1450
1451 /// Creates a worktree with 1 file: /root{suffix}.txt
1452 async fn create_file_wt(
1453 project: Model<Project>,
1454 path: impl AsRef<Path>,
1455 cx: &mut TestAppContext,
1456 ) -> (Model<Worktree>, Entry) {
1457 create_wt(project, false, path, cx).await
1458 }
1459
1460 async fn create_wt(
1461 project: Model<Project>,
1462 is_dir: bool,
1463 path: impl AsRef<Path>,
1464 cx: &mut TestAppContext,
1465 ) -> (Model<Worktree>, Entry) {
1466 let (wt, _) = project
1467 .update(cx, |project, cx| {
1468 project.find_or_create_worktree(path, true, cx)
1469 })
1470 .await
1471 .unwrap();
1472
1473 let entry = cx
1474 .update(|cx| wt.update(cx, |wt, cx| wt.create_entry(Path::new(""), is_dir, cx)))
1475 .await
1476 .unwrap()
1477 .to_included()
1478 .unwrap();
1479
1480 (wt, entry)
1481 }
1482
1483 pub fn insert_active_entry_for(
1484 wt: Model<Worktree>,
1485 entry: Entry,
1486 project: Model<Project>,
1487 cx: &mut TestAppContext,
1488 ) {
1489 cx.update(|cx| {
1490 let p = ProjectPath {
1491 worktree_id: wt.read(cx).id(),
1492 path: entry.path,
1493 };
1494 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
1495 });
1496 }
1497
1498 #[test]
1499 fn escapes_only_special_characters() {
1500 assert_eq!(regex_to_literal(r"test(\w)"), r"test\(\\w\)".to_string());
1501 }
1502
1503 #[test]
1504 fn empty_string_stays_empty() {
1505 assert_eq!(regex_to_literal(""), "".to_string());
1506 }
1507}