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