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::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
10use gpui::{
11 Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
12 Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point,
13 Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred,
14 div,
15};
16use menu;
17use persistence::TERMINAL_DB;
18use project::{Project, search::SearchQuery};
19use schemars::JsonSchema;
20use serde::Deserialize;
21use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
22use task::TaskId;
23use terminal::{
24 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
25 ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
26 TaskStatus, Terminal, TerminalBounds, ToggleViMode,
27 alacritty_terminal::{
28 index::Point as AlacPoint,
29 term::{TermMode, point_to_viewport, search::RegexSearch},
30 },
31 terminal_settings::{CursorShape, TerminalSettings},
32};
33use terminal_element::TerminalElement;
34use terminal_panel::TerminalPanel;
35use terminal_path_like_target::{hover_path_like_target, open_path_like_target};
36use terminal_scrollbar::TerminalScrollHandle;
37use terminal_slash_command::TerminalSlashCommand;
38use ui::{
39 ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar,
40 prelude::*,
41 scrollbars::{self, GlobalSetting, ScrollbarVisibility},
42};
43use util::ResultExt;
44use workspace::{
45 CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId,
46 delete_unloaded_items,
47 item::{
48 BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
49 },
50 register_serializable_item,
51 searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
52};
53use zed_actions::assistant::InlineAssist;
54
55use std::{
56 cmp,
57 ops::{Range, RangeInclusive},
58 path::{Path, PathBuf},
59 rc::Rc,
60 sync::Arc,
61 time::Duration,
62};
63
64struct ImeState {
65 marked_text: String,
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
92/// Renames the terminal tab.
93#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
94#[action(namespace = terminal)]
95pub struct RenameTerminal;
96
97pub fn init(cx: &mut App) {
98 assistant_slash_command::init(cx);
99 terminal_panel::init(cx);
100
101 register_serializable_item::<TerminalView>(cx);
102
103 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
104 workspace.register_action(TerminalView::deploy);
105 })
106 .detach();
107 SlashCommandRegistry::global(cx).register_command(TerminalSlashCommand, true);
108}
109
110pub struct BlockProperties {
111 pub height: u8,
112 pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
113}
114
115pub struct BlockContext<'a, 'b> {
116 pub window: &'a mut Window,
117 pub context: &'b mut App,
118 pub dimensions: TerminalBounds,
119}
120
121///A terminal view, maintains the PTY's file handles and communicates with the terminal
122pub struct TerminalView {
123 terminal: Entity<Terminal>,
124 workspace: WeakEntity<Workspace>,
125 project: WeakEntity<Project>,
126 focus_handle: FocusHandle,
127 //Currently using iTerm bell, show bell emoji in tab until input is received
128 has_bell: bool,
129 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
130 cursor_shape: CursorShape,
131 blink_manager: Entity<BlinkManager>,
132 mode: TerminalMode,
133 blinking_terminal_enabled: bool,
134 needs_serialize: bool,
135 custom_title: Option<String>,
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 scroll_handle: TerminalScrollHandle,
143 ime_state: Option<ImeState>,
144 self_handle: WeakEntity<Self>,
145 rename_editor: Option<Entity<Editor>>,
146 rename_editor_subscription: Option<Subscription>,
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 action: &NewCenterTerminal,
207 window: &mut Window,
208 cx: &mut Context<Workspace>,
209 ) {
210 let local = action.local;
211 let working_directory = default_working_directory(workspace, cx);
212 TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| {
213 if local {
214 project.create_local_terminal(cx)
215 } else {
216 project.create_terminal_shell(working_directory, cx)
217 }
218 })
219 .detach_and_log_err(cx);
220 }
221
222 pub fn new(
223 terminal: Entity<Terminal>,
224 workspace: WeakEntity<Workspace>,
225 workspace_id: Option<WorkspaceId>,
226 project: WeakEntity<Project>,
227 window: &mut Window,
228 cx: &mut Context<Self>,
229 ) -> Self {
230 let workspace_handle = workspace.clone();
231 let terminal_subscriptions =
232 subscribe_for_terminal_events(&terminal, workspace, window, cx);
233
234 let focus_handle = cx.focus_handle();
235 let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| {
236 terminal_view.focus_in(window, cx);
237 });
238 let focus_out = cx.on_focus_out(
239 &focus_handle,
240 window,
241 |terminal_view, _event, window, cx| {
242 terminal_view.focus_out(window, cx);
243 },
244 );
245 let cursor_shape = TerminalSettings::get_global(cx).cursor_shape;
246
247 let scroll_handle = TerminalScrollHandle::new(terminal.read(cx));
248
249 let blink_manager = cx.new(|cx| {
250 BlinkManager::new(
251 CURSOR_BLINK_INTERVAL,
252 |cx| {
253 !matches!(
254 TerminalSettings::get_global(cx).blinking,
255 TerminalBlink::Off
256 )
257 },
258 cx,
259 )
260 });
261
262 let subscriptions = vec![
263 focus_in,
264 focus_out,
265 cx.observe(&blink_manager, |_, _, cx| cx.notify()),
266 cx.observe_global::<SettingsStore>(Self::settings_changed),
267 ];
268
269 Self {
270 terminal,
271 workspace: workspace_handle,
272 project,
273 has_bell: false,
274 focus_handle,
275 context_menu: None,
276 cursor_shape,
277 blink_manager,
278 blinking_terminal_enabled: false,
279 hover: None,
280 hover_tooltip_update: Task::ready(()),
281 mode: TerminalMode::Standalone,
282 workspace_id,
283 show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
284 block_below_cursor: None,
285 scroll_top: Pixels::ZERO,
286 scroll_handle,
287 needs_serialize: false,
288 custom_title: None,
289 ime_state: None,
290 self_handle: cx.entity().downgrade(),
291 rename_editor: None,
292 rename_editor_subscription: None,
293 _subscriptions: subscriptions,
294 _terminal_subscriptions: terminal_subscriptions,
295 }
296 }
297
298 /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
299 pub fn set_embedded_mode(
300 &mut self,
301 max_lines_when_unfocused: Option<usize>,
302 cx: &mut Context<Self>,
303 ) {
304 self.mode = TerminalMode::Embedded {
305 max_lines_when_unfocused,
306 };
307 cx.notify();
308 }
309
310 const MAX_EMBEDDED_LINES: usize = 1_000;
311
312 /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines
313 ///
314 /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES`
315 pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode {
316 match &self.mode {
317 TerminalMode::Standalone => ContentMode::Scrollable,
318 TerminalMode::Embedded {
319 max_lines_when_unfocused,
320 } => {
321 let total_lines = self.terminal.read(cx).total_lines();
322
323 if total_lines > Self::MAX_EMBEDDED_LINES {
324 ContentMode::Scrollable
325 } else {
326 let mut displayed_lines = total_lines;
327
328 if !self.focus_handle.is_focused(window)
329 && let Some(max_lines) = max_lines_when_unfocused
330 {
331 displayed_lines = displayed_lines.min(*max_lines)
332 }
333
334 ContentMode::Inline {
335 displayed_lines,
336 total_lines,
337 }
338 }
339 }
340 }
341 }
342
343 /// Sets the marked (pre-edit) text from the IME.
344 pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
345 if text.is_empty() {
346 return self.clear_marked_text(cx);
347 }
348 self.ime_state = Some(ImeState { marked_text: text });
349 cx.notify();
350 }
351
352 /// Gets the current marked range (UTF-16).
353 pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
354 self.ime_state
355 .as_ref()
356 .map(|state| 0..state.marked_text.encode_utf16().count())
357 }
358
359 /// Clears the marked (pre-edit) text state.
360 pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
361 if self.ime_state.is_some() {
362 self.ime_state = None;
363 cx.notify();
364 }
365 }
366
367 /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
368 pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
369 if !text.is_empty() {
370 self.terminal.update(cx, |term, _| {
371 term.input(text.to_string().into_bytes());
372 });
373 }
374 }
375
376 pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
377 self.terminal.read(cx).last_content().terminal_bounds
378 }
379
380 pub fn entity(&self) -> &Entity<Terminal> {
381 &self.terminal
382 }
383
384 pub fn has_bell(&self) -> bool {
385 self.has_bell
386 }
387
388 pub fn custom_title(&self) -> Option<&str> {
389 self.custom_title.as_deref()
390 }
391
392 pub fn set_custom_title(&mut self, label: Option<String>, cx: &mut Context<Self>) {
393 let label = label.filter(|l| !l.trim().is_empty());
394 if self.custom_title != label {
395 self.custom_title = label;
396 self.needs_serialize = true;
397 cx.emit(ItemEvent::UpdateTab);
398 cx.notify();
399 }
400 }
401
402 pub fn is_renaming(&self) -> bool {
403 self.rename_editor.is_some()
404 }
405
406 pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool {
407 self.rename_editor
408 .as_ref()
409 .is_some_and(|editor| editor.focus_handle(cx).is_focused(window))
410 }
411
412 fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context<Self>) {
413 let Some(editor) = self.rename_editor.take() else {
414 return;
415 };
416 self.rename_editor_subscription = None;
417 if save {
418 let new_label = editor.read(cx).text(cx).trim().to_string();
419 let label = if new_label.is_empty() {
420 None
421 } else {
422 // Only set custom_title if the text differs from the terminal's dynamic title.
423 // This prevents subtle layout changes when clicking away without making changes.
424 let terminal_title = self.terminal.read(cx).title(true);
425 if new_label == terminal_title {
426 None
427 } else {
428 Some(new_label)
429 }
430 };
431 self.set_custom_title(label, cx);
432 }
433 cx.notify();
434 self.focus_handle.focus(window, cx);
435 }
436
437 pub fn rename_terminal(
438 &mut self,
439 _: &RenameTerminal,
440 window: &mut Window,
441 cx: &mut Context<Self>,
442 ) {
443 if self.terminal.read(cx).task().is_some() {
444 return;
445 }
446
447 let current_label = self
448 .custom_title
449 .clone()
450 .unwrap_or_else(|| self.terminal.read(cx).title(true));
451
452 let rename_editor = cx.new(|cx| Editor::single_line(window, cx));
453 let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, {
454 let rename_editor = rename_editor.clone();
455 move |_this, _, event, window, cx| {
456 if let editor::EditorEvent::Blurred = event {
457 // Defer to let focus settle (avoids canceling during double-click).
458 let rename_editor = rename_editor.clone();
459 cx.defer_in(window, move |this, window, cx| {
460 let still_current = this
461 .rename_editor
462 .as_ref()
463 .is_some_and(|current| current == &rename_editor);
464 if still_current && !rename_editor.focus_handle(cx).is_focused(window) {
465 this.finish_renaming(false, window, cx);
466 }
467 });
468 }
469 }
470 });
471
472 self.rename_editor = Some(rename_editor.clone());
473 self.rename_editor_subscription = Some(rename_editor_subscription);
474
475 rename_editor.update(cx, |editor, cx| {
476 editor.set_text(current_label, window, cx);
477 editor.select_all(&SelectAll, window, cx);
478 editor.focus_handle(cx).focus(window, cx);
479 });
480 cx.notify();
481 }
482
483 pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
484 self.has_bell = false;
485 cx.emit(Event::Wakeup);
486 }
487
488 pub fn deploy_context_menu(
489 &mut self,
490 position: Point<Pixels>,
491 window: &mut Window,
492 cx: &mut Context<Self>,
493 ) {
494 let assistant_enabled = self
495 .workspace
496 .upgrade()
497 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
498 .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());
499 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
500 menu.context(self.focus_handle.clone())
501 .action("New Terminal", Box::new(NewTerminal::default()))
502 .separator()
503 .action("Copy", Box::new(Copy))
504 .action("Paste", Box::new(Paste))
505 .action("Select All", Box::new(SelectAll))
506 .action("Clear", Box::new(Clear))
507 .when(assistant_enabled, |menu| {
508 menu.separator()
509 .action("Inline Assist", Box::new(InlineAssist::default()))
510 })
511 .separator()
512 .action(
513 "Close Terminal Tab",
514 Box::new(CloseActiveItem {
515 save_intent: None,
516 close_pinned: true,
517 }),
518 )
519 });
520
521 window.focus(&context_menu.focus_handle(cx), cx);
522 let subscription = cx.subscribe_in(
523 &context_menu,
524 window,
525 |this, _, _: &DismissEvent, window, cx| {
526 if this.context_menu.as_ref().is_some_and(|context_menu| {
527 context_menu.0.focus_handle(cx).contains_focused(window, cx)
528 }) {
529 cx.focus_self(window);
530 }
531 this.context_menu.take();
532 cx.notify();
533 },
534 );
535
536 self.context_menu = Some((context_menu, position, subscription));
537 }
538
539 fn settings_changed(&mut self, cx: &mut Context<Self>) {
540 let settings = TerminalSettings::get_global(cx);
541 let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
542 self.show_breadcrumbs = settings.toolbar.breadcrumbs;
543
544 let should_blink = match settings.blinking {
545 TerminalBlink::Off => false,
546 TerminalBlink::On => true,
547 TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
548 };
549 let new_cursor_shape = settings.cursor_shape;
550 let old_cursor_shape = self.cursor_shape;
551 if old_cursor_shape != new_cursor_shape {
552 self.cursor_shape = new_cursor_shape;
553 self.terminal.update(cx, |term, _| {
554 term.set_cursor_shape(self.cursor_shape);
555 });
556 }
557
558 self.blink_manager.update(
559 cx,
560 if should_blink {
561 BlinkManager::enable
562 } else {
563 BlinkManager::disable
564 },
565 );
566
567 if breadcrumb_visibility_changed {
568 cx.emit(ItemEvent::UpdateBreadcrumbs);
569 }
570 cx.notify();
571 }
572
573 fn show_character_palette(
574 &mut self,
575 _: &ShowCharacterPalette,
576 window: &mut Window,
577 cx: &mut Context<Self>,
578 ) {
579 if self
580 .terminal
581 .read(cx)
582 .last_content
583 .mode
584 .contains(TermMode::ALT_SCREEN)
585 {
586 self.terminal.update(cx, |term, cx| {
587 term.try_keystroke(
588 &Keystroke::parse("ctrl-cmd-space").unwrap(),
589 TerminalSettings::get_global(cx).option_as_meta,
590 )
591 });
592 } else {
593 window.show_character_palette();
594 }
595 }
596
597 fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
598 self.terminal.update(cx, |term, _| term.select_all());
599 cx.notify();
600 }
601
602 fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
603 let task = self
604 .terminal
605 .read(cx)
606 .task()
607 .map(|task| terminal_rerun_override(&task.spawned_task.id))
608 .unwrap_or_default();
609 window.dispatch_action(Box::new(task), cx);
610 }
611
612 fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
613 self.scroll_top = px(0.);
614 self.terminal.update(cx, |term, _| term.clear());
615 cx.notify();
616 }
617
618 fn max_scroll_top(&self, cx: &App) -> Pixels {
619 let terminal = self.terminal.read(cx);
620
621 let Some(block) = self.block_below_cursor.as_ref() else {
622 return Pixels::ZERO;
623 };
624
625 let line_height = terminal.last_content().terminal_bounds.line_height;
626 let viewport_lines = terminal.viewport_lines();
627 let cursor = point_to_viewport(
628 terminal.last_content.display_offset,
629 terminal.last_content.cursor.point,
630 )
631 .unwrap_or_default();
632 let max_scroll_top_in_lines =
633 (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
634
635 max_scroll_top_in_lines as f32 * line_height
636 }
637
638 fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
639 let terminal_content = self.terminal.read(cx).last_content();
640
641 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
642 let line_height = terminal_content.terminal_bounds.line_height;
643 let y_delta = event.delta.pixel_delta(line_height).y;
644 if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
645 self.scroll_top = cmp::max(
646 Pixels::ZERO,
647 cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
648 );
649 cx.notify();
650 return;
651 }
652 }
653 self.terminal.update(cx, |term, cx| {
654 term.scroll_wheel(
655 event,
656 TerminalSettings::get_global(cx).scroll_multiplier.max(0.01),
657 )
658 });
659 }
660
661 fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
662 let terminal_content = self.terminal.read(cx).last_content();
663 if self.block_below_cursor.is_some()
664 && terminal_content.display_offset == 0
665 && self.scroll_top > Pixels::ZERO
666 {
667 let line_height = terminal_content.terminal_bounds.line_height;
668 self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
669 return;
670 }
671
672 self.terminal.update(cx, |term, _| term.scroll_line_up());
673 cx.notify();
674 }
675
676 fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
677 let terminal_content = self.terminal.read(cx).last_content();
678 if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
679 let max_scroll_top = self.max_scroll_top(cx);
680 if self.scroll_top < max_scroll_top {
681 let line_height = terminal_content.terminal_bounds.line_height;
682 self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
683 }
684 return;
685 }
686
687 self.terminal.update(cx, |term, _| term.scroll_line_down());
688 cx.notify();
689 }
690
691 fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
692 if self.scroll_top == Pixels::ZERO {
693 self.terminal.update(cx, |term, _| term.scroll_page_up());
694 } else {
695 let line_height = self
696 .terminal
697 .read(cx)
698 .last_content
699 .terminal_bounds
700 .line_height();
701 let visible_block_lines = (self.scroll_top / line_height) as usize;
702 let viewport_lines = self.terminal.read(cx).viewport_lines();
703 let visible_content_lines = viewport_lines - visible_block_lines;
704
705 if visible_block_lines >= viewport_lines {
706 self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
707 } else {
708 self.scroll_top = px(0.);
709 self.terminal
710 .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
711 }
712 }
713 cx.notify();
714 }
715
716 fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
717 self.terminal.update(cx, |term, _| term.scroll_page_down());
718 let terminal = self.terminal.read(cx);
719 if terminal.last_content().display_offset < terminal.viewport_lines() {
720 self.scroll_top = self.max_scroll_top(cx);
721 }
722 cx.notify();
723 }
724
725 fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
726 self.terminal.update(cx, |term, _| term.scroll_to_top());
727 cx.notify();
728 }
729
730 fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
731 self.terminal.update(cx, |term, _| term.scroll_to_bottom());
732 if self.block_below_cursor.is_some() {
733 self.scroll_top = self.max_scroll_top(cx);
734 }
735 cx.notify();
736 }
737
738 fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context<Self>) {
739 self.terminal.update(cx, |term, _| term.toggle_vi_mode());
740 cx.notify();
741 }
742
743 pub fn should_show_cursor(&self, focused: bool, cx: &mut Context<Self>) -> bool {
744 // Always show cursor when not focused or in special modes
745 if !focused
746 || self
747 .terminal
748 .read(cx)
749 .last_content
750 .mode
751 .contains(TermMode::ALT_SCREEN)
752 {
753 return true;
754 }
755
756 // When focused, check blinking settings and blink manager state
757 match TerminalSettings::get_global(cx).blinking {
758 TerminalBlink::Off => true,
759 TerminalBlink::TerminalControlled => {
760 !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible()
761 }
762 TerminalBlink::On => self.blink_manager.read(cx).visible(),
763 }
764 }
765
766 pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
767 self.blink_manager.update(cx, BlinkManager::pause_blinking);
768 }
769
770 pub fn terminal(&self) -> &Entity<Terminal> {
771 &self.terminal
772 }
773
774 pub fn set_block_below_cursor(
775 &mut self,
776 block: BlockProperties,
777 window: &mut Window,
778 cx: &mut Context<Self>,
779 ) {
780 self.block_below_cursor = Some(Rc::new(block));
781 self.scroll_to_bottom(&ScrollToBottom, window, cx);
782 cx.notify();
783 }
784
785 pub fn clear_block_below_cursor(&mut self, cx: &mut Context<Self>) {
786 self.block_below_cursor = None;
787 self.scroll_top = Pixels::ZERO;
788 cx.notify();
789 }
790
791 ///Attempt to paste the clipboard into the terminal
792 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
793 self.terminal.update(cx, |term, _| term.copy(None));
794 cx.notify();
795 }
796
797 ///Attempt to paste the clipboard into the terminal
798 fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
799 let Some(clipboard) = cx.read_from_clipboard() else {
800 return;
801 };
802
803 if clipboard.entries().iter().any(|entry| match entry {
804 ClipboardEntry::Image(image) => !image.bytes.is_empty(),
805 _ => false,
806 }) {
807 self.forward_ctrl_v(cx);
808 return;
809 }
810
811 if let Some(text) = clipboard.text() {
812 self.terminal
813 .update(cx, |terminal, _cx| terminal.paste(&text));
814 }
815 }
816
817 /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
818 /// and attach images using their native workflows.
819 fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
820 self.terminal.update(cx, |term, _| {
821 term.input(vec![0x16]);
822 });
823 }
824
825 fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
826 self.clear_bell(cx);
827 self.terminal.update(cx, |term, _| {
828 term.input(text.0.to_string().into_bytes());
829 });
830 }
831
832 fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
833 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
834 self.clear_bell(cx);
835 self.process_keystroke(&keystroke, cx);
836 }
837 }
838
839 fn dispatch_context(&self, cx: &App) -> KeyContext {
840 let mut dispatch_context = KeyContext::new_with_defaults();
841 dispatch_context.add("Terminal");
842
843 if self.terminal.read(cx).vi_mode_enabled() {
844 dispatch_context.add("vi_mode");
845 }
846
847 let mode = self.terminal.read(cx).last_content.mode;
848 dispatch_context.set(
849 "screen",
850 if mode.contains(TermMode::ALT_SCREEN) {
851 "alt"
852 } else {
853 "normal"
854 },
855 );
856
857 if mode.contains(TermMode::APP_CURSOR) {
858 dispatch_context.add("DECCKM");
859 }
860 if mode.contains(TermMode::APP_KEYPAD) {
861 dispatch_context.add("DECPAM");
862 } else {
863 dispatch_context.add("DECPNM");
864 }
865 if mode.contains(TermMode::SHOW_CURSOR) {
866 dispatch_context.add("DECTCEM");
867 }
868 if mode.contains(TermMode::LINE_WRAP) {
869 dispatch_context.add("DECAWM");
870 }
871 if mode.contains(TermMode::ORIGIN) {
872 dispatch_context.add("DECOM");
873 }
874 if mode.contains(TermMode::INSERT) {
875 dispatch_context.add("IRM");
876 }
877 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
878 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
879 dispatch_context.add("LNM");
880 }
881 if mode.contains(TermMode::FOCUS_IN_OUT) {
882 dispatch_context.add("report_focus");
883 }
884 if mode.contains(TermMode::ALTERNATE_SCROLL) {
885 dispatch_context.add("alternate_scroll");
886 }
887 if mode.contains(TermMode::BRACKETED_PASTE) {
888 dispatch_context.add("bracketed_paste");
889 }
890 if mode.intersects(TermMode::MOUSE_MODE) {
891 dispatch_context.add("any_mouse_reporting");
892 }
893 {
894 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
895 "click"
896 } else if mode.contains(TermMode::MOUSE_DRAG) {
897 "drag"
898 } else if mode.contains(TermMode::MOUSE_MOTION) {
899 "motion"
900 } else {
901 "off"
902 };
903 dispatch_context.set("mouse_reporting", mouse_reporting);
904 }
905 {
906 let format = if mode.contains(TermMode::SGR_MOUSE) {
907 "sgr"
908 } else if mode.contains(TermMode::UTF8_MOUSE) {
909 "utf8"
910 } else {
911 "normal"
912 };
913 dispatch_context.set("mouse_format", format);
914 };
915
916 if self.terminal.read(cx).last_content.selection.is_some() {
917 dispatch_context.add("selection");
918 }
919
920 dispatch_context
921 }
922
923 fn set_terminal(
924 &mut self,
925 terminal: Entity<Terminal>,
926 window: &mut Window,
927 cx: &mut Context<TerminalView>,
928 ) {
929 self._terminal_subscriptions =
930 subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
931 self.terminal = terminal;
932 }
933
934 fn rerun_button(task: &TaskState) -> Option<IconButton> {
935 if !task.spawned_task.show_rerun {
936 return None;
937 }
938
939 let task_id = task.spawned_task.id.clone();
940 Some(
941 IconButton::new("rerun-icon", IconName::Rerun)
942 .icon_size(IconSize::Small)
943 .size(ButtonSize::Compact)
944 .icon_color(Color::Default)
945 .shape(ui::IconButtonShape::Square)
946 .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx))
947 .on_click(move |_, window, cx| {
948 window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
949 }),
950 )
951 }
952}
953
954fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
955 zed_actions::Rerun {
956 task_id: Some(task.0.clone()),
957 allow_concurrent_runs: Some(true),
958 use_new_terminal: Some(false),
959 reevaluate_context: false,
960 }
961}
962
963fn subscribe_for_terminal_events(
964 terminal: &Entity<Terminal>,
965 workspace: WeakEntity<Workspace>,
966 window: &mut Window,
967 cx: &mut Context<TerminalView>,
968) -> Vec<Subscription> {
969 let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
970 let mut previous_cwd = None;
971 let terminal_events_subscription = cx.subscribe_in(
972 terminal,
973 window,
974 move |terminal_view, terminal, event, window, cx| {
975 let current_cwd = terminal.read(cx).working_directory();
976 if current_cwd != previous_cwd {
977 previous_cwd = current_cwd;
978 terminal_view.needs_serialize = true;
979 }
980
981 match event {
982 Event::Wakeup => {
983 cx.notify();
984 cx.emit(Event::Wakeup);
985 cx.emit(ItemEvent::UpdateTab);
986 cx.emit(SearchEvent::MatchesInvalidated);
987 }
988
989 Event::Bell => {
990 terminal_view.has_bell = true;
991 cx.emit(Event::Wakeup);
992 }
993
994 Event::BlinkChanged(blinking) => {
995 terminal_view.blinking_terminal_enabled = *blinking;
996
997 // If in terminal-controlled mode and focused, update blink manager
998 if matches!(
999 TerminalSettings::get_global(cx).blinking,
1000 TerminalBlink::TerminalControlled
1001 ) && terminal_view.focus_handle.is_focused(window)
1002 {
1003 terminal_view.blink_manager.update(cx, |manager, cx| {
1004 if *blinking {
1005 manager.enable(cx);
1006 } else {
1007 manager.disable(cx);
1008 }
1009 });
1010 }
1011 }
1012
1013 Event::TitleChanged => {
1014 cx.emit(ItemEvent::UpdateTab);
1015 }
1016
1017 Event::NewNavigationTarget(maybe_navigation_target) => {
1018 match maybe_navigation_target
1019 .as_ref()
1020 .zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
1021 {
1022 Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
1023 if Some(hovered_word)
1024 != terminal_view
1025 .hover
1026 .as_ref()
1027 .map(|hover| &hover.hovered_word)
1028 {
1029 terminal_view.hover = Some(HoverTarget {
1030 tooltip: url.clone(),
1031 hovered_word: hovered_word.clone(),
1032 });
1033 terminal_view.hover_tooltip_update = Task::ready(());
1034 cx.notify();
1035 }
1036 }
1037 Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
1038 if Some(hovered_word)
1039 != terminal_view
1040 .hover
1041 .as_ref()
1042 .map(|hover| &hover.hovered_word)
1043 {
1044 terminal_view.hover = None;
1045 terminal_view.hover_tooltip_update = hover_path_like_target(
1046 &workspace,
1047 hovered_word.clone(),
1048 path_like_target,
1049 cx,
1050 );
1051 cx.notify();
1052 }
1053 }
1054 None => {
1055 terminal_view.hover = None;
1056 terminal_view.hover_tooltip_update = Task::ready(());
1057 cx.notify();
1058 }
1059 }
1060 }
1061
1062 Event::Open(maybe_navigation_target) => match maybe_navigation_target {
1063 MaybeNavigationTarget::Url(url) => cx.open_url(url),
1064 MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target(
1065 &workspace,
1066 terminal_view,
1067 path_like_target,
1068 window,
1069 cx,
1070 ),
1071 },
1072 Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
1073 Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
1074 Event::SelectionsChanged => {
1075 window.invalidate_character_coordinates();
1076 cx.emit(SearchEvent::ActiveMatchChanged)
1077 }
1078 }
1079 },
1080 );
1081 vec![terminal_subscription, terminal_events_subscription]
1082}
1083
1084fn regex_search_for_query(query: &SearchQuery) -> Option<RegexSearch> {
1085 let str = query.as_str();
1086 if query.is_regex() {
1087 if str == "." {
1088 return None;
1089 }
1090 RegexSearch::new(str).ok()
1091 } else {
1092 RegexSearch::new(®ex::escape(str)).ok()
1093 }
1094}
1095
1096struct TerminalScrollbarSettingsWrapper;
1097
1098impl GlobalSetting for TerminalScrollbarSettingsWrapper {
1099 fn get_value(_cx: &App) -> &Self {
1100 &Self
1101 }
1102}
1103
1104impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
1105 fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
1106 TerminalSettings::get_global(cx)
1107 .scrollbar
1108 .show
1109 .map(Into::into)
1110 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
1111 }
1112}
1113
1114impl TerminalView {
1115 /// Attempts to process a keystroke in the terminal. Returns true if handled.
1116 ///
1117 /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k)
1118 /// updates the cursor locally without sending data to the shell, so there's no
1119 /// shell output to automatically trigger a re-render.
1120 fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
1121 let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
1122 (
1123 term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
1124 term.vi_mode_enabled(),
1125 )
1126 });
1127
1128 if handled && vi_mode_enabled {
1129 cx.notify();
1130 }
1131
1132 handled
1133 }
1134
1135 fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1136 self.clear_bell(cx);
1137 self.pause_cursor_blinking(window, cx);
1138
1139 if self.process_keystroke(&event.keystroke, cx) {
1140 cx.stop_propagation();
1141 }
1142 }
1143
1144 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1145 self.terminal.update(cx, |terminal, _| {
1146 terminal.set_cursor_shape(self.cursor_shape);
1147 terminal.focus_in();
1148 });
1149
1150 let should_blink = match TerminalSettings::get_global(cx).blinking {
1151 TerminalBlink::Off => false,
1152 TerminalBlink::On => true,
1153 TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
1154 };
1155
1156 if should_blink {
1157 self.blink_manager.update(cx, BlinkManager::enable);
1158 }
1159
1160 window.invalidate_character_coordinates();
1161 cx.notify();
1162 }
1163
1164 fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1165 self.blink_manager.update(cx, BlinkManager::disable);
1166 self.terminal.update(cx, |terminal, _| {
1167 terminal.focus_out();
1168 terminal.set_cursor_shape(CursorShape::Hollow);
1169 });
1170 cx.notify();
1171 }
1172}
1173
1174impl Render for TerminalView {
1175 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1176 // TODO: this should be moved out of render
1177 self.scroll_handle.update(self.terminal.read(cx));
1178
1179 if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
1180 self.terminal.update(cx, |term, _| {
1181 let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
1182 match delta.cmp(&0) {
1183 cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
1184 cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
1185 cmp::Ordering::Equal => {}
1186 }
1187 });
1188 }
1189
1190 let terminal_handle = self.terminal.clone();
1191 let terminal_view_handle = cx.entity();
1192
1193 let focused = self.focus_handle.is_focused(window);
1194
1195 div()
1196 .id("terminal-view")
1197 .size_full()
1198 .relative()
1199 .track_focus(&self.focus_handle(cx))
1200 .key_context(self.dispatch_context(cx))
1201 .on_action(cx.listener(TerminalView::send_text))
1202 .on_action(cx.listener(TerminalView::send_keystroke))
1203 .on_action(cx.listener(TerminalView::copy))
1204 .on_action(cx.listener(TerminalView::paste))
1205 .on_action(cx.listener(TerminalView::clear))
1206 .on_action(cx.listener(TerminalView::scroll_line_up))
1207 .on_action(cx.listener(TerminalView::scroll_line_down))
1208 .on_action(cx.listener(TerminalView::scroll_page_up))
1209 .on_action(cx.listener(TerminalView::scroll_page_down))
1210 .on_action(cx.listener(TerminalView::scroll_to_top))
1211 .on_action(cx.listener(TerminalView::scroll_to_bottom))
1212 .on_action(cx.listener(TerminalView::toggle_vi_mode))
1213 .on_action(cx.listener(TerminalView::show_character_palette))
1214 .on_action(cx.listener(TerminalView::select_all))
1215 .on_action(cx.listener(TerminalView::rerun_task))
1216 .on_action(cx.listener(TerminalView::rename_terminal))
1217 .on_key_down(cx.listener(Self::key_down))
1218 .on_mouse_down(
1219 MouseButton::Right,
1220 cx.listener(|this, event: &MouseDownEvent, window, cx| {
1221 if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1222 if this.terminal.read(cx).last_content.selection.is_none() {
1223 this.terminal.update(cx, |terminal, _| {
1224 terminal.select_word_at_event_position(event);
1225 });
1226 };
1227 this.deploy_context_menu(event.position, window, cx);
1228 cx.notify();
1229 }
1230 }),
1231 )
1232 .child(
1233 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1234 div()
1235 .id("terminal-view-container")
1236 .size_full()
1237 .bg(cx.theme().colors().editor_background)
1238 .child(TerminalElement::new(
1239 terminal_handle,
1240 terminal_view_handle,
1241 self.workspace.clone(),
1242 self.focus_handle.clone(),
1243 focused,
1244 self.should_show_cursor(focused, cx),
1245 self.block_below_cursor.clone(),
1246 self.mode.clone(),
1247 ))
1248 .when(self.content_mode(window, cx).is_scrollable(), |div| {
1249 div.custom_scrollbars(
1250 Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
1251 .show_along(ScrollAxes::Vertical)
1252 .with_track_along(
1253 ScrollAxes::Vertical,
1254 cx.theme().colors().editor_background,
1255 )
1256 .tracked_scroll_handle(&self.scroll_handle),
1257 window,
1258 cx,
1259 )
1260 }),
1261 )
1262 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1263 deferred(
1264 anchored()
1265 .position(*position)
1266 .anchor(gpui::Corner::TopLeft)
1267 .child(menu.clone()),
1268 )
1269 .with_priority(1)
1270 }))
1271 }
1272}
1273
1274impl Item for TerminalView {
1275 type Event = ItemEvent;
1276
1277 fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1278 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
1279 let terminal = self.terminal().read(cx);
1280 let title = terminal.title(false);
1281 let pid = terminal.pid_getter()?.fallback_pid();
1282
1283 move |_, _| {
1284 v_flex()
1285 .gap_1()
1286 .child(Label::new(title.clone()))
1287 .child(h_flex().flex_grow().child(Divider::horizontal()))
1288 .child(
1289 Label::new(format!("Process ID (PID): {}", pid))
1290 .color(Color::Muted)
1291 .size(LabelSize::Small),
1292 )
1293 .into_any_element()
1294 }
1295 }))))
1296 }
1297
1298 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1299 let terminal = self.terminal().read(cx);
1300 let title = self
1301 .custom_title
1302 .as_ref()
1303 .filter(|title| !title.trim().is_empty())
1304 .cloned()
1305 .unwrap_or_else(|| terminal.title(true));
1306
1307 let (icon, icon_color, rerun_button) = match terminal.task() {
1308 Some(terminal_task) => match &terminal_task.status {
1309 TaskStatus::Running => (
1310 IconName::PlayFilled,
1311 Color::Disabled,
1312 TerminalView::rerun_button(terminal_task),
1313 ),
1314 TaskStatus::Unknown => (
1315 IconName::Warning,
1316 Color::Warning,
1317 TerminalView::rerun_button(terminal_task),
1318 ),
1319 TaskStatus::Completed { success } => {
1320 let rerun_button = TerminalView::rerun_button(terminal_task);
1321
1322 if *success {
1323 (IconName::Check, Color::Success, rerun_button)
1324 } else {
1325 (IconName::XCircle, Color::Error, rerun_button)
1326 }
1327 }
1328 },
1329 None => (IconName::Terminal, Color::Muted, None),
1330 };
1331
1332 h_flex()
1333 .gap_1()
1334 .group("term-tab-icon")
1335 .child(
1336 h_flex()
1337 .group("term-tab-icon")
1338 .child(
1339 div()
1340 .when(rerun_button.is_some(), |this| {
1341 this.hover(|style| style.invisible().w_0())
1342 })
1343 .child(Icon::new(icon).color(icon_color)),
1344 )
1345 .when_some(rerun_button, |this, rerun_button| {
1346 this.child(
1347 div()
1348 .absolute()
1349 .visible_on_hover("term-tab-icon")
1350 .child(rerun_button),
1351 )
1352 }),
1353 )
1354 .child(
1355 div()
1356 .relative()
1357 .child(
1358 Label::new(title)
1359 .color(params.text_color())
1360 .when(self.is_renaming(), |this| this.alpha(0.)),
1361 )
1362 .when_some(self.rename_editor.clone(), |this, editor| {
1363 let self_handle = self.self_handle.clone();
1364 let self_handle_cancel = self.self_handle.clone();
1365 this.child(
1366 div()
1367 .absolute()
1368 .top_0()
1369 .left_0()
1370 .size_full()
1371 .child(editor)
1372 .on_action(move |_: &menu::Confirm, window, cx| {
1373 self_handle
1374 .update(cx, |this, cx| {
1375 this.finish_renaming(true, window, cx)
1376 })
1377 .ok();
1378 })
1379 .on_action(move |_: &menu::Cancel, window, cx| {
1380 self_handle_cancel
1381 .update(cx, |this, cx| {
1382 this.finish_renaming(false, window, cx)
1383 })
1384 .ok();
1385 }),
1386 )
1387 }),
1388 )
1389 .into_any()
1390 }
1391
1392 fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1393 if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
1394 return custom_title.clone().into();
1395 }
1396 let terminal = self.terminal().read(cx);
1397 terminal.title(detail == 0).into()
1398 }
1399
1400 fn telemetry_event_text(&self) -> Option<&'static str> {
1401 None
1402 }
1403
1404 fn tab_extra_context_menu_actions(
1405 &self,
1406 _window: &mut Window,
1407 cx: &mut Context<Self>,
1408 ) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
1409 let terminal = self.terminal.read(cx);
1410 if terminal.task().is_none() {
1411 vec![("Rename".into(), Box::new(RenameTerminal))]
1412 } else {
1413 Vec::new()
1414 }
1415 }
1416
1417 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1418 workspace::item::ItemBufferKind::Singleton
1419 }
1420
1421 fn can_split(&self) -> bool {
1422 true
1423 }
1424
1425 fn clone_on_split(
1426 &self,
1427 workspace_id: Option<WorkspaceId>,
1428 window: &mut Window,
1429 cx: &mut Context<Self>,
1430 ) -> Task<Option<Entity<Self>>> {
1431 let Ok(terminal) = self.project.update(cx, |project, cx| {
1432 let cwd = project
1433 .active_project_directory(cx)
1434 .map(|it| it.to_path_buf());
1435 project.clone_terminal(self.terminal(), cx, cwd)
1436 }) else {
1437 return Task::ready(None);
1438 };
1439 cx.spawn_in(window, async move |this, cx| {
1440 let terminal = terminal.await.log_err()?;
1441 this.update_in(cx, |this, window, cx| {
1442 cx.new(|cx| {
1443 TerminalView::new(
1444 terminal,
1445 this.workspace.clone(),
1446 workspace_id,
1447 this.project.clone(),
1448 window,
1449 cx,
1450 )
1451 })
1452 })
1453 .ok()
1454 })
1455 }
1456
1457 fn is_dirty(&self, cx: &App) -> bool {
1458 match self.terminal.read(cx).task() {
1459 Some(task) => task.status == TaskStatus::Running,
1460 None => self.has_bell(),
1461 }
1462 }
1463
1464 fn has_conflict(&self, _cx: &App) -> bool {
1465 false
1466 }
1467
1468 fn can_save_as(&self, _cx: &App) -> bool {
1469 false
1470 }
1471
1472 fn as_searchable(
1473 &self,
1474 handle: &Entity<Self>,
1475 _: &App,
1476 ) -> Option<Box<dyn SearchableItemHandle>> {
1477 Some(Box::new(handle.clone()))
1478 }
1479
1480 fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1481 if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1482 ToolbarItemLocation::PrimaryLeft
1483 } else {
1484 ToolbarItemLocation::Hidden
1485 }
1486 }
1487
1488 fn breadcrumbs(&self, _: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
1489 Some(vec![BreadcrumbText {
1490 text: self.terminal().read(cx).breadcrumb_text.clone(),
1491 highlights: None,
1492 font: None,
1493 }])
1494 }
1495
1496 fn added_to_workspace(
1497 &mut self,
1498 workspace: &mut Workspace,
1499 _: &mut Window,
1500 cx: &mut Context<Self>,
1501 ) {
1502 if self.terminal().read(cx).task().is_none() {
1503 if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1504 log::debug!(
1505 "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1506 );
1507 cx.background_spawn(TERMINAL_DB.update_workspace_id(
1508 new_id,
1509 old_id,
1510 cx.entity_id().as_u64(),
1511 ))
1512 .detach();
1513 }
1514 self.workspace_id = workspace.database_id();
1515 }
1516 }
1517
1518 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1519 f(*event)
1520 }
1521}
1522
1523impl SerializableItem for TerminalView {
1524 fn serialized_item_kind() -> &'static str {
1525 "Terminal"
1526 }
1527
1528 fn cleanup(
1529 workspace_id: WorkspaceId,
1530 alive_items: Vec<workspace::ItemId>,
1531 _window: &mut Window,
1532 cx: &mut App,
1533 ) -> Task<anyhow::Result<()>> {
1534 delete_unloaded_items(alive_items, workspace_id, "terminals", &TERMINAL_DB, cx)
1535 }
1536
1537 fn serialize(
1538 &mut self,
1539 _workspace: &mut Workspace,
1540 item_id: workspace::ItemId,
1541 _closing: bool,
1542 _: &mut Window,
1543 cx: &mut Context<Self>,
1544 ) -> Option<Task<anyhow::Result<()>>> {
1545 let terminal = self.terminal().read(cx);
1546 if terminal.task().is_some() {
1547 return None;
1548 }
1549
1550 if !self.needs_serialize {
1551 return None;
1552 }
1553
1554 let workspace_id = self.workspace_id?;
1555 let cwd = terminal.working_directory();
1556 let custom_title = self.custom_title.clone();
1557 self.needs_serialize = false;
1558
1559 Some(cx.background_spawn(async move {
1560 if let Some(cwd) = cwd {
1561 TERMINAL_DB
1562 .save_working_directory(item_id, workspace_id, cwd)
1563 .await?;
1564 }
1565 TERMINAL_DB
1566 .save_custom_title(item_id, workspace_id, custom_title)
1567 .await?;
1568 Ok(())
1569 }))
1570 }
1571
1572 fn should_serialize(&self, _: &Self::Event) -> bool {
1573 self.needs_serialize
1574 }
1575
1576 fn deserialize(
1577 project: Entity<Project>,
1578 workspace: WeakEntity<Workspace>,
1579 workspace_id: WorkspaceId,
1580 item_id: workspace::ItemId,
1581 window: &mut Window,
1582 cx: &mut App,
1583 ) -> Task<anyhow::Result<Entity<Self>>> {
1584 window.spawn(cx, async move |cx| {
1585 let (cwd, custom_title) = cx
1586 .update(|_window, cx| {
1587 let from_db = TERMINAL_DB
1588 .get_working_directory(item_id, workspace_id)
1589 .log_err()
1590 .flatten();
1591 let cwd = if from_db
1592 .as_ref()
1593 .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1594 {
1595 from_db
1596 } else {
1597 workspace
1598 .upgrade()
1599 .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1600 };
1601 let custom_title = TERMINAL_DB
1602 .get_custom_title(item_id, workspace_id)
1603 .log_err()
1604 .flatten()
1605 .filter(|title| !title.trim().is_empty());
1606 (cwd, custom_title)
1607 })
1608 .ok()
1609 .unwrap_or((None, None));
1610
1611 let terminal = project
1612 .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
1613 .await?;
1614 cx.update(|window, cx| {
1615 cx.new(|cx| {
1616 let mut view = TerminalView::new(
1617 terminal,
1618 workspace,
1619 Some(workspace_id),
1620 project.downgrade(),
1621 window,
1622 cx,
1623 );
1624 if custom_title.is_some() {
1625 view.custom_title = custom_title;
1626 }
1627 view
1628 })
1629 })
1630 })
1631 }
1632}
1633
1634impl SearchableItem for TerminalView {
1635 type Match = RangeInclusive<AlacPoint>;
1636
1637 fn supported_options(&self) -> SearchOptions {
1638 SearchOptions {
1639 case: false,
1640 word: false,
1641 regex: true,
1642 replacement: false,
1643 selection: false,
1644 find_in_results: false,
1645 }
1646 }
1647
1648 /// Clear stored matches
1649 fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1650 self.terminal().update(cx, |term, _| term.matches.clear())
1651 }
1652
1653 /// Store matches returned from find_matches somewhere for rendering
1654 fn update_matches(
1655 &mut self,
1656 matches: &[Self::Match],
1657 _active_match_index: Option<usize>,
1658 _window: &mut Window,
1659 cx: &mut Context<Self>,
1660 ) {
1661 self.terminal()
1662 .update(cx, |term, _| term.matches = matches.to_vec())
1663 }
1664
1665 /// Returns the selection content to pre-load into this search
1666 fn query_suggestion(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> String {
1667 self.terminal()
1668 .read(cx)
1669 .last_content
1670 .selection_text
1671 .clone()
1672 .unwrap_or_default()
1673 }
1674
1675 /// Focus match at given index into the Vec of matches
1676 fn activate_match(
1677 &mut self,
1678 index: usize,
1679 _: &[Self::Match],
1680 _window: &mut Window,
1681 cx: &mut Context<Self>,
1682 ) {
1683 self.terminal()
1684 .update(cx, |term, _| term.activate_match(index));
1685 cx.notify();
1686 }
1687
1688 /// Add selections for all matches given.
1689 fn select_matches(&mut self, matches: &[Self::Match], _: &mut Window, cx: &mut Context<Self>) {
1690 self.terminal()
1691 .update(cx, |term, _| term.select_matches(matches));
1692 cx.notify();
1693 }
1694
1695 /// Get all of the matches for this query, should be done on the background
1696 fn find_matches(
1697 &mut self,
1698 query: Arc<SearchQuery>,
1699 _: &mut Window,
1700 cx: &mut Context<Self>,
1701 ) -> Task<Vec<Self::Match>> {
1702 if let Some(s) = regex_search_for_query(&query) {
1703 self.terminal()
1704 .update(cx, |term, cx| term.find_matches(s, cx))
1705 } else {
1706 Task::ready(vec![])
1707 }
1708 }
1709
1710 /// Reports back to the search toolbar what the active match should be (the selection)
1711 fn active_match_index(
1712 &mut self,
1713 direction: Direction,
1714 matches: &[Self::Match],
1715 _: &mut Window,
1716 cx: &mut Context<Self>,
1717 ) -> Option<usize> {
1718 // Selection head might have a value if there's a selection that isn't
1719 // associated with a match. Therefore, if there are no matches, we should
1720 // report None, no matter the state of the terminal
1721
1722 if !matches.is_empty() {
1723 if let Some(selection_head) = self.terminal().read(cx).selection_head {
1724 // If selection head is contained in a match. Return that match
1725 match direction {
1726 Direction::Prev => {
1727 // If no selection before selection head, return the first match
1728 Some(
1729 matches
1730 .iter()
1731 .enumerate()
1732 .rev()
1733 .find(|(_, search_match)| {
1734 search_match.contains(&selection_head)
1735 || search_match.start() < &selection_head
1736 })
1737 .map(|(ix, _)| ix)
1738 .unwrap_or(0),
1739 )
1740 }
1741 Direction::Next => {
1742 // If no selection after selection head, return the last match
1743 Some(
1744 matches
1745 .iter()
1746 .enumerate()
1747 .find(|(_, search_match)| {
1748 search_match.contains(&selection_head)
1749 || search_match.start() > &selection_head
1750 })
1751 .map(|(ix, _)| ix)
1752 .unwrap_or(matches.len().saturating_sub(1)),
1753 )
1754 }
1755 }
1756 } else {
1757 // Matches found but no active selection, return the first last one (closest to cursor)
1758 Some(matches.len().saturating_sub(1))
1759 }
1760 } else {
1761 None
1762 }
1763 }
1764 fn replace(
1765 &mut self,
1766 _: &Self::Match,
1767 _: &SearchQuery,
1768 _window: &mut Window,
1769 _: &mut Context<Self>,
1770 ) {
1771 // Replacement is not supported in terminal view, so this is a no-op.
1772 }
1773}
1774
1775/// Gets the working directory for the given workspace, respecting the user's settings.
1776/// Falls back to home directory when no project directory is available.
1777pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1778 let directory = match &TerminalSettings::get_global(cx).working_directory {
1779 WorkingDirectory::CurrentProjectDirectory => workspace
1780 .project()
1781 .read(cx)
1782 .active_project_directory(cx)
1783 .as_deref()
1784 .map(Path::to_path_buf)
1785 .or_else(|| first_project_directory(workspace, cx)),
1786 WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
1787 WorkingDirectory::AlwaysHome => None,
1788 WorkingDirectory::Always { directory } => shellexpand::full(directory)
1789 .ok()
1790 .map(|dir| Path::new(&dir.to_string()).to_path_buf())
1791 .filter(|dir| dir.is_dir()),
1792 };
1793 directory.or_else(dirs::home_dir)
1794}
1795///Gets the first project's home directory, or the home directory
1796fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1797 let worktree = workspace.worktrees(cx).next()?.read(cx);
1798 let worktree_path = worktree.abs_path();
1799 if worktree.root_entry()?.is_dir() {
1800 Some(worktree_path.to_path_buf())
1801 } else {
1802 // If worktree is a file, return its parent directory
1803 worktree_path.parent().map(|p| p.to_path_buf())
1804 }
1805}
1806
1807#[cfg(test)]
1808mod tests {
1809 use super::*;
1810 use gpui::TestAppContext;
1811 use project::{Entry, Project, ProjectPath, Worktree};
1812 use std::path::Path;
1813 use util::rel_path::RelPath;
1814 use workspace::AppState;
1815
1816 // Working directory calculation tests
1817
1818 // No Worktrees in project -> home_dir()
1819 #[gpui::test]
1820 async fn no_worktree(cx: &mut TestAppContext) {
1821 let (project, workspace) = init_test(cx).await;
1822 cx.read(|cx| {
1823 let workspace = workspace.read(cx);
1824 let active_entry = project.read(cx).active_entry();
1825
1826 //Make sure environment is as expected
1827 assert!(active_entry.is_none());
1828 assert!(workspace.worktrees(cx).next().is_none());
1829
1830 let res = default_working_directory(workspace, cx);
1831 assert_eq!(res, dirs::home_dir());
1832 let res = first_project_directory(workspace, cx);
1833 assert_eq!(res, None);
1834 });
1835 }
1836
1837 // No active entry, but a worktree, worktree is a file -> parent directory
1838 #[gpui::test]
1839 async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
1840 let (project, workspace) = init_test(cx).await;
1841
1842 create_file_wt(project.clone(), "/root.txt", cx).await;
1843 cx.read(|cx| {
1844 let workspace = workspace.read(cx);
1845 let active_entry = project.read(cx).active_entry();
1846
1847 //Make sure environment is as expected
1848 assert!(active_entry.is_none());
1849 assert!(workspace.worktrees(cx).next().is_some());
1850
1851 let res = default_working_directory(workspace, cx);
1852 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1853 let res = first_project_directory(workspace, cx);
1854 assert_eq!(res, Some(Path::new("/").to_path_buf()));
1855 });
1856 }
1857
1858 // No active entry, but a worktree, worktree is a folder -> worktree_folder
1859 #[gpui::test]
1860 async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1861 let (project, workspace) = init_test(cx).await;
1862
1863 let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
1864 cx.update(|cx| {
1865 let workspace = workspace.read(cx);
1866 let active_entry = project.read(cx).active_entry();
1867
1868 assert!(active_entry.is_none());
1869 assert!(workspace.worktrees(cx).next().is_some());
1870
1871 let res = default_working_directory(workspace, cx);
1872 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1873 let res = first_project_directory(workspace, cx);
1874 assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
1875 });
1876 }
1877
1878 // Active entry with a work tree, worktree is a file -> worktree_folder()
1879 #[gpui::test]
1880 async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
1881 let (project, workspace) = init_test(cx).await;
1882
1883 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1884 let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
1885 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1886
1887 cx.update(|cx| {
1888 let workspace = workspace.read(cx);
1889 let active_entry = project.read(cx).active_entry();
1890
1891 assert!(active_entry.is_some());
1892
1893 let res = default_working_directory(workspace, cx);
1894 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1895 let res = first_project_directory(workspace, cx);
1896 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1897 });
1898 }
1899
1900 // Active entry, with a worktree, worktree is a folder -> worktree_folder
1901 #[gpui::test]
1902 async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
1903 let (project, workspace) = init_test(cx).await;
1904
1905 let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
1906 let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
1907 insert_active_entry_for(wt2, entry2, project.clone(), cx);
1908
1909 cx.update(|cx| {
1910 let workspace = workspace.read(cx);
1911 let active_entry = project.read(cx).active_entry();
1912
1913 assert!(active_entry.is_some());
1914
1915 let res = default_working_directory(workspace, cx);
1916 assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
1917 let res = first_project_directory(workspace, cx);
1918 assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
1919 });
1920 }
1921
1922 /// Creates a worktree with 1 file: /root.txt
1923 pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
1924 let params = cx.update(AppState::test);
1925 cx.update(|cx| {
1926 theme::init(theme::LoadThemes::JustBase, cx);
1927 });
1928
1929 let project = Project::test(params.fs.clone(), [], cx).await;
1930 let workspace = cx
1931 .add_window(|window, cx| Workspace::test_new(project.clone(), window, cx))
1932 .root(cx)
1933 .unwrap();
1934
1935 (project, workspace)
1936 }
1937
1938 /// Creates a worktree with 1 folder: /root{suffix}/
1939 async fn create_folder_wt(
1940 project: Entity<Project>,
1941 path: impl AsRef<Path>,
1942 cx: &mut TestAppContext,
1943 ) -> (Entity<Worktree>, Entry) {
1944 create_wt(project, true, path, cx).await
1945 }
1946
1947 /// Creates a worktree with 1 file: /root{suffix}.txt
1948 async fn create_file_wt(
1949 project: Entity<Project>,
1950 path: impl AsRef<Path>,
1951 cx: &mut TestAppContext,
1952 ) -> (Entity<Worktree>, Entry) {
1953 create_wt(project, false, path, cx).await
1954 }
1955
1956 async fn create_wt(
1957 project: Entity<Project>,
1958 is_dir: bool,
1959 path: impl AsRef<Path>,
1960 cx: &mut TestAppContext,
1961 ) -> (Entity<Worktree>, Entry) {
1962 let (wt, _) = project
1963 .update(cx, |project, cx| {
1964 project.find_or_create_worktree(path, true, cx)
1965 })
1966 .await
1967 .unwrap();
1968
1969 let entry = cx
1970 .update(|cx| {
1971 wt.update(cx, |wt, cx| {
1972 wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
1973 })
1974 })
1975 .await
1976 .unwrap()
1977 .into_included()
1978 .unwrap();
1979
1980 (wt, entry)
1981 }
1982
1983 pub fn insert_active_entry_for(
1984 wt: Entity<Worktree>,
1985 entry: Entry,
1986 project: Entity<Project>,
1987 cx: &mut TestAppContext,
1988 ) {
1989 cx.update(|cx| {
1990 let p = ProjectPath {
1991 worktree_id: wt.read(cx).id(),
1992 path: entry.path,
1993 };
1994 project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
1995 });
1996 }
1997
1998 // Terminal rename tests
1999
2000 #[gpui::test]
2001 async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
2002 cx.executor().allow_parking();
2003
2004 let (project, workspace) = init_test(cx).await;
2005
2006 let terminal = project
2007 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2008 .await
2009 .unwrap();
2010
2011 let terminal_view = cx
2012 .add_window(|window, cx| {
2013 TerminalView::new(
2014 terminal,
2015 workspace.downgrade(),
2016 None,
2017 project.downgrade(),
2018 window,
2019 cx,
2020 )
2021 })
2022 .root(cx)
2023 .unwrap();
2024
2025 terminal_view.update(cx, |view, _cx| {
2026 assert!(view.custom_title().is_none());
2027 });
2028 }
2029
2030 #[gpui::test]
2031 async fn test_set_custom_title(cx: &mut TestAppContext) {
2032 cx.executor().allow_parking();
2033
2034 let (project, workspace) = init_test(cx).await;
2035
2036 let terminal = project
2037 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2038 .await
2039 .unwrap();
2040
2041 let terminal_view = cx
2042 .add_window(|window, cx| {
2043 TerminalView::new(
2044 terminal,
2045 workspace.downgrade(),
2046 None,
2047 project.downgrade(),
2048 window,
2049 cx,
2050 )
2051 })
2052 .root(cx)
2053 .unwrap();
2054
2055 terminal_view.update(cx, |view, cx| {
2056 view.set_custom_title(Some("frontend".to_string()), cx);
2057 assert_eq!(view.custom_title(), Some("frontend"));
2058 });
2059 }
2060
2061 #[gpui::test]
2062 async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
2063 cx.executor().allow_parking();
2064
2065 let (project, workspace) = init_test(cx).await;
2066
2067 let terminal = project
2068 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2069 .await
2070 .unwrap();
2071
2072 let terminal_view = cx
2073 .add_window(|window, cx| {
2074 TerminalView::new(
2075 terminal,
2076 workspace.downgrade(),
2077 None,
2078 project.downgrade(),
2079 window,
2080 cx,
2081 )
2082 })
2083 .root(cx)
2084 .unwrap();
2085
2086 terminal_view.update(cx, |view, cx| {
2087 view.set_custom_title(Some("test".to_string()), cx);
2088 assert_eq!(view.custom_title(), Some("test"));
2089
2090 view.set_custom_title(Some("".to_string()), cx);
2091 assert!(view.custom_title().is_none());
2092
2093 view.set_custom_title(Some(" ".to_string()), cx);
2094 assert!(view.custom_title().is_none());
2095 });
2096 }
2097
2098 #[gpui::test]
2099 async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
2100 cx.executor().allow_parking();
2101
2102 let (project, workspace) = init_test(cx).await;
2103
2104 let terminal = project
2105 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2106 .await
2107 .unwrap();
2108
2109 let terminal_view = cx
2110 .add_window(|window, cx| {
2111 TerminalView::new(
2112 terminal,
2113 workspace.downgrade(),
2114 None,
2115 project.downgrade(),
2116 window,
2117 cx,
2118 )
2119 })
2120 .root(cx)
2121 .unwrap();
2122
2123 terminal_view.update(cx, |view, cx| {
2124 view.needs_serialize = false;
2125 view.set_custom_title(Some("new_label".to_string()), cx);
2126 assert!(view.needs_serialize);
2127 });
2128 }
2129
2130 #[gpui::test]
2131 async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
2132 cx.executor().allow_parking();
2133
2134 let (project, workspace) = init_test(cx).await;
2135
2136 let terminal = project
2137 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2138 .await
2139 .unwrap();
2140
2141 let terminal_view = cx
2142 .add_window(|window, cx| {
2143 TerminalView::new(
2144 terminal,
2145 workspace.downgrade(),
2146 None,
2147 project.downgrade(),
2148 window,
2149 cx,
2150 )
2151 })
2152 .root(cx)
2153 .unwrap();
2154
2155 terminal_view.update(cx, |view, cx| {
2156 view.set_custom_title(Some("my-server".to_string()), cx);
2157 let text = view.tab_content_text(0, cx);
2158 assert_eq!(text.as_ref(), "my-server");
2159 });
2160
2161 terminal_view.update(cx, |view, cx| {
2162 view.set_custom_title(None, cx);
2163 let text = view.tab_content_text(0, cx);
2164 assert_ne!(text.as_ref(), "my-server");
2165 });
2166 }
2167
2168 #[gpui::test]
2169 async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
2170 cx: &mut TestAppContext,
2171 ) {
2172 cx.executor().allow_parking();
2173
2174 let (project, workspace) = init_test(cx).await;
2175
2176 let terminal = project
2177 .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2178 .await
2179 .unwrap();
2180
2181 let terminal_view = cx
2182 .add_window(|window, cx| {
2183 TerminalView::new(
2184 terminal,
2185 workspace.downgrade(),
2186 None,
2187 project.downgrade(),
2188 window,
2189 cx,
2190 )
2191 })
2192 .root(cx)
2193 .unwrap();
2194
2195 terminal_view.update(cx, |view, cx| {
2196 view.custom_title = Some("".to_string());
2197 let text = view.tab_content_text(0, cx);
2198 assert!(
2199 !text.is_empty(),
2200 "Tab should show terminal title, not empty string; got: '{}'",
2201 text
2202 );
2203 });
2204
2205 terminal_view.update(cx, |view, cx| {
2206 view.custom_title = Some(" ".to_string());
2207 let text = view.tab_content_text(0, cx);
2208 assert!(
2209 !text.is_empty() && text.as_ref() != " ",
2210 "Tab should show terminal title, not whitespace; got: '{}'",
2211 text
2212 );
2213 });
2214 }
2215}