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