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