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_position = 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_position,
1112 root_entry.clone(),
1113 )));
1114 }
1115 None => root_path_with_position,
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 path_to_check.path.is_relative() {
1130 if let Some(entry) = worktree.read(cx).entry_for_path(&path_to_check.path) {
1131 return Task::ready(Some(OpenTarget::Worktree(
1132 PathWithPosition {
1133 path: worktree_root.join(&entry.path),
1134 row: path_to_check.row,
1135 column: path_to_check.column,
1136 },
1137 entry.clone(),
1138 )));
1139 }
1140 }
1141
1142 paths_to_check.push(path_to_check);
1143 }
1144
1145 if !paths_to_check.is_empty() {
1146 worktree_paths_to_check.push((worktree.clone(), paths_to_check));
1147 }
1148 }
1149
1150 // Before entire worktree traversal(s), make an attempt to do FS checks if available.
1151 let fs_paths_to_check = if workspace.read(cx).project().read(cx).is_local() {
1152 potential_paths
1153 .into_iter()
1154 .flat_map(|path_to_check| {
1155 let mut paths_to_check = Vec::new();
1156 let maybe_path = &path_to_check.path;
1157 if maybe_path.starts_with("~") {
1158 if let Some(home_path) =
1159 maybe_path
1160 .strip_prefix("~")
1161 .ok()
1162 .and_then(|stripped_maybe_path| {
1163 Some(dirs::home_dir()?.join(stripped_maybe_path))
1164 })
1165 {
1166 paths_to_check.push(PathWithPosition {
1167 path: home_path,
1168 row: path_to_check.row,
1169 column: path_to_check.column,
1170 });
1171 }
1172 } else {
1173 paths_to_check.push(PathWithPosition {
1174 path: maybe_path.clone(),
1175 row: path_to_check.row,
1176 column: path_to_check.column,
1177 });
1178 if maybe_path.is_relative() {
1179 if let Some(cwd) = &cwd {
1180 paths_to_check.push(PathWithPosition {
1181 path: cwd.join(maybe_path),
1182 row: path_to_check.row,
1183 column: path_to_check.column,
1184 });
1185 }
1186 for worktree in &worktree_candidates {
1187 paths_to_check.push(PathWithPosition {
1188 path: worktree.read(cx).abs_path().join(maybe_path),
1189 row: path_to_check.row,
1190 column: path_to_check.column,
1191 });
1192 }
1193 }
1194 }
1195 paths_to_check
1196 })
1197 .collect()
1198 } else {
1199 Vec::new()
1200 };
1201
1202 let worktree_check_task = cx.spawn(async move |cx| {
1203 for (worktree, worktree_paths_to_check) in worktree_paths_to_check {
1204 let found_entry = worktree
1205 .update(cx, |worktree, _| {
1206 let worktree_root = worktree.abs_path();
1207 let mut traversal = worktree.traverse_from_path(true, true, false, "".as_ref());
1208 while let Some(entry) = traversal.next() {
1209 if let Some(path_in_worktree) = worktree_paths_to_check
1210 .iter()
1211 .find(|path_to_check| entry.path.ends_with(&path_to_check.path))
1212 {
1213 return Some(OpenTarget::Worktree(
1214 PathWithPosition {
1215 path: worktree_root.join(&entry.path),
1216 row: path_in_worktree.row,
1217 column: path_in_worktree.column,
1218 },
1219 entry.clone(),
1220 ));
1221 }
1222 }
1223 None
1224 })
1225 .ok()?;
1226 if let Some(found_entry) = found_entry {
1227 return Some(found_entry);
1228 }
1229 }
1230 None
1231 });
1232
1233 let fs = workspace.read(cx).project().read(cx).fs().clone();
1234 cx.background_spawn(async move {
1235 for mut path_to_check in fs_paths_to_check {
1236 if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() {
1237 if let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() {
1238 path_to_check.path = fs_path_to_check;
1239 return Some(OpenTarget::File(path_to_check, metadata));
1240 }
1241 }
1242 }
1243
1244 worktree_check_task.await
1245 })
1246}
1247
1248fn regex_to_literal(regex: &str) -> String {
1249 regex
1250 .chars()
1251 .flat_map(|c| {
1252 if REGEX_SPECIAL_CHARS.contains(&c) {
1253 vec!['\\', c]
1254 } else {
1255 vec![c]
1256 }
1257 })
1258 .collect()
1259}
1260
1261pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
1262 let query = query.as_str();
1263 if query == "." {
1264 return None;
1265 }
1266 let searcher = RegexSearch::new(query);
1267 searcher.ok()
1268}
1269
1270impl TerminalView {
1271 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1272 self.clear_bell(cx);
1273 self.pause_cursor_blinking(window, cx);
1274
1275 self.terminal.update(cx, |term, cx| {
1276 let handled = term.try_keystroke(
1277 &event.keystroke,
1278 TerminalSettings::get_global(cx).option_as_meta,
1279 );
1280 if handled {
1281 cx.stop_propagation();
1282 }
1283 });
1284 }
1285
1286 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1287 self.terminal.update(cx, |terminal, _| {
1288 terminal.set_cursor_shape(self.cursor_shape);
1289 terminal.focus_in();
1290 });
1291 self.blink_cursors(self.blink_epoch, window, cx);
1292 window.invalidate_character_coordinates();
1293 cx.notify();
1294 }
1295
1296 fn focus_out(&mut self, _: &mut Window, cx: &mut Context<Self>) {
1297 self.terminal.update(cx, |terminal, _| {
1298 terminal.focus_out();
1299 terminal.set_cursor_shape(CursorShape::Hollow);
1300 });
1301 self.hide_scrollbar(cx);
1302 cx.notify();
1303 }
1304}
1305
1306impl Render for TerminalView {
1307 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1308 let terminal_handle = self.terminal.clone();
1309 let terminal_view_handle = cx.entity().clone();
1310
1311 let focused = self.focus_handle.is_focused(window);
1312
1313 div()
1314 .id("terminal-view")
1315 .size_full()
1316 .relative()
1317 .track_focus(&self.focus_handle(cx))
1318 .key_context(self.dispatch_context(cx))
1319 .on_action(cx.listener(TerminalView::send_text))
1320 .on_action(cx.listener(TerminalView::send_keystroke))
1321 .on_action(cx.listener(TerminalView::copy))
1322 .on_action(cx.listener(TerminalView::paste))
1323 .on_action(cx.listener(TerminalView::clear))
1324 .on_action(cx.listener(TerminalView::scroll_line_up))
1325 .on_action(cx.listener(TerminalView::scroll_line_down))
1326 .on_action(cx.listener(TerminalView::scroll_page_up))
1327 .on_action(cx.listener(TerminalView::scroll_page_down))
1328 .on_action(cx.listener(TerminalView::scroll_to_top))
1329 .on_action(cx.listener(TerminalView::scroll_to_bottom))
1330 .on_action(cx.listener(TerminalView::toggle_vi_mode))
1331 .on_action(cx.listener(TerminalView::show_character_palette))
1332 .on_action(cx.listener(TerminalView::select_all))
1333 .on_key_down(cx.listener(Self::key_down))
1334 .on_mouse_down(
1335 MouseButton::Right,
1336 cx.listener(|this, event: &MouseDownEvent, window, cx| {
1337 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1338 if this.terminal.read(cx).last_content.selection.is_none() {
1339 this.terminal.update(cx, |terminal, _| {
1340 terminal.select_word_at_event_position(event);
1341 });
1342 };
1343 this.deploy_context_menu(event.position, window, cx);
1344 cx.notify();
1345 }
1346 }),
1347 )
1348 .on_hover(cx.listener(|this, hovered, window, cx| {
1349 if *hovered {
1350 this.show_scrollbar = true;
1351 this.hide_scrollbar_task.take();
1352 cx.notify();
1353 } else if !this.focus_handle.contains_focused(window, cx) {
1354 this.hide_scrollbar(cx);
1355 }
1356 }))
1357 .child(
1358 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1359 div()
1360 .size_full()
1361 .child(TerminalElement::new(
1362 terminal_handle,
1363 terminal_view_handle,
1364 self.workspace.clone(),
1365 self.focus_handle.clone(),
1366 focused,
1367 self.should_show_cursor(focused, cx),
1368 self.block_below_cursor.clone(),
1369 ))
1370 .when_some(self.render_scrollbar(cx), |div, scrollbar| {
1371 div.child(scrollbar)
1372 }),
1373 )
1374 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1375 deferred(
1376 anchored()
1377 .position(*position)
1378 .anchor(gpui::Corner::TopLeft)
1379 .child(menu.clone()),
1380 )
1381 .with_priority(1)
1382 }))
1383 }
1384}
1385
1386impl Item for TerminalView {
1387 type Event = ItemEvent;
1388
1389 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1390 let terminal = self.terminal().read(cx);
1391 let title = terminal.title(false);
1392 let pid = terminal.pty_info.pid_getter().fallback_pid();
1393
1394 Some(TabTooltipContent::Custom(Box::new(move |_window, cx| {
1395 cx.new(|_| TerminalTooltip::new(title.clone(), pid)).into()
1396 })))
1397 }
1398
1399 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1400 let terminal = self.terminal().read(cx);
1401 let title = terminal.title(true);
1402
1403 let (icon, icon_color, rerun_button) = match terminal.task() {
1404 Some(terminal_task) => match &terminal_task.status {
1405 TaskStatus::Running => (
1406 IconName::Play,
1407 Color::Disabled,
1408 TerminalView::rerun_button(&terminal_task),
1409 ),
1410 TaskStatus::Unknown => (
1411 IconName::Warning,
1412 Color::Warning,
1413 TerminalView::rerun_button(&terminal_task),
1414 ),
1415 TaskStatus::Completed { success } => {
1416 let rerun_button = TerminalView::rerun_button(&terminal_task);
1417
1418 if *success {
1419 (IconName::Check, Color::Success, rerun_button)
1420 } else {
1421 (IconName::XCircle, Color::Error, rerun_button)
1422 }
1423 }
1424 },
1425 None => (IconName::Terminal, Color::Muted, None),
1426 };
1427
1428 h_flex()
1429 .gap_1()
1430 .group("term-tab-icon")
1431 .child(
1432 h_flex()
1433 .group("term-tab-icon")
1434 .child(
1435 div()
1436 .when(rerun_button.is_some(), |this| {
1437 this.hover(|style| style.invisible().w_0())
1438 })
1439 .child(Icon::new(icon).color(icon_color)),
1440 )
1441 .when_some(rerun_button, |this, rerun_button| {
1442 this.child(
1443 div()
1444 .absolute()
1445 .visible_on_hover("term-tab-icon")
1446 .child(rerun_button),
1447 )
1448 }),
1449 )
1450 .child(Label::new(title).color(params.text_color()))
1451 .into_any()
1452 }
1453
1454 fn telemetry_event_text(&self) -> Option<&'static str> {
1455 None
1456 }
1457
1458 fn clone_on_split(
1459 &self,
1460 workspace_id: Option<WorkspaceId>,
1461 window: &mut Window,
1462 cx: &mut Context<Self>,
1463 ) -> Option<Entity<Self>> {
1464 let window_handle = window.window_handle();
1465 let terminal = self
1466 .project
1467 .update(cx, |project, cx| {
1468 let terminal = self.terminal().read(cx);
1469 let working_directory = terminal
1470 .working_directory()
1471 .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf()));
1472 let python_venv_directory = terminal.python_venv_directory.clone();
1473 project.create_terminal_with_venv(
1474 TerminalKind::Shell(working_directory),
1475 python_venv_directory,
1476 window_handle,
1477 cx,
1478 )
1479 })
1480 .ok()?
1481 .log_err()?;
1482
1483 Some(cx.new(|cx| {
1484 TerminalView::new(
1485 terminal,
1486 self.workspace.clone(),
1487 workspace_id,
1488 self.project.clone(),
1489 window,
1490 cx,
1491 )
1492 }))
1493 }
1494
1495 fn is_dirty(&self, cx: &gpui::App) -> bool {
1496 match self.terminal.read(cx).task() {
1497 Some(task) => task.status == TaskStatus::Running,
1498 None => self.has_bell(),
1499 }
1500 }
1501
1502 fn has_conflict(&self, _cx: &App) -> bool {
1503 false
1504 }
1505
1506 fn can_save_as(&self, _cx: &App) -> bool {
1507 false
1508 }
1509
1510 fn is_singleton(&self, _cx: &App) -> bool {
1511 true
1512 }
1513
1514 fn as_searchable(&self, handle: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
1515 Some(Box::new(handle.clone()))
1516 }
1517
1518 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1519 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1520 ToolbarItemLocation::PrimaryLeft
1521 } else {
1522 ToolbarItemLocation::Hidden
1523 }
1524 }
1525
1526 fn breadcrumbs(&self, _: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
1527 Some(vec![BreadcrumbText {
1528 text: self.terminal().read(cx).breadcrumb_text.clone(),
1529 highlights: None,
1530 font: None,
1531 }])
1532 }
1533
1534 fn added_to_workspace(
1535 &mut self,
1536 workspace: &mut Workspace,
1537 _: &mut Window,
1538 cx: &mut Context<Self>,
1539 ) {
1540 if self.terminal().read(cx).task().is_none() {
1541 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1542 cx.background_spawn(TERMINAL_DB.update_workspace_id(
1543 new_id,
1544 old_id,
1545 cx.entity_id().as_u64(),
1546 ))
1547 .detach();
1548 }
1549 self.workspace_id = workspace.database_id();
1550 }
1551 }
1552
1553 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1554 f(*event)
1555 }
1556}
1557
1558impl SerializableItem for TerminalView {
1559 fn serialized_item_kind() -> &'static str {
1560 "Terminal"
1561 }
1562
1563 fn cleanup(
1564 workspace_id: WorkspaceId,
1565 alive_items: Vec<workspace::ItemId>,
1566 window: &mut Window,
1567 cx: &mut App,
1568 ) -> Task<gpui::Result<()>> {
1569 window.spawn(cx, async move |_| {
1570 TERMINAL_DB
1571 .delete_unloaded_items(workspace_id, alive_items)
1572 .await
1573 })
1574 }
1575
1576 fn serialize(
1577 &mut self,
1578 _workspace: &mut Workspace,
1579 item_id: workspace::ItemId,
1580 _closing: bool,
1581 _: &mut Window,
1582 cx: &mut Context<Self>,
1583 ) -> Option<Task<gpui::Result<()>>> {
1584 let terminal = self.terminal().read(cx);
1585 if terminal.task().is_some() {
1586 return None;
1587 }
1588
1589 if let Some((cwd, workspace_id)) = terminal.working_directory().zip(self.workspace_id) {
1590 Some(cx.background_spawn(async move {
1591 TERMINAL_DB
1592 .save_working_directory(item_id, workspace_id, cwd)
1593 .await
1594 }))
1595 } else {
1596 None
1597 }
1598 }
1599
1600 fn should_serialize(&self, event: &Self::Event) -> bool {
1601 matches!(event, ItemEvent::UpdateTab)
1602 }
1603
1604 fn deserialize(
1605 project: Entity<Project>,
1606 workspace: WeakEntity<Workspace>,
1607 workspace_id: workspace::WorkspaceId,
1608 item_id: workspace::ItemId,
1609 window: &mut Window,
1610 cx: &mut App,
1611 ) -> Task<anyhow::Result<Entity<Self>>> {
1612 let window_handle = window.window_handle();
1613 window.spawn(cx, async move |cx| {
1614 let cwd = cx
1615 .update(|_window, cx| {
1616 let from_db = TERMINAL_DB
1617 .get_working_directory(item_id, workspace_id)
1618 .log_err()
1619 .flatten();
1620 if from_db
1621 .as_ref()
1622 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1623 {
1624 from_db
1625 } else {
1626 workspace
1627 .upgrade()
1628 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1629 }
1630 })
1631 .ok()
1632 .flatten();
1633
1634 let terminal = project
1635 .update(cx, |project, cx| {
1636 project.create_terminal(TerminalKind::Shell(cwd), window_handle, cx)
1637 })?
1638 .await?;
1639 cx.update(|window, cx| {
1640 cx.new(|cx| {
1641 TerminalView::new(
1642 terminal,
1643 workspace,
1644 Some(workspace_id),
1645 project.downgrade(),
1646 window,
1647 cx,
1648 )
1649 })
1650 })
1651 })
1652 }
1653}
1654
1655impl SearchableItem for TerminalView {
1656 type Match = RangeInclusive<Point>;
1657
1658 fn supported_options(&self) -> SearchOptions {
1659 SearchOptions {
1660 case: false,
1661 word: false,
1662 regex: true,
1663 replacement: false,
1664 selection: false,
1665 find_in_results: false,
1666 }
1667 }
1668
1669 /// Clear stored matches
1670 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1671 self.terminal().update(cx, |term, _| term.matches.clear())
1672 }
1673
1674 /// Store matches returned from find_matches somewhere for rendering
1675 fn update_matches(
1676 &mut self,
1677 matches: &[Self::Match],
1678 _window: &mut Window,
1679 cx: &mut Context<Self>,
1680 ) {
1681 self.terminal()
1682 .update(cx, |term, _| term.matches = matches.to_vec())
1683 }
1684
1685 /// Returns the selection content to pre-load into this search
1686 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1687 self.terminal()
1688 .read(cx)
1689 .last_content
1690 .selection_text
1691 .clone()
1692 .unwrap_or_default()
1693 }
1694
1695 /// Focus match at given index into the Vec of matches
1696 fn activate_match(
1697 &mut self,
1698 index: usize,
1699 _: &[Self::Match],
1700 _window: &mut Window,
1701 cx: &mut Context<Self>,
1702 ) {
1703 self.terminal()
1704 .update(cx, |term, _| term.activate_match(index));
1705 cx.notify();
1706 }
1707
1708 /// Add selections for all matches given.
1709 fn select_matches(&mut self, matches: &[Self::Match], _: &mut Window, cx: &mut Context<Self>) {
1710 self.terminal()
1711 .update(cx, |term, _| term.select_matches(matches));
1712 cx.notify();
1713 }
1714
1715 /// Get all of the matches for this query, should be done on the background
1716 fn find_matches(
1717 &mut self,
1718 query: Arc<SearchQuery>,
1719 _: &mut Window,
1720 cx: &mut Context<Self>,
1721 ) -> Task<Vec<Self::Match>> {
1722 let searcher = match &*query {
1723 SearchQuery::Text { .. } => regex_search_for_query(
1724 &(SearchQuery::text(
1725 regex_to_literal(query.as_str()),
1726 query.whole_word(),
1727 query.case_sensitive(),
1728 query.include_ignored(),
1729 query.files_to_include().clone(),
1730 query.files_to_exclude().clone(),
1731 false,
1732 None,
1733 )
1734 .unwrap()),
1735 ),
1736 SearchQuery::Regex { .. } => regex_search_for_query(&query),
1737 };
1738
1739 if let Some(s) = searcher {
1740 self.terminal()
1741 .update(cx, |term, cx| term.find_matches(s, cx))
1742 } else {
1743 Task::ready(vec![])
1744 }
1745 }
1746
1747 /// Reports back to the search toolbar what the active match should be (the selection)
1748 fn active_match_index(
1749 &mut self,
1750 direction: Direction,
1751 matches: &[Self::Match],
1752 _: &mut Window,
1753 cx: &mut Context<Self>,
1754 ) -> Option<usize> {
1755 // Selection head might have a value if there's a selection that isn't
1756 // associated with a match. Therefore, if there are no matches, we should
1757 // report None, no matter the state of the terminal
1758 let res = if !matches.is_empty() {
1759 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1760 // If selection head is contained in a match. Return that match
1761 match direction {
1762 Direction::Prev => {
1763 // If no selection before selection head, return the first match
1764 Some(
1765 matches
1766 .iter()
1767 .enumerate()
1768 .rev()
1769 .find(|(_, search_match)| {
1770 search_match.contains(&selection_head)
1771 || search_match.start() < &selection_head
1772 })
1773 .map(|(ix, _)| ix)
1774 .unwrap_or(0),
1775 )
1776 }
1777 Direction::Next => {
1778 // If no selection after selection head, return the last match
1779 Some(
1780 matches
1781 .iter()
1782 .enumerate()
1783 .find(|(_, search_match)| {
1784 search_match.contains(&selection_head)
1785 || search_match.start() > &selection_head
1786 })
1787 .map(|(ix, _)| ix)
1788 .unwrap_or(matches.len().saturating_sub(1)),
1789 )
1790 }
1791 }
1792 } else {
1793 // Matches found but no active selection, return the first last one (closest to cursor)
1794 Some(matches.len().saturating_sub(1))
1795 }
1796 } else {
1797 None
1798 };
1799
1800 res
1801 }
1802 fn replace(
1803 &mut self,
1804 _: &Self::Match,
1805 _: &SearchQuery,
1806 _window: &mut Window,
1807 _: &mut Context<Self>,
1808 ) {
1809 // Replacement is not supported in terminal view, so this is a no-op.
1810 }
1811}
1812
1813///Gets the working directory for the given workspace, respecting the user's settings.
1814/// None implies "~" on whichever machine we end up on.
1815pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1816 match &TerminalSettings::get_global(cx).working_directory {
1817 WorkingDirectory::CurrentProjectDirectory => workspace
1818 .project()
1819 .read(cx)
1820 .active_project_directory(cx)
1821 .as_deref()
1822 .map(Path::to_path_buf),
1823 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1824 WorkingDirectory::AlwaysHome => None,
1825 WorkingDirectory::Always { directory } => {
1826 shellexpand::full(&directory) //TODO handle this better
1827 .ok()
1828 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1829 .filter(|dir| dir.is_dir())
1830 }
1831 }
1832}
1833///Gets the first project's home directory, or the home directory
1834fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1835 let worktree = workspace.worktrees(cx).next()?.read(cx);
1836 if !worktree.root_entry()?.is_dir() {
1837 return None;
1838 }
1839 Some(worktree.abs_path().to_path_buf())
1840}
1841
1842#[cfg(test)]
1843mod tests {
1844 use super::*;
1845 use gpui::TestAppContext;
1846 use project::{Entry, Project, ProjectPath, Worktree};
1847 use std::path::Path;
1848 use workspace::AppState;
1849
1850 // Working directory calculation tests
1851
1852 // No Worktrees in project -> home_dir()
1853 #[gpui::test]
1854 async fn no_worktree(cx: &mut TestAppContext) {
1855 let (project, workspace) = init_test(cx).await;
1856 cx.read(|cx| {
1857 let workspace = workspace.read(cx);
1858 let active_entry = project.read(cx).active_entry();
1859
1860 //Make sure environment is as expected
1861 assert!(active_entry.is_none());
1862 assert!(workspace.worktrees(cx).next().is_none());
1863
1864 let res = default_working_directory(workspace, cx);
1865 assert_eq!(res, None);
1866 let res = first_project_directory(workspace, cx);
1867 assert_eq!(res, None);
1868 });
1869 }
1870
1871 // No active entry, but a worktree, worktree is a file -> home_dir()
1872 #[gpui::test]
1873 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1874 let (project, workspace) = init_test(cx).await;
1875
1876 create_file_wt(project.clone(), "/root.txt", cx).await;
1877 cx.read(|cx| {
1878 let workspace = workspace.read(cx);
1879 let active_entry = project.read(cx).active_entry();
1880
1881 //Make sure environment is as expected
1882 assert!(active_entry.is_none());
1883 assert!(workspace.worktrees(cx).next().is_some());
1884
1885 let res = default_working_directory(workspace, cx);
1886 assert_eq!(res, None);
1887 let res = first_project_directory(workspace, cx);
1888 assert_eq!(res, None);
1889 });
1890 }
1891
1892 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1893 #[gpui::test]
1894 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1895 let (project, workspace) = init_test(cx).await;
1896
1897 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1898 cx.update(|cx| {
1899 let workspace = workspace.read(cx);
1900 let active_entry = project.read(cx).active_entry();
1901
1902 assert!(active_entry.is_none());
1903 assert!(workspace.worktrees(cx).next().is_some());
1904
1905 let res = default_working_directory(workspace, cx);
1906 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1907 let res = first_project_directory(workspace, cx);
1908 assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
1909 });
1910 }
1911
1912 // Active entry with a work tree, worktree is a file -> worktree_folder()
1913 #[gpui::test]
1914 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1915 let (project, workspace) = init_test(cx).await;
1916
1917 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1918 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1919 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1920
1921 cx.update(|cx| {
1922 let workspace = workspace.read(cx);
1923 let active_entry = project.read(cx).active_entry();
1924
1925 assert!(active_entry.is_some());
1926
1927 let res = default_working_directory(workspace, cx);
1928 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1929 let res = first_project_directory(workspace, cx);
1930 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1931 });
1932 }
1933
1934 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1935 #[gpui::test]
1936 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1937 let (project, workspace) = init_test(cx).await;
1938
1939 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1940 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1941 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1942
1943 cx.update(|cx| {
1944 let workspace = workspace.read(cx);
1945 let active_entry = project.read(cx).active_entry();
1946
1947 assert!(active_entry.is_some());
1948
1949 let res = default_working_directory(workspace, cx);
1950 assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
1951 let res = first_project_directory(workspace, cx);
1952 assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
1953 });
1954 }
1955
1956 /// Creates a worktree with 1 file: /root.txt
1957 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
1958 let params = cx.update(AppState::test);
1959 cx.update(|cx| {
1960 terminal::init(cx);
1961 theme::init(theme::LoadThemes::JustBase, cx);
1962 Project::init_settings(cx);
1963 language::init(cx);
1964 });
1965
1966 let project = Project::test(params.fs.clone(), [], cx).await;
1967 let workspace = cx
1968 .add_window(|window, cx| Workspace::test_new(project.clone(), window, cx))
1969 .root(cx)
1970 .unwrap();
1971
1972 (project, workspace)
1973 }
1974
1975 /// Creates a worktree with 1 folder: /root{suffix}/
1976 async fn create_folder_wt(
1977 project: Entity<Project>,
1978 path: impl AsRef<Path>,
1979 cx: &mut TestAppContext,
1980 ) -> (Entity<Worktree>, Entry) {
1981 create_wt(project, true, path, cx).await
1982 }
1983
1984 /// Creates a worktree with 1 file: /root{suffix}.txt
1985 async fn create_file_wt(
1986 project: Entity<Project>,
1987 path: impl AsRef<Path>,
1988 cx: &mut TestAppContext,
1989 ) -> (Entity<Worktree>, Entry) {
1990 create_wt(project, false, path, cx).await
1991 }
1992
1993 async fn create_wt(
1994 project: Entity<Project>,
1995 is_dir: bool,
1996 path: impl AsRef<Path>,
1997 cx: &mut TestAppContext,
1998 ) -> (Entity<Worktree>, Entry) {
1999 let (wt, _) = project
2000 .update(cx, |project, cx| {
2001 project.find_or_create_worktree(path, true, cx)
2002 })
2003 .await
2004 .unwrap();
2005
2006 let entry = cx
2007 .update(|cx| {
2008 wt.update(cx, |wt, cx| {
2009 wt.create_entry(Path::new(""), is_dir, None, cx)
2010 })
2011 })
2012 .await
2013 .unwrap()
2014 .to_included()
2015 .unwrap();
2016
2017 (wt, entry)
2018 }
2019
2020 pub fn insert_active_entry_for(
2021 wt: Entity<Worktree>,
2022 entry: Entry,
2023 project: Entity<Project>,
2024 cx: &mut TestAppContext,
2025 ) {
2026 cx.update(|cx| {
2027 let p = ProjectPath {
2028 worktree_id: wt.read(cx).id(),
2029 path: entry.path,
2030 };
2031 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2032 });
2033 }
2034
2035 #[test]
2036 fn escapes_only_special_characters() {
2037 assert_eq!(regex_to_literal(r"test(\w)"), r"test\(\\w\)".to_string());
2038 }
2039
2040 #[test]
2041 fn empty_string_stays_empty() {
2042 assert_eq!(regex_to_literal(""), "".to_string());
2043 }
2044}