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