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