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