1mod persistence;
2pub mod terminal_element;
3pub mod terminal_panel;
4pub mod terminal_scrollbar;
5mod terminal_slash_command;
6pub mod terminal_tab_tooltip;
7
8use assistant_slash_command::SlashCommandRegistry;
9use editor::{Editor, EditorSettings, actions::SelectAll, scroll::ScrollbarAutoHide};
10use gpui::{
11 AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
12 KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, ScrollWheelEvent,
13 Stateful, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
14 impl_actions,
15};
16use itertools::Itertools;
17use persistence::TERMINAL_DB;
18use project::{Entry, Metadata, Project, search::SearchQuery, terminals::TerminalKind};
19use schemars::JsonSchema;
20use task::TaskId;
21use terminal::{
22 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
23 ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
24 TaskStatus, Terminal, TerminalBounds, ToggleViMode,
25 alacritty_terminal::{
26 index::Point,
27 term::{TermMode, search::RegexSearch},
28 },
29 terminal_settings::{self, CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory},
30};
31use terminal_element::{TerminalElement, is_blank};
32use terminal_panel::TerminalPanel;
33use terminal_scrollbar::TerminalScrollHandle;
34use terminal_slash_command::TerminalSlashCommand;
35use terminal_tab_tooltip::TerminalTooltip;
36use ui::{
37 ContextMenu, Icon, IconName, Label, Scrollbar, ScrollbarState, Tooltip, h_flex, prelude::*,
38};
39use util::{ResultExt, debug_panic, paths::PathWithPosition};
40use workspace::{
41 CloseActiveItem, NewCenterTerminal, NewTerminal, OpenOptions, OpenVisible, ToolbarItemLocation,
42 Workspace, WorkspaceId, delete_unloaded_items,
43 item::{
44 BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
45 },
46 register_serializable_item,
47 searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
48};
49
50use anyhow::Context as _;
51use serde::Deserialize;
52use settings::{Settings, SettingsStore};
53use smol::Timer;
54use zed_actions::assistant::InlineAssist;
55
56use std::{
57 cmp,
58 ops::{Range, RangeInclusive},
59 path::{Path, PathBuf},
60 rc::Rc,
61 sync::Arc,
62 time::Duration,
63};
64
65const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
66
67const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"];
68
69/// Event to transmit the scroll from the element to the view
70#[derive(Clone, Debug, PartialEq)]
71pub struct ScrollTerminal(pub i32);
72
73#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)]
74pub struct SendText(String);
75
76#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)]
77pub struct SendKeystroke(String);
78
79actions!(terminal, [RerunTask]);
80
81impl_actions!(terminal, [SendText, SendKeystroke]);
82
83pub fn init(cx: &mut App) {
84 assistant_slash_command::init(cx);
85 terminal_panel::init(cx);
86 terminal::init(cx);
87
88 register_serializable_item::<TerminalView>(cx);
89
90 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
91 workspace.register_action(TerminalView::deploy);
92 })
93 .detach();
94 SlashCommandRegistry::global(cx).register_command(TerminalSlashCommand, true);
95}
96
97pub struct BlockProperties {
98 pub height: u8,
99 pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
100}
101
102pub struct BlockContext<'a, 'b> {
103 pub window: &'a mut Window,
104 pub context: &'b mut App,
105 pub dimensions: TerminalBounds,
106}
107
108///A terminal view, maintains the PTY's file handles and communicates with the terminal
109pub struct TerminalView {
110 terminal: Entity<Terminal>,
111 workspace: WeakEntity<Workspace>,
112 project: WeakEntity<Project>,
113 focus_handle: FocusHandle,
114 //Currently using iTerm bell, show bell emoji in tab until input is received
115 has_bell: bool,
116 context_menu: Option<(Entity<ContextMenu>, gpui::Point<Pixels>, Subscription)>,
117 cursor_shape: CursorShape,
118 blink_state: bool,
119 mode: TerminalMode,
120 blinking_terminal_enabled: bool,
121 cwd_serialized: bool,
122 blinking_paused: bool,
123 blink_epoch: usize,
124 hover: Option<HoverTarget>,
125 hover_tooltip_update: Task<()>,
126 workspace_id: Option<WorkspaceId>,
127 show_breadcrumbs: bool,
128 block_below_cursor: Option<Rc<BlockProperties>>,
129 scroll_top: Pixels,
130 scrollbar_state: ScrollbarState,
131 scroll_handle: TerminalScrollHandle,
132 show_scrollbar: bool,
133 hide_scrollbar_task: Option<Task<()>>,
134 marked_text: Option<String>,
135 marked_range_utf16: Option<Range<usize>>,
136 _subscriptions: Vec<Subscription>,
137 _terminal_subscriptions: Vec<Subscription>,
138}
139
140#[derive(Default, Clone)]
141pub enum TerminalMode {
142 #[default]
143 Scrollable,
144 Embedded {
145 max_lines: Option<usize>,
146 },
147}
148
149#[derive(Debug)]
150struct HoverTarget {
151 tooltip: String,
152 hovered_word: HoveredWord,
153}
154
155impl EventEmitter<Event> for TerminalView {}
156impl EventEmitter<ItemEvent> for TerminalView {}
157impl EventEmitter<SearchEvent> for TerminalView {}
158
159impl Focusable for TerminalView {
160 fn focus_handle(&self, _cx: &App) -> FocusHandle {
161 self.focus_handle.clone()
162 }
163}
164
165impl TerminalView {
166 ///Create a new Terminal in the current working directory or the user's home directory
167 pub fn deploy(
168 workspace: &mut Workspace,
169 _: &NewCenterTerminal,
170 window: &mut Window,
171 cx: &mut Context<Workspace>,
172 ) {
173 let working_directory = default_working_directory(workspace, cx);
174 TerminalPanel::add_center_terminal(
175 workspace,
176 TerminalKind::Shell(working_directory),
177 window,
178 cx,
179 )
180 .detach_and_log_err(cx);
181 }
182
183 pub fn new(
184 terminal: Entity<Terminal>,
185 workspace: WeakEntity<Workspace>,
186 workspace_id: Option<WorkspaceId>,
187 project: WeakEntity<Project>,
188 window: &mut Window,
189 cx: &mut Context<Self>,
190 ) -> Self {
191 let workspace_handle = workspace.clone();
192 let terminal_subscriptions =
193 subscribe_for_terminal_events(&terminal, workspace, window, cx);
194
195 let focus_handle = cx.focus_handle();
196 let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| {
197 terminal_view.focus_in(window, cx);
198 });
199 let focus_out = cx.on_focus_out(
200 &focus_handle,
201 window,
202 |terminal_view, _event, window, cx| {
203 terminal_view.focus_out(window, cx);
204 },
205 );
206 let cursor_shape = TerminalSettings::get_global(cx)
207 .cursor_shape
208 .unwrap_or_default();
209
210 let scroll_handle = TerminalScrollHandle::new(terminal.read(cx));
211
212 Self {
213 terminal,
214 workspace: workspace_handle,
215 project,
216 has_bell: false,
217 focus_handle,
218 context_menu: None,
219 cursor_shape,
220 blink_state: true,
221 blinking_terminal_enabled: false,
222 blinking_paused: false,
223 blink_epoch: 0,
224 hover: None,
225 hover_tooltip_update: Task::ready(()),
226 mode: TerminalMode::Scrollable,
227 workspace_id,
228 show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
229 block_below_cursor: None,
230 scroll_top: Pixels::ZERO,
231 scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
232 scroll_handle,
233 show_scrollbar: !Self::should_autohide_scrollbar(cx),
234 hide_scrollbar_task: None,
235 cwd_serialized: false,
236 marked_text: None,
237 marked_range_utf16: None,
238 _subscriptions: vec![
239 focus_in,
240 focus_out,
241 cx.observe_global::<SettingsStore>(Self::settings_changed),
242 ],
243 _terminal_subscriptions: terminal_subscriptions,
244 }
245 }
246
247 /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
248 pub fn set_embedded_mode(&mut self, max_lines: Option<usize>, cx: &mut Context<Self>) {
249 self.mode = TerminalMode::Embedded { max_lines };
250 cx.notify();
251 }
252
253 pub fn is_content_limited(&self, window: &Window) -> bool {
254 match &self.mode {
255 TerminalMode::Scrollable => false,
256 TerminalMode::Embedded { max_lines } => {
257 !self.focus_handle.is_focused(window) && max_lines.is_some()
258 }
259 }
260 }
261
262 /// Sets the marked (pre-edit) text from the IME.
263 pub(crate) fn set_marked_text(
264 &mut self,
265 text: String,
266 range: Range<usize>,
267 cx: &mut Context<Self>,
268 ) {
269 self.marked_text = Some(text);
270 self.marked_range_utf16 = Some(range);
271 cx.notify();
272 }
273
274 /// Gets the current marked range (UTF-16).
275 pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
276 self.marked_range_utf16.clone()
277 }
278
279 /// Clears the marked (pre-edit) text state.
280 pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
281 if self.marked_text.is_some() {
282 self.marked_text = None;
283 self.marked_range_utf16 = None;
284 cx.notify();
285 }
286 }
287
288 /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
289 pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
290 if !text.is_empty() {
291 self.terminal.update(cx, |term, _| {
292 term.input(text.to_string().into_bytes());
293 });
294 }
295 }
296
297 pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
298 self.terminal.read(cx).last_content().terminal_bounds
299 }
300
301 pub fn entity(&self) -> &Entity<Terminal> {
302 &self.terminal
303 }
304
305 pub fn has_bell(&self) -> bool {
306 self.has_bell
307 }
308
309 pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
310 self.has_bell = false;
311 cx.emit(Event::Wakeup);
312 }
313
314 pub fn deploy_context_menu(
315 &mut self,
316 position: gpui::Point<Pixels>,
317 window: &mut Window,
318 cx: &mut Context<Self>,
319 ) {
320 let assistant_enabled = self
321 .workspace
322 .upgrade()
323 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
324 .map_or(false, |terminal_panel| {
325 terminal_panel.read(cx).assistant_enabled()
326 });
327 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
328 menu.context(self.focus_handle.clone())
329 .action("New Terminal", Box::new(NewTerminal))
330 .separator()
331 .action("Copy", Box::new(Copy))
332 .action("Paste", Box::new(Paste))
333 .action("Select All", Box::new(SelectAll))
334 .action("Clear", Box::new(Clear))
335 .when(assistant_enabled, |menu| {
336 menu.separator()
337 .action("Inline Assist", Box::new(InlineAssist::default()))
338 })
339 .separator()
340 .action(
341 "Close Terminal Tab",
342 Box::new(CloseActiveItem {
343 save_intent: None,
344 close_pinned: true,
345 }),
346 )
347 });
348
349 window.focus(&context_menu.focus_handle(cx));
350 let subscription = cx.subscribe_in(
351 &context_menu,
352 window,
353 |this, _, _: &DismissEvent, window, cx| {
354 if this.context_menu.as_ref().is_some_and(|context_menu| {
355 context_menu.0.focus_handle(cx).contains_focused(window, cx)
356 }) {
357 cx.focus_self(window);
358 }
359 this.context_menu.take();
360 cx.notify();
361 },
362 );
363
364 self.context_menu = Some((context_menu, position, subscription));
365 }
366
367 fn settings_changed(&mut self, cx: &mut Context<Self>) {
368 let settings = TerminalSettings::get_global(cx);
369 self.show_breadcrumbs = settings.toolbar.breadcrumbs;
370
371 let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
372 let old_cursor_shape = self.cursor_shape;
373 if old_cursor_shape != new_cursor_shape {
374 self.cursor_shape = new_cursor_shape;
375 self.terminal.update(cx, |term, _| {
376 term.set_cursor_shape(self.cursor_shape);
377 });
378 }
379
380 cx.notify();
381 }
382
383 fn show_character_palette(
384 &mut self,
385 _: &ShowCharacterPalette,
386 window: &mut Window,
387 cx: &mut Context<Self>,
388 ) {
389 if self
390 .terminal
391 .read(cx)
392 .last_content
393 .mode
394 .contains(TermMode::ALT_SCREEN)
395 {
396 self.terminal.update(cx, |term, cx| {
397 term.try_keystroke(
398 &Keystroke::parse("ctrl-cmd-space").unwrap(),
399 TerminalSettings::get_global(cx).option_as_meta,
400 )
401 });
402 } else {
403 window.show_character_palette();
404 }
405 }
406
407 fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
408 self.terminal.update(cx, |term, _| term.select_all());
409 cx.notify();
410 }
411
412 fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
413 let task = self
414 .terminal
415 .read(cx)
416 .task()
417 .map(|task| terminal_rerun_override(&task.id))
418 .unwrap_or_default();
419 window.dispatch_action(Box::new(task), cx);
420 }
421
422 fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
423 self.scroll_top = px(0.);
424 self.terminal.update(cx, |term, _| term.clear());
425 cx.notify();
426 }
427
428 fn max_scroll_top(&self, cx: &App) -> Pixels {
429 let terminal = self.terminal.read(cx);
430
431 let Some(block) = self.block_below_cursor.as_ref() else {
432 return Pixels::ZERO;
433 };
434
435 let line_height = terminal.last_content().terminal_bounds.line_height;
436 let mut terminal_lines = terminal.total_lines();
437 let viewport_lines = terminal.viewport_lines();
438 if terminal.total_lines() == terminal.viewport_lines() {
439 let mut last_line = None;
440 for cell in terminal.last_content.cells.iter().rev() {
441 if !is_blank(cell) {
442 break;
443 }
444
445 let last_line = last_line.get_or_insert(cell.point.line);
446 if *last_line != cell.point.line {
447 terminal_lines -= 1;
448 }
449 *last_line = cell.point.line;
450 }
451 }
452
453 let max_scroll_top_in_lines =
454 (block.height as usize).saturating_sub(viewport_lines.saturating_sub(terminal_lines));
455
456 max_scroll_top_in_lines as f32 * line_height
457 }
458
459 fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
460 let terminal_content = self.terminal.read(cx).last_content();
461
462 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
463 let line_height = terminal_content.terminal_bounds.line_height;
464 let y_delta = event.delta.pixel_delta(line_height).y;
465 if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
466 self.scroll_top = cmp::max(
467 Pixels::ZERO,
468 cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
469 );
470 cx.notify();
471 return;
472 }
473 }
474 self.terminal.update(cx, |term, _| term.scroll_wheel(event));
475 }
476
477 fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
478 let terminal_content = self.terminal.read(cx).last_content();
479 if self.block_below_cursor.is_some()
480 && terminal_content.display_offset == 0
481 && self.scroll_top > Pixels::ZERO
482 {
483 let line_height = terminal_content.terminal_bounds.line_height;
484 self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
485 return;
486 }
487
488 self.terminal.update(cx, |term, _| term.scroll_line_up());
489 cx.notify();
490 }
491
492 fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
493 let terminal_content = self.terminal.read(cx).last_content();
494 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
495 let max_scroll_top = self.max_scroll_top(cx);
496 if self.scroll_top < max_scroll_top {
497 let line_height = terminal_content.terminal_bounds.line_height;
498 self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
499 }
500 return;
501 }
502
503 self.terminal.update(cx, |term, _| term.scroll_line_down());
504 cx.notify();
505 }
506
507 fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
508 if self.scroll_top == Pixels::ZERO {
509 self.terminal.update(cx, |term, _| term.scroll_page_up());
510 } else {
511 let line_height = self
512 .terminal
513 .read(cx)
514 .last_content
515 .terminal_bounds
516 .line_height();
517 let visible_block_lines = (self.scroll_top / line_height) as usize;
518 let viewport_lines = self.terminal.read(cx).viewport_lines();
519 let visible_content_lines = viewport_lines - visible_block_lines;
520
521 if visible_block_lines >= viewport_lines {
522 self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
523 } else {
524 self.scroll_top = px(0.);
525 self.terminal
526 .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
527 }
528 }
529 cx.notify();
530 }
531
532 fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
533 self.terminal.update(cx, |term, _| term.scroll_page_down());
534 let terminal = self.terminal.read(cx);
535 if terminal.last_content().display_offset < terminal.viewport_lines() {
536 self.scroll_top = self.max_scroll_top(cx);
537 }
538 cx.notify();
539 }
540
541 fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
542 self.terminal.update(cx, |term, _| term.scroll_to_top());
543 cx.notify();
544 }
545
546 fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
547 self.terminal.update(cx, |term, _| term.scroll_to_bottom());
548 if self.block_below_cursor.is_some() {
549 self.scroll_top = self.max_scroll_top(cx);
550 }
551 cx.notify();
552 }
553
554 fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context<Self>) {
555 self.terminal.update(cx, |term, _| term.toggle_vi_mode());
556 cx.notify();
557 }
558
559 pub fn should_show_cursor(&self, focused: bool, cx: &mut Context<Self>) -> bool {
560 //Don't blink the cursor when not focused, blinking is disabled, or paused
561 if !focused
562 || self.blinking_paused
563 || self
564 .terminal
565 .read(cx)
566 .last_content
567 .mode
568 .contains(TermMode::ALT_SCREEN)
569 {
570 return true;
571 }
572
573 match TerminalSettings::get_global(cx).blinking {
574 //If the user requested to never blink, don't blink it.
575 TerminalBlink::Off => true,
576 //If the terminal is controlling it, check terminal mode
577 TerminalBlink::TerminalControlled => {
578 !self.blinking_terminal_enabled || self.blink_state
579 }
580 TerminalBlink::On => self.blink_state,
581 }
582 }
583
584 fn blink_cursors(&mut self, epoch: usize, window: &mut Window, cx: &mut Context<Self>) {
585 if epoch == self.blink_epoch && !self.blinking_paused {
586 self.blink_state = !self.blink_state;
587 cx.notify();
588
589 let epoch = self.next_blink_epoch();
590 cx.spawn_in(window, async move |this, cx| {
591 Timer::after(CURSOR_BLINK_INTERVAL).await;
592 this.update_in(cx, |this, window, cx| this.blink_cursors(epoch, window, cx))
593 .ok();
594 })
595 .detach();
596 }
597 }
598
599 pub fn pause_cursor_blinking(&mut self, window: &mut Window, cx: &mut Context<Self>) {
600 self.blink_state = true;
601 cx.notify();
602
603 let epoch = self.next_blink_epoch();
604 cx.spawn_in(window, async move |this, cx| {
605 Timer::after(CURSOR_BLINK_INTERVAL).await;
606 this.update_in(cx, |this, window, cx| {
607 this.resume_cursor_blinking(epoch, window, cx)
608 })
609 .ok();
610 })
611 .detach();
612 }
613
614 pub fn terminal(&self) -> &Entity<Terminal> {
615 &self.terminal
616 }
617
618 pub fn set_block_below_cursor(
619 &mut self,
620 block: BlockProperties,
621 window: &mut Window,
622 cx: &mut Context<Self>,
623 ) {
624 self.block_below_cursor = Some(Rc::new(block));
625 self.scroll_to_bottom(&ScrollToBottom, window, cx);
626 cx.notify();
627 }
628
629 pub fn clear_block_below_cursor(&mut self, cx: &mut Context<Self>) {
630 self.block_below_cursor = None;
631 self.scroll_top = Pixels::ZERO;
632 cx.notify();
633 }
634
635 fn next_blink_epoch(&mut self) -> usize {
636 self.blink_epoch += 1;
637 self.blink_epoch
638 }
639
640 fn resume_cursor_blinking(
641 &mut self,
642 epoch: usize,
643 window: &mut Window,
644 cx: &mut Context<Self>,
645 ) {
646 if epoch == self.blink_epoch {
647 self.blinking_paused = false;
648 self.blink_cursors(epoch, window, cx);
649 }
650 }
651
652 ///Attempt to paste the clipboard into the terminal
653 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
654 self.terminal.update(cx, |term, _| term.copy());
655 cx.notify();
656 }
657
658 ///Attempt to paste the clipboard into the terminal
659 fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
660 if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
661 self.terminal
662 .update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
663 }
664 }
665
666 fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
667 self.clear_bell(cx);
668 self.terminal.update(cx, |term, _| {
669 term.input(text.0.to_string().into_bytes());
670 });
671 }
672
673 fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
674 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
675 self.clear_bell(cx);
676 self.terminal.update(cx, |term, cx| {
677 let processed =
678 term.try_keystroke(&keystroke, TerminalSettings::get_global(cx).option_as_meta);
679 if processed && term.vi_mode_enabled() {
680 cx.notify();
681 }
682 processed
683 });
684 }
685 }
686
687 fn dispatch_context(&self, cx: &App) -> KeyContext {
688 let mut dispatch_context = KeyContext::new_with_defaults();
689 dispatch_context.add("Terminal");
690
691 if self.terminal.read(cx).vi_mode_enabled() {
692 dispatch_context.add("vi_mode");
693 }
694
695 let mode = self.terminal.read(cx).last_content.mode;
696 dispatch_context.set(
697 "screen",
698 if mode.contains(TermMode::ALT_SCREEN) {
699 "alt"
700 } else {
701 "normal"
702 },
703 );
704
705 if mode.contains(TermMode::APP_CURSOR) {
706 dispatch_context.add("DECCKM");
707 }
708 if mode.contains(TermMode::APP_KEYPAD) {
709 dispatch_context.add("DECPAM");
710 } else {
711 dispatch_context.add("DECPNM");
712 }
713 if mode.contains(TermMode::SHOW_CURSOR) {
714 dispatch_context.add("DECTCEM");
715 }
716 if mode.contains(TermMode::LINE_WRAP) {
717 dispatch_context.add("DECAWM");
718 }
719 if mode.contains(TermMode::ORIGIN) {
720 dispatch_context.add("DECOM");
721 }
722 if mode.contains(TermMode::INSERT) {
723 dispatch_context.add("IRM");
724 }
725 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
726 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
727 dispatch_context.add("LNM");
728 }
729 if mode.contains(TermMode::FOCUS_IN_OUT) {
730 dispatch_context.add("report_focus");
731 }
732 if mode.contains(TermMode::ALTERNATE_SCROLL) {
733 dispatch_context.add("alternate_scroll");
734 }
735 if mode.contains(TermMode::BRACKETED_PASTE) {
736 dispatch_context.add("bracketed_paste");
737 }
738 if mode.intersects(TermMode::MOUSE_MODE) {
739 dispatch_context.add("any_mouse_reporting");
740 }
741 {
742 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
743 "click"
744 } else if mode.contains(TermMode::MOUSE_DRAG) {
745 "drag"
746 } else if mode.contains(TermMode::MOUSE_MOTION) {
747 "motion"
748 } else {
749 "off"
750 };
751 dispatch_context.set("mouse_reporting", mouse_reporting);
752 }
753 {
754 let format = if mode.contains(TermMode::SGR_MOUSE) {
755 "sgr"
756 } else if mode.contains(TermMode::UTF8_MOUSE) {
757 "utf8"
758 } else {
759 "normal"
760 };
761 dispatch_context.set("mouse_format", format);
762 };
763 dispatch_context
764 }
765
766 fn set_terminal(
767 &mut self,
768 terminal: Entity<Terminal>,
769 window: &mut Window,
770 cx: &mut Context<TerminalView>,
771 ) {
772 self._terminal_subscriptions =
773 subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
774 self.terminal = terminal;
775 }
776
777 // Hack: Using editor in terminal causes cyclic dependency i.e. editor -> terminal -> project -> editor.
778 fn map_show_scrollbar_from_editor_to_terminal(
779 show_scrollbar: editor::ShowScrollbar,
780 ) -> terminal_settings::ShowScrollbar {
781 match show_scrollbar {
782 editor::ShowScrollbar::Auto => terminal_settings::ShowScrollbar::Auto,
783 editor::ShowScrollbar::System => terminal_settings::ShowScrollbar::System,
784 editor::ShowScrollbar::Always => terminal_settings::ShowScrollbar::Always,
785 editor::ShowScrollbar::Never => terminal_settings::ShowScrollbar::Never,
786 }
787 }
788
789 fn should_show_scrollbar(cx: &App) -> bool {
790 let show = TerminalSettings::get_global(cx)
791 .scrollbar
792 .show
793 .unwrap_or_else(|| {
794 Self::map_show_scrollbar_from_editor_to_terminal(
795 EditorSettings::get_global(cx).scrollbar.show,
796 )
797 });
798 match show {
799 terminal_settings::ShowScrollbar::Auto => true,
800 terminal_settings::ShowScrollbar::System => true,
801 terminal_settings::ShowScrollbar::Always => true,
802 terminal_settings::ShowScrollbar::Never => false,
803 }
804 }
805
806 fn should_autohide_scrollbar(cx: &App) -> bool {
807 let show = TerminalSettings::get_global(cx)
808 .scrollbar
809 .show
810 .unwrap_or_else(|| {
811 Self::map_show_scrollbar_from_editor_to_terminal(
812 EditorSettings::get_global(cx).scrollbar.show,
813 )
814 });
815 match show {
816 terminal_settings::ShowScrollbar::Auto => true,
817 terminal_settings::ShowScrollbar::System => cx
818 .try_global::<ScrollbarAutoHide>()
819 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
820 terminal_settings::ShowScrollbar::Always => false,
821 terminal_settings::ShowScrollbar::Never => true,
822 }
823 }
824
825 fn hide_scrollbar(&mut self, cx: &mut Context<Self>) {
826 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
827 if !Self::should_autohide_scrollbar(cx) {
828 return;
829 }
830 self.hide_scrollbar_task = Some(cx.spawn(async move |panel, cx| {
831 cx.background_executor()
832 .timer(SCROLLBAR_SHOW_INTERVAL)
833 .await;
834 panel
835 .update(cx, |panel, cx| {
836 panel.show_scrollbar = false;
837 cx.notify();
838 })
839 .log_err();
840 }))
841 }
842
843 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
844 if !Self::should_show_scrollbar(cx)
845 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
846 || matches!(self.mode, TerminalMode::Embedded { .. })
847 {
848 return None;
849 }
850
851 if self.terminal.read(cx).total_lines() == self.terminal.read(cx).viewport_lines() {
852 return None;
853 }
854
855 self.scroll_handle.update(self.terminal.read(cx));
856
857 if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
858 self.terminal.update(cx, |term, _| {
859 let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
860 match delta.cmp(&0) {
861 std::cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
862 std::cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
863 std::cmp::Ordering::Equal => {}
864 }
865 });
866 }
867
868 Some(
869 div()
870 .occlude()
871 .id("terminal-view-scroll")
872 .on_mouse_move(cx.listener(|_, _, _window, cx| {
873 cx.notify();
874 cx.stop_propagation()
875 }))
876 .on_hover(|_, _window, cx| {
877 cx.stop_propagation();
878 })
879 .on_any_mouse_down(|_, _window, cx| {
880 cx.stop_propagation();
881 })
882 .on_mouse_up(
883 MouseButton::Left,
884 cx.listener(|terminal_view, _, window, cx| {
885 if !terminal_view.scrollbar_state.is_dragging()
886 && !terminal_view.focus_handle.contains_focused(window, cx)
887 {
888 terminal_view.hide_scrollbar(cx);
889 cx.notify();
890 }
891 cx.stop_propagation();
892 }),
893 )
894 .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
895 cx.notify();
896 }))
897 .h_full()
898 .absolute()
899 .right_1()
900 .top_1()
901 .bottom_0()
902 .w(px(12.))
903 .cursor_default()
904 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
905 )
906 }
907
908 fn rerun_button(task: &TaskState) -> Option<IconButton> {
909 if !task.show_rerun {
910 return None;
911 }
912
913 let task_id = task.id.clone();
914 Some(
915 IconButton::new("rerun-icon", IconName::Rerun)
916 .icon_size(IconSize::Small)
917 .size(ButtonSize::Compact)
918 .icon_color(Color::Default)
919 .shape(ui::IconButtonShape::Square)
920 .tooltip(move |window, cx| {
921 Tooltip::for_action("Rerun task", &RerunTask, window, cx)
922 })
923 .on_click(move |_, window, cx| {
924 window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
925 }),
926 )
927 }
928}
929
930fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
931 zed_actions::Rerun {
932 task_id: Some(task.0.clone()),
933 allow_concurrent_runs: Some(true),
934 use_new_terminal: Some(false),
935 reevaluate_context: false,
936 }
937}
938
939fn subscribe_for_terminal_events(
940 terminal: &Entity<Terminal>,
941 workspace: WeakEntity<Workspace>,
942 window: &mut Window,
943 cx: &mut Context<TerminalView>,
944) -> Vec<Subscription> {
945 let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
946 let mut previous_cwd = None;
947 let terminal_events_subscription = cx.subscribe_in(
948 terminal,
949 window,
950 move |terminal_view, terminal, event, window, cx| {
951 let current_cwd = terminal.read(cx).working_directory();
952 if current_cwd != previous_cwd {
953 previous_cwd = current_cwd;
954 terminal_view.cwd_serialized = false;
955 }
956
957 match event {
958 Event::Wakeup => {
959 cx.notify();
960 cx.emit(Event::Wakeup);
961 cx.emit(ItemEvent::UpdateTab);
962 cx.emit(SearchEvent::MatchesInvalidated);
963 }
964
965 Event::Bell => {
966 terminal_view.has_bell = true;
967 cx.emit(Event::Wakeup);
968 }
969
970 Event::BlinkChanged(blinking) => {
971 if matches!(
972 TerminalSettings::get_global(cx).blinking,
973 TerminalBlink::TerminalControlled
974 ) {
975 terminal_view.blinking_terminal_enabled = *blinking;
976 }
977 }
978
979 Event::TitleChanged => {
980 cx.emit(ItemEvent::UpdateTab);
981 }
982
983 Event::NewNavigationTarget(maybe_navigation_target) => {
984 match maybe_navigation_target
985 .as_ref()
986 .zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
987 {
988 Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
989 if Some(hovered_word)
990 != terminal_view
991 .hover
992 .as_ref()
993 .map(|hover| &hover.hovered_word)
994 {
995 terminal_view.hover = Some(HoverTarget {
996 tooltip: url.clone(),
997 hovered_word: hovered_word.clone(),
998 });
999 terminal_view.hover_tooltip_update = Task::ready(());
1000 cx.notify();
1001 }
1002 }
1003 Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
1004 if Some(hovered_word)
1005 != terminal_view
1006 .hover
1007 .as_ref()
1008 .map(|hover| &hover.hovered_word)
1009 {
1010 let valid_files_to_open_task = possible_open_target(
1011 &workspace,
1012 &path_like_target.terminal_dir,
1013 &path_like_target.maybe_path,
1014 cx,
1015 );
1016 let hovered_word = hovered_word.clone();
1017
1018 terminal_view.hover = None;
1019 terminal_view.hover_tooltip_update =
1020 cx.spawn(async move |terminal_view, cx| {
1021 let file_to_open = valid_files_to_open_task.await;
1022 terminal_view
1023 .update(cx, |terminal_view, _| match file_to_open {
1024 Some(
1025 OpenTarget::File(path, _)
1026 | OpenTarget::Worktree(path, _),
1027 ) => {
1028 terminal_view.hover = Some(HoverTarget {
1029 tooltip: path.to_string(|path| {
1030 path.to_string_lossy().to_string()
1031 }),
1032 hovered_word,
1033 });
1034 }
1035 None => {
1036 terminal_view.hover = None;
1037 }
1038 })
1039 .ok();
1040 });
1041 cx.notify();
1042 }
1043 }
1044 None => {
1045 terminal_view.hover = None;
1046 terminal_view.hover_tooltip_update = Task::ready(());
1047 cx.notify();
1048 }
1049 }
1050 }
1051
1052 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
1053 MaybeNavigationTarget::Url(url) => cx.open_url(url),
1054
1055 MaybeNavigationTarget::PathLike(path_like_target) => {
1056 if terminal_view.hover.is_none() {
1057 return;
1058 }
1059 let task_workspace = workspace.clone();
1060 let path_like_target = path_like_target.clone();
1061 cx.spawn_in(window, async move |terminal_view, cx| {
1062 let open_target = terminal_view
1063 .update(cx, |_, cx| {
1064 possible_open_target(
1065 &task_workspace,
1066 &path_like_target.terminal_dir,
1067 &path_like_target.maybe_path,
1068 cx,
1069 )
1070 })?
1071 .await;
1072 if let Some(open_target) = open_target {
1073 let path_to_open = open_target.path();
1074 let opened_items = task_workspace
1075 .update_in(cx, |workspace, window, cx| {
1076 workspace.open_paths(
1077 vec![path_to_open.path.clone()],
1078 OpenOptions {
1079 visible: Some(OpenVisible::OnlyDirectories),
1080 ..Default::default()
1081 },
1082 None,
1083 window,
1084 cx,
1085 )
1086 })
1087 .context("workspace update")?
1088 .await;
1089 if opened_items.len() != 1 {
1090 debug_panic!(
1091 "Received {} items for one path {path_to_open:?}",
1092 opened_items.len(),
1093 );
1094 }
1095
1096 if let Some(opened_item) = opened_items.first() {
1097 if open_target.is_file() {
1098 if let Some(Ok(opened_item)) = opened_item {
1099 if let Some(row) = path_to_open.row {
1100 let col = path_to_open.column.unwrap_or(0);
1101 if let Some(active_editor) =
1102 opened_item.downcast::<Editor>()
1103 {
1104 active_editor
1105 .downgrade()
1106 .update_in(cx, |editor, window, cx| {
1107 editor.go_to_singleton_buffer_point(
1108 language::Point::new(
1109 row.saturating_sub(1),
1110 col.saturating_sub(1),
1111 ),
1112 window,
1113 cx,
1114 )
1115 })
1116 .log_err();
1117 }
1118 }
1119 }
1120 } else if open_target.is_dir() {
1121 task_workspace.update(cx, |workspace, cx| {
1122 workspace.project().update(cx, |_, cx| {
1123 cx.emit(project::Event::ActivateProjectPanel);
1124 })
1125 })?;
1126 }
1127 }
1128 }
1129
1130 anyhow::Ok(())
1131 })
1132 .detach_and_log_err(cx)
1133 }
1134 },
1135 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
1136 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
1137 Event::SelectionsChanged => {
1138 window.invalidate_character_coordinates();
1139 cx.emit(SearchEvent::ActiveMatchChanged)
1140 }
1141 }
1142 },
1143 );
1144 vec![terminal_subscription, terminal_events_subscription]
1145}
1146
1147#[derive(Debug, Clone)]
1148enum OpenTarget {
1149 Worktree(PathWithPosition, Entry),
1150 File(PathWithPosition, Metadata),
1151}
1152
1153impl OpenTarget {
1154 fn is_file(&self) -> bool {
1155 match self {
1156 OpenTarget::Worktree(_, entry) => entry.is_file(),
1157 OpenTarget::File(_, metadata) => !metadata.is_dir,
1158 }
1159 }
1160
1161 fn is_dir(&self) -> bool {
1162 match self {
1163 OpenTarget::Worktree(_, entry) => entry.is_dir(),
1164 OpenTarget::File(_, metadata) => metadata.is_dir,
1165 }
1166 }
1167
1168 fn path(&self) -> &PathWithPosition {
1169 match self {
1170 OpenTarget::Worktree(path, _) => path,
1171 OpenTarget::File(path, _) => path,
1172 }
1173 }
1174}
1175
1176fn possible_open_target(
1177 workspace: &WeakEntity<Workspace>,
1178 cwd: &Option<PathBuf>,
1179 maybe_path: &str,
1180 cx: &App,
1181) -> Task<Option<OpenTarget>> {
1182 let Some(workspace) = workspace.upgrade() else {
1183 return Task::ready(None);
1184 };
1185 // We have to check for both paths, as on Unix, certain paths with positions are valid file paths too.
1186 // We can be on FS remote part, without real FS, so cannot canonicalize or check for existence the path right away.
1187 let mut potential_paths = Vec::new();
1188 let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path));
1189 let path_with_position = PathWithPosition::parse_str(maybe_path);
1190 let worktree_candidates = workspace
1191 .read(cx)
1192 .worktrees(cx)
1193 .sorted_by_key(|worktree| {
1194 let worktree_root = worktree.read(cx).abs_path();
1195 match cwd
1196 .as_ref()
1197 .and_then(|cwd| worktree_root.strip_prefix(cwd).ok())
1198 {
1199 Some(cwd_child) => cwd_child.components().count(),
1200 None => usize::MAX,
1201 }
1202 })
1203 .collect::<Vec<_>>();
1204 // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it.
1205 for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) {
1206 if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() {
1207 potential_paths.push(PathWithPosition {
1208 path: stripped.to_owned(),
1209 row: original_path.row,
1210 column: original_path.column,
1211 });
1212 }
1213 if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() {
1214 potential_paths.push(PathWithPosition {
1215 path: stripped.to_owned(),
1216 row: path_with_position.row,
1217 column: path_with_position.column,
1218 });
1219 }
1220 }
1221
1222 let insert_both_paths = original_path != path_with_position;
1223 potential_paths.insert(0, original_path);
1224 if insert_both_paths {
1225 potential_paths.insert(1, path_with_position);
1226 }
1227
1228 // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix.
1229 // That will be slow, though, so do the fast checks first.
1230 let mut worktree_paths_to_check = Vec::new();
1231 for worktree in &worktree_candidates {
1232 let worktree_root = worktree.read(cx).abs_path();
1233 let mut paths_to_check = Vec::with_capacity(potential_paths.len());
1234
1235 for path_with_position in &potential_paths {
1236 let path_to_check = if worktree_root.ends_with(&path_with_position.path) {
1237 let root_path_with_position = PathWithPosition {
1238 path: worktree_root.to_path_buf(),
1239 row: path_with_position.row,
1240 column: path_with_position.column,
1241 };
1242 match worktree.read(cx).root_entry() {
1243 Some(root_entry) => {
1244 return Task::ready(Some(OpenTarget::Worktree(
1245 root_path_with_position,
1246 root_entry.clone(),
1247 )));
1248 }
1249 None => root_path_with_position,
1250 }
1251 } else {
1252 PathWithPosition {
1253 path: path_with_position
1254 .path
1255 .strip_prefix(&worktree_root)
1256 .unwrap_or(&path_with_position.path)
1257 .to_owned(),
1258 row: path_with_position.row,
1259 column: path_with_position.column,
1260 }
1261 };
1262
1263 if path_to_check.path.is_relative() {
1264 if let Some(entry) = worktree.read(cx).entry_for_path(&path_to_check.path) {
1265 return Task::ready(Some(OpenTarget::Worktree(
1266 PathWithPosition {
1267 path: worktree_root.join(&entry.path),
1268 row: path_to_check.row,
1269 column: path_to_check.column,
1270 },
1271 entry.clone(),
1272 )));
1273 }
1274 }
1275
1276 paths_to_check.push(path_to_check);
1277 }
1278
1279 if !paths_to_check.is_empty() {
1280 worktree_paths_to_check.push((worktree.clone(), paths_to_check));
1281 }
1282 }
1283
1284 // Before entire worktree traversal(s), make an attempt to do FS checks if available.
1285 let fs_paths_to_check = if workspace.read(cx).project().read(cx).is_local() {
1286 potential_paths
1287 .into_iter()
1288 .flat_map(|path_to_check| {
1289 let mut paths_to_check = Vec::new();
1290 let maybe_path = &path_to_check.path;
1291 if maybe_path.starts_with("~") {
1292 if let Some(home_path) =
1293 maybe_path
1294 .strip_prefix("~")
1295 .ok()
1296 .and_then(|stripped_maybe_path| {
1297 Some(dirs::home_dir()?.join(stripped_maybe_path))
1298 })
1299 {
1300 paths_to_check.push(PathWithPosition {
1301 path: home_path,
1302 row: path_to_check.row,
1303 column: path_to_check.column,
1304 });
1305 }
1306 } else {
1307 paths_to_check.push(PathWithPosition {
1308 path: maybe_path.clone(),
1309 row: path_to_check.row,
1310 column: path_to_check.column,
1311 });
1312 if maybe_path.is_relative() {
1313 if let Some(cwd) = &cwd {
1314 paths_to_check.push(PathWithPosition {
1315 path: cwd.join(maybe_path),
1316 row: path_to_check.row,
1317 column: path_to_check.column,
1318 });
1319 }
1320 for worktree in &worktree_candidates {
1321 paths_to_check.push(PathWithPosition {
1322 path: worktree.read(cx).abs_path().join(maybe_path),
1323 row: path_to_check.row,
1324 column: path_to_check.column,
1325 });
1326 }
1327 }
1328 }
1329 paths_to_check
1330 })
1331 .collect()
1332 } else {
1333 Vec::new()
1334 };
1335
1336 let worktree_check_task = cx.spawn(async move |cx| {
1337 for (worktree, worktree_paths_to_check) in worktree_paths_to_check {
1338 let found_entry = worktree
1339 .update(cx, |worktree, _| {
1340 let worktree_root = worktree.abs_path();
1341 let mut traversal = worktree.traverse_from_path(true, true, false, "".as_ref());
1342 while let Some(entry) = traversal.next() {
1343 if let Some(path_in_worktree) = worktree_paths_to_check
1344 .iter()
1345 .find(|path_to_check| entry.path.ends_with(&path_to_check.path))
1346 {
1347 return Some(OpenTarget::Worktree(
1348 PathWithPosition {
1349 path: worktree_root.join(&entry.path),
1350 row: path_in_worktree.row,
1351 column: path_in_worktree.column,
1352 },
1353 entry.clone(),
1354 ));
1355 }
1356 }
1357 None
1358 })
1359 .ok()?;
1360 if let Some(found_entry) = found_entry {
1361 return Some(found_entry);
1362 }
1363 }
1364 None
1365 });
1366
1367 let fs = workspace.read(cx).project().read(cx).fs().clone();
1368 cx.background_spawn(async move {
1369 for mut path_to_check in fs_paths_to_check {
1370 if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() {
1371 if let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() {
1372 path_to_check.path = fs_path_to_check;
1373 return Some(OpenTarget::File(path_to_check, metadata));
1374 }
1375 }
1376 }
1377
1378 worktree_check_task.await
1379 })
1380}
1381
1382fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
1383 let str = query.as_str();
1384 if query.is_regex() {
1385 if str == "." {
1386 return None;
1387 }
1388 RegexSearch::new(str).ok()
1389 } else {
1390 RegexSearch::new(®ex::escape(str)).ok()
1391 }
1392}
1393
1394impl TerminalView {
1395 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1396 self.clear_bell(cx);
1397 self.pause_cursor_blinking(window, cx);
1398
1399 self.terminal.update(cx, |term, cx| {
1400 let handled = term.try_keystroke(
1401 &event.keystroke,
1402 TerminalSettings::get_global(cx).option_as_meta,
1403 );
1404 if handled {
1405 cx.stop_propagation();
1406 }
1407 });
1408 }
1409
1410 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1411 self.terminal.update(cx, |terminal, _| {
1412 terminal.set_cursor_shape(self.cursor_shape);
1413 terminal.focus_in();
1414 });
1415 self.blink_cursors(self.blink_epoch, window, cx);
1416 window.invalidate_character_coordinates();
1417 cx.notify();
1418 }
1419
1420 fn focus_out(&mut self, _: &mut Window, cx: &mut Context<Self>) {
1421 self.terminal.update(cx, |terminal, _| {
1422 terminal.focus_out();
1423 terminal.set_cursor_shape(CursorShape::Hollow);
1424 });
1425 self.hide_scrollbar(cx);
1426 cx.notify();
1427 }
1428}
1429
1430impl Render for TerminalView {
1431 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1432 let terminal_handle = self.terminal.clone();
1433 let terminal_view_handle = cx.entity().clone();
1434
1435 let focused = self.focus_handle.is_focused(window);
1436
1437 div()
1438 .id("terminal-view")
1439 .size_full()
1440 .relative()
1441 .track_focus(&self.focus_handle(cx))
1442 .key_context(self.dispatch_context(cx))
1443 .on_action(cx.listener(TerminalView::send_text))
1444 .on_action(cx.listener(TerminalView::send_keystroke))
1445 .on_action(cx.listener(TerminalView::copy))
1446 .on_action(cx.listener(TerminalView::paste))
1447 .on_action(cx.listener(TerminalView::clear))
1448 .on_action(cx.listener(TerminalView::scroll_line_up))
1449 .on_action(cx.listener(TerminalView::scroll_line_down))
1450 .on_action(cx.listener(TerminalView::scroll_page_up))
1451 .on_action(cx.listener(TerminalView::scroll_page_down))
1452 .on_action(cx.listener(TerminalView::scroll_to_top))
1453 .on_action(cx.listener(TerminalView::scroll_to_bottom))
1454 .on_action(cx.listener(TerminalView::toggle_vi_mode))
1455 .on_action(cx.listener(TerminalView::show_character_palette))
1456 .on_action(cx.listener(TerminalView::select_all))
1457 .on_action(cx.listener(TerminalView::rerun_task))
1458 .on_key_down(cx.listener(Self::key_down))
1459 .on_mouse_down(
1460 MouseButton::Right,
1461 cx.listener(|this, event: &MouseDownEvent, window, cx| {
1462 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1463 if this.terminal.read(cx).last_content.selection.is_none() {
1464 this.terminal.update(cx, |terminal, _| {
1465 terminal.select_word_at_event_position(event);
1466 });
1467 };
1468 this.deploy_context_menu(event.position, window, cx);
1469 cx.notify();
1470 }
1471 }),
1472 )
1473 .on_hover(cx.listener(|this, hovered, window, cx| {
1474 if *hovered {
1475 this.show_scrollbar = true;
1476 this.hide_scrollbar_task.take();
1477 cx.notify();
1478 } else if !this.focus_handle.contains_focused(window, cx) {
1479 this.hide_scrollbar(cx);
1480 }
1481 }))
1482 .child(
1483 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1484 div()
1485 .size_full()
1486 .child(TerminalElement::new(
1487 terminal_handle,
1488 terminal_view_handle,
1489 self.workspace.clone(),
1490 self.focus_handle.clone(),
1491 focused,
1492 self.should_show_cursor(focused, cx),
1493 self.block_below_cursor.clone(),
1494 self.mode.clone(),
1495 ))
1496 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
1497 div.child(scrollbar)
1498 }),
1499 )
1500 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1501 deferred(
1502 anchored()
1503 .position(*position)
1504 .anchor(gpui::Corner::TopLeft)
1505 .child(menu.clone()),
1506 )
1507 .with_priority(1)
1508 }))
1509 }
1510}
1511
1512impl Item for TerminalView {
1513 type Event = ItemEvent;
1514
1515 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1516 let terminal = self.terminal().read(cx);
1517 let title = terminal.title(false);
1518 let pid = terminal.pty_info.pid_getter().fallback_pid();
1519
1520 Some(TabTooltipContent::Custom(Box::new(move |_window, cx| {
1521 cx.new(|_| TerminalTooltip::new(title.clone(), pid)).into()
1522 })))
1523 }
1524
1525 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1526 let terminal = self.terminal().read(cx);
1527 let title = terminal.title(true);
1528
1529 let (icon, icon_color, rerun_button) = match terminal.task() {
1530 Some(terminal_task) => match &terminal_task.status {
1531 TaskStatus::Running => (
1532 IconName::Play,
1533 Color::Disabled,
1534 TerminalView::rerun_button(&terminal_task),
1535 ),
1536 TaskStatus::Unknown => (
1537 IconName::Warning,
1538 Color::Warning,
1539 TerminalView::rerun_button(&terminal_task),
1540 ),
1541 TaskStatus::Completed { success } => {
1542 let rerun_button = TerminalView::rerun_button(&terminal_task);
1543
1544 if *success {
1545 (IconName::Check, Color::Success, rerun_button)
1546 } else {
1547 (IconName::XCircle, Color::Error, rerun_button)
1548 }
1549 }
1550 },
1551 None => (IconName::Terminal, Color::Muted, None),
1552 };
1553
1554 h_flex()
1555 .gap_1()
1556 .group("term-tab-icon")
1557 .child(
1558 h_flex()
1559 .group("term-tab-icon")
1560 .child(
1561 div()
1562 .when(rerun_button.is_some(), |this| {
1563 this.hover(|style| style.invisible().w_0())
1564 })
1565 .child(Icon::new(icon).color(icon_color)),
1566 )
1567 .when_some(rerun_button, |this, rerun_button| {
1568 this.child(
1569 div()
1570 .absolute()
1571 .visible_on_hover("term-tab-icon")
1572 .child(rerun_button),
1573 )
1574 }),
1575 )
1576 .child(Label::new(title).color(params.text_color()))
1577 .into_any()
1578 }
1579
1580 fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1581 let terminal = self.terminal().read(cx);
1582 terminal.title(detail == 0).into()
1583 }
1584
1585 fn telemetry_event_text(&self) -> Option<&'static str> {
1586 None
1587 }
1588
1589 fn clone_on_split(
1590 &self,
1591 workspace_id: Option<WorkspaceId>,
1592 window: &mut Window,
1593 cx: &mut Context<Self>,
1594 ) -> Option<Entity<Self>> {
1595 let window_handle = window.window_handle();
1596 let terminal = self
1597 .project
1598 .update(cx, |project, cx| {
1599 let terminal = self.terminal().read(cx);
1600 let working_directory = terminal
1601 .working_directory()
1602 .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf()));
1603 let python_venv_directory = terminal.python_venv_directory.clone();
1604 project.create_terminal_with_venv(
1605 TerminalKind::Shell(working_directory),
1606 python_venv_directory,
1607 window_handle,
1608 cx,
1609 )
1610 })
1611 .ok()?
1612 .log_err()?;
1613
1614 Some(cx.new(|cx| {
1615 TerminalView::new(
1616 terminal,
1617 self.workspace.clone(),
1618 workspace_id,
1619 self.project.clone(),
1620 window,
1621 cx,
1622 )
1623 }))
1624 }
1625
1626 fn is_dirty(&self, cx: &gpui::App) -> bool {
1627 match self.terminal.read(cx).task() {
1628 Some(task) => task.status == TaskStatus::Running,
1629 None => self.has_bell(),
1630 }
1631 }
1632
1633 fn has_conflict(&self, _cx: &App) -> bool {
1634 false
1635 }
1636
1637 fn can_save_as(&self, _cx: &App) -> bool {
1638 false
1639 }
1640
1641 fn is_singleton(&self, _cx: &App) -> bool {
1642 true
1643 }
1644
1645 fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
1646 Some(Box::new(handle.clone()))
1647 }
1648
1649 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1650 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1651 ToolbarItemLocation::PrimaryLeft
1652 } else {
1653 ToolbarItemLocation::Hidden
1654 }
1655 }
1656
1657 fn breadcrumbs(&self, _: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
1658 Some(vec![BreadcrumbText {
1659 text: self.terminal().read(cx).breadcrumb_text.clone(),
1660 highlights: None,
1661 font: None,
1662 }])
1663 }
1664
1665 fn added_to_workspace(
1666 &mut self,
1667 workspace: &mut Workspace,
1668 _: &mut Window,
1669 cx: &mut Context<Self>,
1670 ) {
1671 if self.terminal().read(cx).task().is_none() {
1672 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1673 log::debug!(
1674 "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1675 );
1676 cx.background_spawn(TERMINAL_DB.update_workspace_id(
1677 new_id,
1678 old_id,
1679 cx.entity_id().as_u64(),
1680 ))
1681 .detach();
1682 }
1683 self.workspace_id = workspace.database_id();
1684 }
1685 }
1686
1687 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1688 f(*event)
1689 }
1690}
1691
1692impl SerializableItem for TerminalView {
1693 fn serialized_item_kind() -> &'static str {
1694 "Terminal"
1695 }
1696
1697 fn cleanup(
1698 workspace_id: WorkspaceId,
1699 alive_items: Vec<workspace::ItemId>,
1700 _window: &mut Window,
1701 cx: &mut App,
1702 ) -> Task<anyhow::Result<()>> {
1703 delete_unloaded_items(alive_items, workspace_id, "terminals", &TERMINAL_DB, cx)
1704 }
1705
1706 fn serialize(
1707 &mut self,
1708 _workspace: &mut Workspace,
1709 item_id: workspace::ItemId,
1710 _closing: bool,
1711 _: &mut Window,
1712 cx: &mut Context<Self>,
1713 ) -> Option<Task<anyhow::Result<()>>> {
1714 let terminal = self.terminal().read(cx);
1715 if terminal.task().is_some() {
1716 return None;
1717 }
1718
1719 if let Some((cwd, workspace_id)) = terminal.working_directory().zip(self.workspace_id) {
1720 self.cwd_serialized = true;
1721 Some(cx.background_spawn(async move {
1722 TERMINAL_DB
1723 .save_working_directory(item_id, workspace_id, cwd)
1724 .await
1725 }))
1726 } else {
1727 None
1728 }
1729 }
1730
1731 fn should_serialize(&self, _: &Self::Event) -> bool {
1732 !self.cwd_serialized
1733 }
1734
1735 fn deserialize(
1736 project: Entity<Project>,
1737 workspace: WeakEntity<Workspace>,
1738 workspace_id: workspace::WorkspaceId,
1739 item_id: workspace::ItemId,
1740 window: &mut Window,
1741 cx: &mut App,
1742 ) -> Task<anyhow::Result<Entity<Self>>> {
1743 let window_handle = window.window_handle();
1744 window.spawn(cx, async move |cx| {
1745 let cwd = cx
1746 .update(|_window, cx| {
1747 let from_db = TERMINAL_DB
1748 .get_working_directory(item_id, workspace_id)
1749 .log_err()
1750 .flatten();
1751 if from_db
1752 .as_ref()
1753 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1754 {
1755 from_db
1756 } else {
1757 workspace
1758 .upgrade()
1759 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1760 }
1761 })
1762 .ok()
1763 .flatten();
1764
1765 let terminal = project
1766 .update(cx, |project, cx| {
1767 project.create_terminal(TerminalKind::Shell(cwd), window_handle, cx)
1768 })?
1769 .await?;
1770 cx.update(|window, cx| {
1771 cx.new(|cx| {
1772 TerminalView::new(
1773 terminal,
1774 workspace,
1775 Some(workspace_id),
1776 project.downgrade(),
1777 window,
1778 cx,
1779 )
1780 })
1781 })
1782 })
1783 }
1784}
1785
1786impl SearchableItem for TerminalView {
1787 type Match = RangeInclusive<Point>;
1788
1789 fn supported_options(&self) -> SearchOptions {
1790 SearchOptions {
1791 case: false,
1792 word: false,
1793 regex: true,
1794 replacement: false,
1795 selection: false,
1796 find_in_results: false,
1797 }
1798 }
1799
1800 /// Clear stored matches
1801 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1802 self.terminal().update(cx, |term, _| term.matches.clear())
1803 }
1804
1805 /// Store matches returned from find_matches somewhere for rendering
1806 fn update_matches(
1807 &mut self,
1808 matches: &[Self::Match],
1809 _window: &mut Window,
1810 cx: &mut Context<Self>,
1811 ) {
1812 self.terminal()
1813 .update(cx, |term, _| term.matches = matches.to_vec())
1814 }
1815
1816 /// Returns the selection content to pre-load into this search
1817 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1818 self.terminal()
1819 .read(cx)
1820 .last_content
1821 .selection_text
1822 .clone()
1823 .unwrap_or_default()
1824 }
1825
1826 /// Focus match at given index into the Vec of matches
1827 fn activate_match(
1828 &mut self,
1829 index: usize,
1830 _: &[Self::Match],
1831 _window: &mut Window,
1832 cx: &mut Context<Self>,
1833 ) {
1834 self.terminal()
1835 .update(cx, |term, _| term.activate_match(index));
1836 cx.notify();
1837 }
1838
1839 /// Add selections for all matches given.
1840 fn select_matches(&mut self, matches: &[Self::Match], _: &mut Window, cx: &mut Context<Self>) {
1841 self.terminal()
1842 .update(cx, |term, _| term.select_matches(matches));
1843 cx.notify();
1844 }
1845
1846 /// Get all of the matches for this query, should be done on the background
1847 fn find_matches(
1848 &mut self,
1849 query: Arc<SearchQuery>,
1850 _: &mut Window,
1851 cx: &mut Context<Self>,
1852 ) -> Task<Vec<Self::Match>> {
1853 if let Some(s) = regex_search_for_query(&query) {
1854 self.terminal()
1855 .update(cx, |term, cx| term.find_matches(s, cx))
1856 } else {
1857 Task::ready(vec![])
1858 }
1859 }
1860
1861 /// Reports back to the search toolbar what the active match should be (the selection)
1862 fn active_match_index(
1863 &mut self,
1864 direction: Direction,
1865 matches: &[Self::Match],
1866 _: &mut Window,
1867 cx: &mut Context<Self>,
1868 ) -> Option<usize> {
1869 // Selection head might have a value if there's a selection that isn't
1870 // associated with a match. Therefore, if there are no matches, we should
1871 // report None, no matter the state of the terminal
1872 let res = if !matches.is_empty() {
1873 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1874 // If selection head is contained in a match. Return that match
1875 match direction {
1876 Direction::Prev => {
1877 // If no selection before selection head, return the first match
1878 Some(
1879 matches
1880 .iter()
1881 .enumerate()
1882 .rev()
1883 .find(|(_, search_match)| {
1884 search_match.contains(&selection_head)
1885 || search_match.start() < &selection_head
1886 })
1887 .map(|(ix, _)| ix)
1888 .unwrap_or(0),
1889 )
1890 }
1891 Direction::Next => {
1892 // If no selection after selection head, return the last match
1893 Some(
1894 matches
1895 .iter()
1896 .enumerate()
1897 .find(|(_, search_match)| {
1898 search_match.contains(&selection_head)
1899 || search_match.start() > &selection_head
1900 })
1901 .map(|(ix, _)| ix)
1902 .unwrap_or(matches.len().saturating_sub(1)),
1903 )
1904 }
1905 }
1906 } else {
1907 // Matches found but no active selection, return the first last one (closest to cursor)
1908 Some(matches.len().saturating_sub(1))
1909 }
1910 } else {
1911 None
1912 };
1913
1914 res
1915 }
1916 fn replace(
1917 &mut self,
1918 _: &Self::Match,
1919 _: &SearchQuery,
1920 _window: &mut Window,
1921 _: &mut Context<Self>,
1922 ) {
1923 // Replacement is not supported in terminal view, so this is a no-op.
1924 }
1925}
1926
1927///Gets the working directory for the given workspace, respecting the user's settings.
1928/// None implies "~" on whichever machine we end up on.
1929pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1930 match &TerminalSettings::get_global(cx).working_directory {
1931 WorkingDirectory::CurrentProjectDirectory => workspace
1932 .project()
1933 .read(cx)
1934 .active_project_directory(cx)
1935 .as_deref()
1936 .map(Path::to_path_buf),
1937 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1938 WorkingDirectory::AlwaysHome => None,
1939 WorkingDirectory::Always { directory } => {
1940 shellexpand::full(&directory) //TODO handle this better
1941 .ok()
1942 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1943 .filter(|dir| dir.is_dir())
1944 }
1945 }
1946}
1947///Gets the first project's home directory, or the home directory
1948fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1949 let worktree = workspace.worktrees(cx).next()?.read(cx);
1950 if !worktree.root_entry()?.is_dir() {
1951 return None;
1952 }
1953 Some(worktree.abs_path().to_path_buf())
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 use super::*;
1959 use gpui::TestAppContext;
1960 use project::{Entry, Project, ProjectPath, Worktree};
1961 use std::path::Path;
1962 use workspace::AppState;
1963
1964 // Working directory calculation tests
1965
1966 // No Worktrees in project -> home_dir()
1967 #[gpui::test]
1968 async fn no_worktree(cx: &mut TestAppContext) {
1969 let (project, workspace) = init_test(cx).await;
1970 cx.read(|cx| {
1971 let workspace = workspace.read(cx);
1972 let active_entry = project.read(cx).active_entry();
1973
1974 //Make sure environment is as expected
1975 assert!(active_entry.is_none());
1976 assert!(workspace.worktrees(cx).next().is_none());
1977
1978 let res = default_working_directory(workspace, cx);
1979 assert_eq!(res, None);
1980 let res = first_project_directory(workspace, cx);
1981 assert_eq!(res, None);
1982 });
1983 }
1984
1985 // No active entry, but a worktree, worktree is a file -> home_dir()
1986 #[gpui::test]
1987 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1988 let (project, workspace) = init_test(cx).await;
1989
1990 create_file_wt(project.clone(), "/root.txt", cx).await;
1991 cx.read(|cx| {
1992 let workspace = workspace.read(cx);
1993 let active_entry = project.read(cx).active_entry();
1994
1995 //Make sure environment is as expected
1996 assert!(active_entry.is_none());
1997 assert!(workspace.worktrees(cx).next().is_some());
1998
1999 let res = default_working_directory(workspace, cx);
2000 assert_eq!(res, None);
2001 let res = first_project_directory(workspace, cx);
2002 assert_eq!(res, None);
2003 });
2004 }
2005
2006 // No active entry, but a worktree, worktree is a folder -> worktree_folder
2007 #[gpui::test]
2008 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2009 let (project, workspace) = init_test(cx).await;
2010
2011 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2012 cx.update(|cx| {
2013 let workspace = workspace.read(cx);
2014 let active_entry = project.read(cx).active_entry();
2015
2016 assert!(active_entry.is_none());
2017 assert!(workspace.worktrees(cx).next().is_some());
2018
2019 let res = default_working_directory(workspace, cx);
2020 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
2021 let res = first_project_directory(workspace, cx);
2022 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
2023 });
2024 }
2025
2026 // Active entry with a work tree, worktree is a file -> worktree_folder()
2027 #[gpui::test]
2028 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
2029 let (project, workspace) = init_test(cx).await;
2030
2031 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2032 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
2033 insert_active_entry_for(wt2, entry2, project.clone(), cx);
2034
2035 cx.update(|cx| {
2036 let workspace = workspace.read(cx);
2037 let active_entry = project.read(cx).active_entry();
2038
2039 assert!(active_entry.is_some());
2040
2041 let res = default_working_directory(workspace, cx);
2042 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
2043 let res = first_project_directory(workspace, cx);
2044 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
2045 });
2046 }
2047
2048 // Active entry, with a worktree, worktree is a folder -> worktree_folder
2049 #[gpui::test]
2050 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2051 let (project, workspace) = init_test(cx).await;
2052
2053 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2054 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
2055 insert_active_entry_for(wt2, entry2, project.clone(), cx);
2056
2057 cx.update(|cx| {
2058 let workspace = workspace.read(cx);
2059 let active_entry = project.read(cx).active_entry();
2060
2061 assert!(active_entry.is_some());
2062
2063 let res = default_working_directory(workspace, cx);
2064 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
2065 let res = first_project_directory(workspace, cx);
2066 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
2067 });
2068 }
2069
2070 /// Creates a worktree with 1 file: /root.txt
2071 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
2072 let params = cx.update(AppState::test);
2073 cx.update(|cx| {
2074 terminal::init(cx);
2075 theme::init(theme::LoadThemes::JustBase, cx);
2076 Project::init_settings(cx);
2077 language::init(cx);
2078 });
2079
2080 let project = Project::test(params.fs.clone(), [], cx).await;
2081 let workspace = cx
2082 .add_window(|window, cx| Workspace::test_new(project.clone(), window, cx))
2083 .root(cx)
2084 .unwrap();
2085
2086 (project, workspace)
2087 }
2088
2089 /// Creates a worktree with 1 folder: /root{suffix}/
2090 async fn create_folder_wt(
2091 project: Entity<Project>,
2092 path: impl AsRef<Path>,
2093 cx: &mut TestAppContext,
2094 ) -> (Entity<Worktree>, Entry) {
2095 create_wt(project, true, path, cx).await
2096 }
2097
2098 /// Creates a worktree with 1 file: /root{suffix}.txt
2099 async fn create_file_wt(
2100 project: Entity<Project>,
2101 path: impl AsRef<Path>,
2102 cx: &mut TestAppContext,
2103 ) -> (Entity<Worktree>, Entry) {
2104 create_wt(project, false, path, cx).await
2105 }
2106
2107 async fn create_wt(
2108 project: Entity<Project>,
2109 is_dir: bool,
2110 path: impl AsRef<Path>,
2111 cx: &mut TestAppContext,
2112 ) -> (Entity<Worktree>, Entry) {
2113 let (wt, _) = project
2114 .update(cx, |project, cx| {
2115 project.find_or_create_worktree(path, true, cx)
2116 })
2117 .await
2118 .unwrap();
2119
2120 let entry = cx
2121 .update(|cx| {
2122 wt.update(cx, |wt, cx| {
2123 wt.create_entry(Path::new(""), is_dir, None, cx)
2124 })
2125 })
2126 .await
2127 .unwrap()
2128 .to_included()
2129 .unwrap();
2130
2131 (wt, entry)
2132 }
2133
2134 pub fn insert_active_entry_for(
2135 wt: Entity<Worktree>,
2136 entry: Entry,
2137 project: Entity<Project>,
2138 cx: &mut TestAppContext,
2139 ) {
2140 cx.update(|cx| {
2141 let p = ProjectPath {
2142 worktree_id: wt.read(cx).id(),
2143 path: entry.path,
2144 };
2145 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2146 });
2147 }
2148}