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