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