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