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