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