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