terminal_view.rs

   1mod persistence;
   2pub mod terminal_element;
   3pub mod terminal_panel;
   4mod terminal_path_like_target;
   5pub mod terminal_scrollbar;
   6
   7use editor::{
   8    Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager,
   9    ui_scrollbar_settings_from_raw,
  10};
  11use gpui::{
  12    Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths,
  13    FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent,
  14    Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions,
  15    anchored, deferred, div,
  16};
  17use itertools::Itertools;
  18use menu;
  19use persistence::TerminalDb;
  20use project::{Project, ProjectEntryId, search::SearchQuery};
  21use schemars::JsonSchema;
  22use serde::Deserialize;
  23use settings::{Settings, SettingsStore, TerminalBell, TerminalBlink, WorkingDirectory};
  24use std::{
  25    any::Any,
  26    cmp,
  27    ops::{Range, RangeInclusive},
  28    path::{Path, PathBuf},
  29    rc::Rc,
  30    sync::Arc,
  31    time::Duration,
  32};
  33use task::TaskId;
  34use terminal::{
  35    Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
  36    ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState,
  37    TaskStatus, Terminal, TerminalBounds, ToggleViMode,
  38    alacritty_terminal::{
  39        index::Point as AlacPoint,
  40        term::{TermMode, point_to_viewport, search::RegexSearch},
  41    },
  42    terminal_settings::{CursorShape, TerminalSettings},
  43};
  44use terminal_element::TerminalElement;
  45use terminal_panel::TerminalPanel;
  46use terminal_path_like_target::{hover_path_like_target, open_path_like_target};
  47use terminal_scrollbar::TerminalScrollHandle;
  48use ui::{
  49    ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar,
  50    prelude::*,
  51    scrollbars::{self, ScrollbarVisibility},
  52};
  53use util::ResultExt;
  54use workspace::{
  55    CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane,
  56    ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
  57    item::{
  58        HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
  59    },
  60    register_serializable_item,
  61    searchable::{
  62        Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle,
  63    },
  64};
  65use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist};
  66
  67struct ImeState {
  68    marked_text: String,
  69}
  70
  71const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
  72
  73/// Event to transmit the scroll from the element to the view
  74#[derive(Clone, Debug, PartialEq)]
  75pub struct ScrollTerminal(pub i32);
  76
  77/// Sends the specified text directly to the terminal.
  78#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
  79#[action(namespace = terminal)]
  80pub struct SendText(String);
  81
  82/// Sends a keystroke sequence to the terminal.
  83#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
  84#[action(namespace = terminal)]
  85pub struct SendKeystroke(String);
  86
  87actions!(
  88    terminal,
  89    [
  90        /// Reruns the last executed task in the terminal.
  91        RerunTask,
  92    ]
  93);
  94
  95/// Renames the terminal tab.
  96#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)]
  97#[action(namespace = terminal)]
  98pub struct RenameTerminal;
  99
 100pub fn init(cx: &mut App) {
 101    terminal_panel::init(cx);
 102
 103    register_serializable_item::<TerminalView>(cx);
 104
 105    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 106        workspace.register_action(TerminalView::deploy);
 107    })
 108    .detach();
 109}
 110
 111pub struct BlockProperties {
 112    pub height: u8,
 113    pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
 114}
 115
 116pub struct BlockContext<'a, 'b> {
 117    pub window: &'a mut Window,
 118    pub context: &'b mut App,
 119    pub dimensions: TerminalBounds,
 120}
 121
 122///A terminal view, maintains the PTY's file handles and communicates with the terminal
 123pub struct TerminalView {
 124    terminal: Entity<Terminal>,
 125    workspace: WeakEntity<Workspace>,
 126    project: WeakEntity<Project>,
 127    focus_handle: FocusHandle,
 128    //Currently using iTerm bell, show bell emoji in tab until input is received
 129    has_bell: bool,
 130    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 131    cursor_shape: CursorShape,
 132    blink_manager: Entity<BlinkManager>,
 133    mode: TerminalMode,
 134    blinking_terminal_enabled: bool,
 135    needs_serialize: bool,
 136    custom_title: Option<String>,
 137    hover: Option<HoverTarget>,
 138    hover_tooltip_update: Task<()>,
 139    workspace_id: Option<WorkspaceId>,
 140    show_breadcrumbs: bool,
 141    block_below_cursor: Option<Rc<BlockProperties>>,
 142    scroll_top: Pixels,
 143    scroll_handle: TerminalScrollHandle,
 144    ime_state: Option<ImeState>,
 145    self_handle: WeakEntity<Self>,
 146    rename_editor: Option<Entity<Editor>>,
 147    rename_editor_subscription: Option<Subscription>,
 148    _subscriptions: Vec<Subscription>,
 149    _terminal_subscriptions: Vec<Subscription>,
 150}
 151
 152#[derive(Default, Clone)]
 153pub enum TerminalMode {
 154    #[default]
 155    Standalone,
 156    Embedded {
 157        max_lines_when_unfocused: Option<usize>,
 158    },
 159}
 160
 161#[derive(Clone)]
 162pub enum ContentMode {
 163    Scrollable,
 164    Inline {
 165        displayed_lines: usize,
 166        total_lines: usize,
 167    },
 168}
 169
 170impl ContentMode {
 171    pub fn is_limited(&self) -> bool {
 172        match self {
 173            ContentMode::Scrollable => false,
 174            ContentMode::Inline {
 175                displayed_lines,
 176                total_lines,
 177            } => displayed_lines < total_lines,
 178        }
 179    }
 180
 181    pub fn is_scrollable(&self) -> bool {
 182        matches!(self, ContentMode::Scrollable)
 183    }
 184}
 185
 186#[derive(Debug)]
 187#[cfg_attr(test, derive(Clone, Eq, PartialEq))]
 188struct HoverTarget {
 189    tooltip: String,
 190    hovered_word: HoveredWord,
 191}
 192
 193impl EventEmitter<Event> for TerminalView {}
 194impl EventEmitter<ItemEvent> for TerminalView {}
 195impl EventEmitter<SearchEvent> for TerminalView {}
 196
 197impl Focusable for TerminalView {
 198    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 199        self.focus_handle.clone()
 200    }
 201}
 202
 203impl TerminalView {
 204    ///Create a new Terminal in the current working directory or the user's home directory
 205    pub fn deploy(
 206        workspace: &mut Workspace,
 207        action: &NewCenterTerminal,
 208        window: &mut Window,
 209        cx: &mut Context<Workspace>,
 210    ) {
 211        let local = action.local;
 212        let working_directory = default_working_directory(workspace, cx);
 213        TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| {
 214            if local {
 215                project.create_local_terminal(cx)
 216            } else {
 217                project.create_terminal_shell(working_directory, cx)
 218            }
 219        })
 220        .detach_and_log_err(cx);
 221    }
 222
 223    pub fn new(
 224        terminal: Entity<Terminal>,
 225        workspace: WeakEntity<Workspace>,
 226        workspace_id: Option<WorkspaceId>,
 227        project: WeakEntity<Project>,
 228        window: &mut Window,
 229        cx: &mut Context<Self>,
 230    ) -> Self {
 231        let workspace_handle = workspace.clone();
 232        let terminal_subscriptions =
 233            subscribe_for_terminal_events(&terminal, workspace, window, cx);
 234
 235        let focus_handle = cx.focus_handle();
 236        let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| {
 237            terminal_view.focus_in(window, cx);
 238        });
 239        let focus_out = cx.on_focus_out(
 240            &focus_handle,
 241            window,
 242            |terminal_view, _event, window, cx| {
 243                terminal_view.focus_out(window, cx);
 244            },
 245        );
 246        let cursor_shape = TerminalSettings::get_global(cx).cursor_shape;
 247
 248        let scroll_handle = TerminalScrollHandle::new(terminal.read(cx));
 249
 250        let blink_manager = cx.new(|cx| {
 251            BlinkManager::new(
 252                CURSOR_BLINK_INTERVAL,
 253                |cx| {
 254                    !matches!(
 255                        TerminalSettings::get_global(cx).blinking,
 256                        TerminalBlink::Off
 257                    )
 258                },
 259                cx,
 260            )
 261        });
 262
 263        let subscriptions = vec![
 264            focus_in,
 265            focus_out,
 266            cx.observe(&blink_manager, |_, _, cx| cx.notify()),
 267            cx.observe_global::<SettingsStore>(Self::settings_changed),
 268        ];
 269
 270        Self {
 271            terminal,
 272            workspace: workspace_handle,
 273            project,
 274            has_bell: false,
 275            focus_handle,
 276            context_menu: None,
 277            cursor_shape,
 278            blink_manager,
 279            blinking_terminal_enabled: false,
 280            hover: None,
 281            hover_tooltip_update: Task::ready(()),
 282            mode: TerminalMode::Standalone,
 283            workspace_id,
 284            show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
 285            block_below_cursor: None,
 286            scroll_top: Pixels::ZERO,
 287            scroll_handle,
 288            needs_serialize: false,
 289            custom_title: None,
 290            ime_state: None,
 291            self_handle: cx.entity().downgrade(),
 292            rename_editor: None,
 293            rename_editor_subscription: None,
 294            _subscriptions: subscriptions,
 295            _terminal_subscriptions: terminal_subscriptions,
 296        }
 297    }
 298
 299    /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines.
 300    pub fn set_embedded_mode(
 301        &mut self,
 302        max_lines_when_unfocused: Option<usize>,
 303        cx: &mut Context<Self>,
 304    ) {
 305        self.mode = TerminalMode::Embedded {
 306            max_lines_when_unfocused,
 307        };
 308        cx.notify();
 309    }
 310
 311    const MAX_EMBEDDED_LINES: usize = 1_000;
 312
 313    /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines
 314    ///
 315    /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES`
 316    pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode {
 317        match &self.mode {
 318            TerminalMode::Standalone => ContentMode::Scrollable,
 319            TerminalMode::Embedded {
 320                max_lines_when_unfocused,
 321            } => {
 322                let total_lines = self.terminal.read(cx).total_lines();
 323
 324                if total_lines > Self::MAX_EMBEDDED_LINES {
 325                    ContentMode::Scrollable
 326                } else {
 327                    let mut displayed_lines = total_lines;
 328
 329                    if !self.focus_handle.is_focused(window)
 330                        && let Some(max_lines) = max_lines_when_unfocused
 331                    {
 332                        displayed_lines = displayed_lines.min(*max_lines)
 333                    }
 334
 335                    ContentMode::Inline {
 336                        displayed_lines,
 337                        total_lines,
 338                    }
 339                }
 340            }
 341        }
 342    }
 343
 344    /// Sets the marked (pre-edit) text from the IME.
 345    pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) {
 346        if text.is_empty() {
 347            return self.clear_marked_text(cx);
 348        }
 349        self.ime_state = Some(ImeState { marked_text: text });
 350        cx.notify();
 351    }
 352
 353    /// Gets the current marked range (UTF-16).
 354    pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
 355        self.ime_state
 356            .as_ref()
 357            .map(|state| 0..state.marked_text.encode_utf16().count())
 358    }
 359
 360    /// Clears the marked (pre-edit) text state.
 361    pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
 362        if self.ime_state.is_some() {
 363            self.ime_state = None;
 364            cx.notify();
 365        }
 366    }
 367
 368    /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range.
 369    pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) {
 370        if !text.is_empty() {
 371            self.terminal.update(cx, |term, _| {
 372                term.input(text.to_string().into_bytes());
 373            });
 374        }
 375    }
 376
 377    pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds {
 378        self.terminal.read(cx).last_content().terminal_bounds
 379    }
 380
 381    pub fn entity(&self) -> &Entity<Terminal> {
 382        &self.terminal
 383    }
 384
 385    pub fn has_bell(&self) -> bool {
 386        self.has_bell
 387    }
 388
 389    pub fn custom_title(&self) -> Option<&str> {
 390        self.custom_title.as_deref()
 391    }
 392
 393    pub fn set_custom_title(&mut self, label: Option<String>, cx: &mut Context<Self>) {
 394        let label = label.filter(|l| !l.trim().is_empty());
 395        if self.custom_title != label {
 396            self.custom_title = label;
 397            self.needs_serialize = true;
 398            cx.emit(ItemEvent::UpdateTab);
 399            cx.notify();
 400        }
 401    }
 402
 403    pub fn is_renaming(&self) -> bool {
 404        self.rename_editor.is_some()
 405    }
 406
 407    pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool {
 408        self.rename_editor
 409            .as_ref()
 410            .is_some_and(|editor| editor.focus_handle(cx).is_focused(window))
 411    }
 412
 413    fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context<Self>) {
 414        let Some(editor) = self.rename_editor.take() else {
 415            return;
 416        };
 417        self.rename_editor_subscription = None;
 418        if save {
 419            let new_label = editor.read(cx).text(cx).trim().to_string();
 420            let label = if new_label.is_empty() {
 421                None
 422            } else {
 423                // Only set custom_title if the text differs from the terminal's dynamic title.
 424                // This prevents subtle layout changes when clicking away without making changes.
 425                let terminal_title = self.terminal.read(cx).title(true);
 426                if new_label == terminal_title {
 427                    None
 428                } else {
 429                    Some(new_label)
 430                }
 431            };
 432            self.set_custom_title(label, cx);
 433        }
 434        cx.notify();
 435        self.focus_handle.focus(window, cx);
 436    }
 437
 438    pub fn rename_terminal(
 439        &mut self,
 440        _: &RenameTerminal,
 441        window: &mut Window,
 442        cx: &mut Context<Self>,
 443    ) {
 444        if self.terminal.read(cx).task().is_some() {
 445            return;
 446        }
 447
 448        let current_label = self
 449            .custom_title
 450            .clone()
 451            .unwrap_or_else(|| self.terminal.read(cx).title(true));
 452
 453        let rename_editor = cx.new(|cx| Editor::single_line(window, cx));
 454        let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, {
 455            let rename_editor = rename_editor.clone();
 456            move |_this, _, event, window, cx| {
 457                if let editor::EditorEvent::Blurred = event {
 458                    // Defer to let focus settle (avoids canceling during double-click).
 459                    let rename_editor = rename_editor.clone();
 460                    cx.defer_in(window, move |this, window, cx| {
 461                        let still_current = this
 462                            .rename_editor
 463                            .as_ref()
 464                            .is_some_and(|current| current == &rename_editor);
 465                        if still_current && !rename_editor.focus_handle(cx).is_focused(window) {
 466                            this.finish_renaming(false, window, cx);
 467                        }
 468                    });
 469                }
 470            }
 471        });
 472
 473        self.rename_editor = Some(rename_editor.clone());
 474        self.rename_editor_subscription = Some(rename_editor_subscription);
 475
 476        rename_editor.update(cx, |editor, cx| {
 477            editor.set_text(current_label, window, cx);
 478            editor.select_all(&SelectAll, window, cx);
 479            editor.focus_handle(cx).focus(window, cx);
 480        });
 481        cx.notify();
 482    }
 483
 484    pub fn clear_bell(&mut self, cx: &mut Context<TerminalView>) {
 485        self.has_bell = false;
 486        cx.emit(Event::Wakeup);
 487    }
 488
 489    pub fn deploy_context_menu(
 490        &mut self,
 491        position: Point<Pixels>,
 492        has_selection: bool,
 493        window: &mut Window,
 494        cx: &mut Context<Self>,
 495    ) {
 496        let assistant_enabled = self
 497            .workspace
 498            .upgrade()
 499            .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
 500            .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());
 501        let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
 502            menu.context(self.focus_handle.clone())
 503                .action("New Terminal", Box::new(NewTerminal::default()))
 504                .action(
 505                    "New Center Terminal",
 506                    Box::new(NewCenterTerminal::default()),
 507                )
 508                .separator()
 509                .action("Copy", Box::new(Copy))
 510                .action("Paste", Box::new(Paste))
 511                .action("Select All", Box::new(SelectAll))
 512                .action("Clear", Box::new(Clear))
 513                .when(assistant_enabled, |menu| {
 514                    menu.separator()
 515                        .action("Inline Assist", Box::new(InlineAssist::default()))
 516                        .when(has_selection, |menu| {
 517                            menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
 518                        })
 519                })
 520                .separator()
 521                .action(
 522                    "Close Terminal Tab",
 523                    Box::new(CloseActiveItem {
 524                        save_intent: None,
 525                        close_pinned: true,
 526                    }),
 527                )
 528        });
 529
 530        window.focus(&context_menu.focus_handle(cx), cx);
 531        let subscription = cx.subscribe_in(
 532            &context_menu,
 533            window,
 534            |this, _, _: &DismissEvent, window, cx| {
 535                if this.context_menu.as_ref().is_some_and(|context_menu| {
 536                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
 537                }) {
 538                    cx.focus_self(window);
 539                }
 540                this.context_menu.take();
 541                cx.notify();
 542            },
 543        );
 544
 545        self.context_menu = Some((context_menu, position, subscription));
 546    }
 547
 548    fn settings_changed(&mut self, cx: &mut Context<Self>) {
 549        let settings = TerminalSettings::get_global(cx);
 550        let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
 551        self.show_breadcrumbs = settings.toolbar.breadcrumbs;
 552
 553        let should_blink = match settings.blinking {
 554            TerminalBlink::Off => false,
 555            TerminalBlink::On => true,
 556            TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
 557        };
 558        let new_cursor_shape = settings.cursor_shape;
 559        let old_cursor_shape = self.cursor_shape;
 560        if old_cursor_shape != new_cursor_shape {
 561            self.cursor_shape = new_cursor_shape;
 562            self.terminal.update(cx, |term, _| {
 563                term.set_cursor_shape(self.cursor_shape);
 564            });
 565        }
 566
 567        self.blink_manager.update(
 568            cx,
 569            if should_blink {
 570                BlinkManager::enable
 571            } else {
 572                BlinkManager::disable
 573            },
 574        );
 575
 576        if breadcrumb_visibility_changed {
 577            cx.emit(ItemEvent::UpdateBreadcrumbs);
 578        }
 579        cx.notify();
 580    }
 581
 582    fn show_character_palette(
 583        &mut self,
 584        _: &ShowCharacterPalette,
 585        window: &mut Window,
 586        cx: &mut Context<Self>,
 587    ) {
 588        if self
 589            .terminal
 590            .read(cx)
 591            .last_content
 592            .mode
 593            .contains(TermMode::ALT_SCREEN)
 594        {
 595            self.terminal.update(cx, |term, cx| {
 596                term.try_keystroke(
 597                    &Keystroke::parse("ctrl-cmd-space").unwrap(),
 598                    TerminalSettings::get_global(cx).option_as_meta,
 599                )
 600            });
 601        } else {
 602            window.show_character_palette();
 603        }
 604    }
 605
 606    fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
 607        self.terminal.update(cx, |term, _| term.select_all());
 608        cx.notify();
 609    }
 610
 611    fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context<Self>) {
 612        let task = self
 613            .terminal
 614            .read(cx)
 615            .task()
 616            .map(|task| terminal_rerun_override(&task.spawned_task.id))
 617            .unwrap_or_default();
 618        window.dispatch_action(Box::new(task), cx);
 619    }
 620
 621    fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context<Self>) {
 622        self.scroll_top = px(0.);
 623        self.terminal.update(cx, |term, _| term.clear());
 624        cx.notify();
 625    }
 626
 627    fn max_scroll_top(&self, cx: &App) -> Pixels {
 628        let terminal = self.terminal.read(cx);
 629
 630        let Some(block) = self.block_below_cursor.as_ref() else {
 631            return Pixels::ZERO;
 632        };
 633
 634        let line_height = terminal.last_content().terminal_bounds.line_height;
 635        let viewport_lines = terminal.viewport_lines();
 636        let cursor = point_to_viewport(
 637            terminal.last_content.display_offset,
 638            terminal.last_content.cursor.point,
 639        )
 640        .unwrap_or_default();
 641        let max_scroll_top_in_lines =
 642            (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1));
 643
 644        max_scroll_top_in_lines as f32 * line_height
 645    }
 646
 647    fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context<Self>) {
 648        let terminal_content = self.terminal.read(cx).last_content();
 649
 650        if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
 651            let line_height = terminal_content.terminal_bounds.line_height;
 652            let y_delta = event.delta.pixel_delta(line_height).y;
 653            if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO {
 654                self.scroll_top = cmp::max(
 655                    Pixels::ZERO,
 656                    cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)),
 657                );
 658                cx.notify();
 659                return;
 660            }
 661        }
 662        self.terminal.update(cx, |term, cx| {
 663            term.scroll_wheel(
 664                event,
 665                TerminalSettings::get_global(cx).scroll_multiplier.max(0.01),
 666            )
 667        });
 668    }
 669
 670    fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context<Self>) {
 671        let terminal_content = self.terminal.read(cx).last_content();
 672        if self.block_below_cursor.is_some()
 673            && terminal_content.display_offset == 0
 674            && self.scroll_top > Pixels::ZERO
 675        {
 676            let line_height = terminal_content.terminal_bounds.line_height;
 677            self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO);
 678            return;
 679        }
 680
 681        self.terminal.update(cx, |term, _| term.scroll_line_up());
 682        cx.notify();
 683    }
 684
 685    fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context<Self>) {
 686        let terminal_content = self.terminal.read(cx).last_content();
 687        if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 {
 688            let max_scroll_top = self.max_scroll_top(cx);
 689            if self.scroll_top < max_scroll_top {
 690                let line_height = terminal_content.terminal_bounds.line_height;
 691                self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top);
 692            }
 693            return;
 694        }
 695
 696        self.terminal.update(cx, |term, _| term.scroll_line_down());
 697        cx.notify();
 698    }
 699
 700    fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context<Self>) {
 701        if self.scroll_top == Pixels::ZERO {
 702            self.terminal.update(cx, |term, _| term.scroll_page_up());
 703        } else {
 704            let line_height = self
 705                .terminal
 706                .read(cx)
 707                .last_content
 708                .terminal_bounds
 709                .line_height();
 710            let visible_block_lines = (self.scroll_top / line_height) as usize;
 711            let viewport_lines = self.terminal.read(cx).viewport_lines();
 712            let visible_content_lines = viewport_lines - visible_block_lines;
 713
 714            if visible_block_lines >= viewport_lines {
 715                self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height;
 716            } else {
 717                self.scroll_top = px(0.);
 718                self.terminal
 719                    .update(cx, |term, _| term.scroll_up_by(visible_content_lines));
 720            }
 721        }
 722        cx.notify();
 723    }
 724
 725    fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context<Self>) {
 726        self.terminal.update(cx, |term, _| term.scroll_page_down());
 727        let terminal = self.terminal.read(cx);
 728        if terminal.last_content().display_offset < terminal.viewport_lines() {
 729            self.scroll_top = self.max_scroll_top(cx);
 730        }
 731        cx.notify();
 732    }
 733
 734    fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context<Self>) {
 735        self.terminal.update(cx, |term, _| term.scroll_to_top());
 736        cx.notify();
 737    }
 738
 739    fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context<Self>) {
 740        self.terminal.update(cx, |term, _| term.scroll_to_bottom());
 741        if self.block_below_cursor.is_some() {
 742            self.scroll_top = self.max_scroll_top(cx);
 743        }
 744        cx.notify();
 745    }
 746
 747    fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context<Self>) {
 748        self.terminal.update(cx, |term, _| term.toggle_vi_mode());
 749        cx.notify();
 750    }
 751
 752    pub fn should_show_cursor(&self, focused: bool, cx: &mut Context<Self>) -> bool {
 753        // Hide cursor when in embedded mode and not focused (read-only output like Agent panel)
 754        if let TerminalMode::Embedded { .. } = &self.mode {
 755            if !focused {
 756                return false;
 757            }
 758        }
 759
 760        // For Standalone mode: always show cursor when not focused or in special modes
 761        if !focused
 762            || self
 763                .terminal
 764                .read(cx)
 765                .last_content
 766                .mode
 767                .contains(TermMode::ALT_SCREEN)
 768        {
 769            return true;
 770        }
 771
 772        // When focused, check blinking settings and blink manager state
 773        match TerminalSettings::get_global(cx).blinking {
 774            TerminalBlink::Off => true,
 775            TerminalBlink::TerminalControlled => {
 776                !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible()
 777            }
 778            TerminalBlink::On => self.blink_manager.read(cx).visible(),
 779        }
 780    }
 781
 782    pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 783        self.blink_manager.update(cx, BlinkManager::pause_blinking);
 784    }
 785
 786    pub fn terminal(&self) -> &Entity<Terminal> {
 787        &self.terminal
 788    }
 789
 790    pub fn set_block_below_cursor(
 791        &mut self,
 792        block: BlockProperties,
 793        window: &mut Window,
 794        cx: &mut Context<Self>,
 795    ) {
 796        self.block_below_cursor = Some(Rc::new(block));
 797        self.scroll_to_bottom(&ScrollToBottom, window, cx);
 798        cx.notify();
 799    }
 800
 801    pub fn clear_block_below_cursor(&mut self, cx: &mut Context<Self>) {
 802        self.block_below_cursor = None;
 803        self.scroll_top = Pixels::ZERO;
 804        cx.notify();
 805    }
 806
 807    ///Attempt to paste the clipboard into the terminal
 808    fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
 809        self.terminal.update(cx, |term, _| term.copy(None));
 810        cx.notify();
 811    }
 812
 813    ///Attempt to paste the clipboard into the terminal
 814    fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
 815        let Some(clipboard) = cx.read_from_clipboard() else {
 816            return;
 817        };
 818
 819        match clipboard.entries().first() {
 820            Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => {
 821                self.forward_ctrl_v(cx);
 822            }
 823            _ => {
 824                if let Some(text) = clipboard.text() {
 825                    self.terminal
 826                        .update(cx, |terminal, _cx| terminal.paste(&text));
 827                }
 828            }
 829        }
 830    }
 831
 832    /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
 833    /// and attach images using their native workflows.
 834    fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
 835        self.terminal.update(cx, |term, _| {
 836            term.input(vec![0x16]);
 837        });
 838    }
 839
 840    fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
 841        let mut text = paths.iter().map(|path| format!(" {path:?}")).join("");
 842        text.push(' ');
 843        window.focus(&self.focus_handle(cx), cx);
 844        self.terminal.update(cx, |terminal, _| {
 845            terminal.paste(&text);
 846        });
 847    }
 848
 849    fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {
 850        self.clear_bell(cx);
 851        self.blink_manager.update(cx, BlinkManager::pause_blinking);
 852        self.terminal.update(cx, |term, _| {
 853            term.input(text.0.to_string().into_bytes());
 854        });
 855    }
 856
 857    fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context<Self>) {
 858        if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
 859            self.clear_bell(cx);
 860            self.blink_manager.update(cx, BlinkManager::pause_blinking);
 861            self.process_keystroke(&keystroke, cx);
 862        }
 863    }
 864
 865    fn dispatch_context(&self, cx: &App) -> KeyContext {
 866        let mut dispatch_context = KeyContext::new_with_defaults();
 867        dispatch_context.add("Terminal");
 868
 869        if self.terminal.read(cx).vi_mode_enabled() {
 870            dispatch_context.add("vi_mode");
 871        }
 872
 873        let mode = self.terminal.read(cx).last_content.mode;
 874        dispatch_context.set(
 875            "screen",
 876            if mode.contains(TermMode::ALT_SCREEN) {
 877                "alt"
 878            } else {
 879                "normal"
 880            },
 881        );
 882
 883        if mode.contains(TermMode::APP_CURSOR) {
 884            dispatch_context.add("DECCKM");
 885        }
 886        if mode.contains(TermMode::APP_KEYPAD) {
 887            dispatch_context.add("DECPAM");
 888        } else {
 889            dispatch_context.add("DECPNM");
 890        }
 891        if mode.contains(TermMode::SHOW_CURSOR) {
 892            dispatch_context.add("DECTCEM");
 893        }
 894        if mode.contains(TermMode::LINE_WRAP) {
 895            dispatch_context.add("DECAWM");
 896        }
 897        if mode.contains(TermMode::ORIGIN) {
 898            dispatch_context.add("DECOM");
 899        }
 900        if mode.contains(TermMode::INSERT) {
 901            dispatch_context.add("IRM");
 902        }
 903        //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
 904        if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
 905            dispatch_context.add("LNM");
 906        }
 907        if mode.contains(TermMode::FOCUS_IN_OUT) {
 908            dispatch_context.add("report_focus");
 909        }
 910        if mode.contains(TermMode::ALTERNATE_SCROLL) {
 911            dispatch_context.add("alternate_scroll");
 912        }
 913        if mode.contains(TermMode::BRACKETED_PASTE) {
 914            dispatch_context.add("bracketed_paste");
 915        }
 916        if mode.intersects(TermMode::MOUSE_MODE) {
 917            dispatch_context.add("any_mouse_reporting");
 918        }
 919        {
 920            let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
 921                "click"
 922            } else if mode.contains(TermMode::MOUSE_DRAG) {
 923                "drag"
 924            } else if mode.contains(TermMode::MOUSE_MOTION) {
 925                "motion"
 926            } else {
 927                "off"
 928            };
 929            dispatch_context.set("mouse_reporting", mouse_reporting);
 930        }
 931        {
 932            let format = if mode.contains(TermMode::SGR_MOUSE) {
 933                "sgr"
 934            } else if mode.contains(TermMode::UTF8_MOUSE) {
 935                "utf8"
 936            } else {
 937                "normal"
 938            };
 939            dispatch_context.set("mouse_format", format);
 940        };
 941
 942        if self.terminal.read(cx).last_content.selection.is_some() {
 943            dispatch_context.add("selection");
 944        }
 945
 946        dispatch_context
 947    }
 948
 949    fn set_terminal(
 950        &mut self,
 951        terminal: Entity<Terminal>,
 952        window: &mut Window,
 953        cx: &mut Context<TerminalView>,
 954    ) {
 955        self._terminal_subscriptions =
 956            subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx);
 957        self.terminal = terminal;
 958    }
 959
 960    fn rerun_button(task: &TaskState) -> Option<IconButton> {
 961        if !task.spawned_task.show_rerun {
 962            return None;
 963        }
 964
 965        let task_id = task.spawned_task.id.clone();
 966        Some(
 967            IconButton::new("rerun-icon", IconName::Rerun)
 968                .icon_size(IconSize::Small)
 969                .size(ButtonSize::Compact)
 970                .icon_color(Color::Default)
 971                .shape(ui::IconButtonShape::Square)
 972                .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx))
 973                .on_click(move |_, window, cx| {
 974                    window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx);
 975                }),
 976        )
 977    }
 978}
 979
 980fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun {
 981    zed_actions::Rerun {
 982        task_id: Some(task.0.clone()),
 983        allow_concurrent_runs: Some(true),
 984        use_new_terminal: Some(false),
 985        reevaluate_context: false,
 986    }
 987}
 988
 989fn subscribe_for_terminal_events(
 990    terminal: &Entity<Terminal>,
 991    workspace: WeakEntity<Workspace>,
 992    window: &mut Window,
 993    cx: &mut Context<TerminalView>,
 994) -> Vec<Subscription> {
 995    let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify());
 996    let mut previous_cwd = None;
 997    let terminal_events_subscription = cx.subscribe_in(
 998        terminal,
 999        window,
1000        move |terminal_view, terminal, event, window, cx| {
1001            let current_cwd = terminal.read(cx).working_directory();
1002            if current_cwd != previous_cwd {
1003                previous_cwd = current_cwd;
1004                terminal_view.needs_serialize = true;
1005            }
1006
1007            match event {
1008                Event::Wakeup => {
1009                    cx.notify();
1010                    cx.emit(Event::Wakeup);
1011                    cx.emit(ItemEvent::UpdateTab);
1012                    cx.emit(SearchEvent::MatchesInvalidated);
1013                }
1014
1015                Event::Bell => {
1016                    terminal_view.has_bell = true;
1017                    if let TerminalBell::System = TerminalSettings::get_global(cx).bell {
1018                        window.play_system_bell();
1019                    }
1020                    cx.emit(Event::Wakeup);
1021                }
1022
1023                Event::BlinkChanged(blinking) => {
1024                    terminal_view.blinking_terminal_enabled = *blinking;
1025
1026                    // If in terminal-controlled mode and focused, update blink manager
1027                    if matches!(
1028                        TerminalSettings::get_global(cx).blinking,
1029                        TerminalBlink::TerminalControlled
1030                    ) && terminal_view.focus_handle.is_focused(window)
1031                    {
1032                        terminal_view.blink_manager.update(cx, |manager, cx| {
1033                            if *blinking {
1034                                manager.enable(cx);
1035                            } else {
1036                                manager.disable(cx);
1037                            }
1038                        });
1039                    }
1040                }
1041
1042                Event::TitleChanged => {
1043                    cx.emit(ItemEvent::UpdateTab);
1044                }
1045
1046                Event::NewNavigationTarget(maybe_navigation_target) => {
1047                    match maybe_navigation_target
1048                        .as_ref()
1049                        .zip(terminal.read(cx).last_content.last_hovered_word.as_ref())
1050                    {
1051                        Some((MaybeNavigationTarget::Url(url), hovered_word)) => {
1052                            if Some(hovered_word)
1053                                != terminal_view
1054                                    .hover
1055                                    .as_ref()
1056                                    .map(|hover| &hover.hovered_word)
1057                            {
1058                                terminal_view.hover = Some(HoverTarget {
1059                                    tooltip: url.clone(),
1060                                    hovered_word: hovered_word.clone(),
1061                                });
1062                                terminal_view.hover_tooltip_update = Task::ready(());
1063                                cx.notify();
1064                            }
1065                        }
1066                        Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => {
1067                            if Some(hovered_word)
1068                                != terminal_view
1069                                    .hover
1070                                    .as_ref()
1071                                    .map(|hover| &hover.hovered_word)
1072                            {
1073                                terminal_view.hover = None;
1074                                terminal_view.hover_tooltip_update = hover_path_like_target(
1075                                    &workspace,
1076                                    hovered_word.clone(),
1077                                    path_like_target,
1078                                    cx,
1079                                );
1080                                cx.notify();
1081                            }
1082                        }
1083                        None => {
1084                            terminal_view.hover = None;
1085                            terminal_view.hover_tooltip_update = Task::ready(());
1086                            cx.notify();
1087                        }
1088                    }
1089                }
1090
1091                Event::Open(maybe_navigation_target) => match maybe_navigation_target {
1092                    MaybeNavigationTarget::Url(url) => cx.open_url(url),
1093                    MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target(
1094                        &workspace,
1095                        terminal_view,
1096                        path_like_target,
1097                        window,
1098                        cx,
1099                    ),
1100                },
1101                Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
1102                Event::CloseTerminal => cx.emit(ItemEvent::CloseItem),
1103                Event::SelectionsChanged => {
1104                    window.invalidate_character_coordinates();
1105                    cx.emit(SearchEvent::ActiveMatchChanged)
1106                }
1107            }
1108        },
1109    );
1110    vec![terminal_subscription, terminal_events_subscription]
1111}
1112
1113fn regex_search_for_query(query: &SearchQuery) -> Option<RegexSearch> {
1114    let str = query.as_str();
1115    if query.is_regex() {
1116        if str == "." {
1117            return None;
1118        }
1119        RegexSearch::new(str).ok()
1120    } else {
1121        RegexSearch::new(&regex::escape(str)).ok()
1122    }
1123}
1124
1125#[derive(Default)]
1126struct TerminalScrollbarSettingsWrapper;
1127
1128impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
1129    fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
1130        TerminalSettings::get_global(cx)
1131            .scrollbar
1132            .show
1133            .map(ui_scrollbar_settings_from_raw)
1134            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
1135    }
1136}
1137
1138impl TerminalView {
1139    /// Attempts to process a keystroke in the terminal. Returns true if handled.
1140    ///
1141    /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k)
1142    /// updates the cursor locally without sending data to the shell, so there's no
1143    /// shell output to automatically trigger a re-render.
1144    fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
1145        let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| {
1146            (
1147                term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta),
1148                term.vi_mode_enabled(),
1149            )
1150        });
1151
1152        if handled && vi_mode_enabled {
1153            cx.notify();
1154        }
1155
1156        handled
1157    }
1158
1159    fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1160        self.clear_bell(cx);
1161        self.pause_cursor_blinking(window, cx);
1162
1163        if self.process_keystroke(&event.keystroke, cx) {
1164            cx.stop_propagation();
1165        }
1166    }
1167
1168    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1169        self.terminal.update(cx, |terminal, _| {
1170            terminal.set_cursor_shape(self.cursor_shape);
1171            terminal.focus_in();
1172        });
1173
1174        let should_blink = match TerminalSettings::get_global(cx).blinking {
1175            TerminalBlink::Off => false,
1176            TerminalBlink::On => true,
1177            TerminalBlink::TerminalControlled => self.blinking_terminal_enabled,
1178        };
1179
1180        if should_blink {
1181            self.blink_manager.update(cx, BlinkManager::enable);
1182        }
1183
1184        window.invalidate_character_coordinates();
1185        cx.notify();
1186    }
1187
1188    fn focus_out(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1189        self.blink_manager.update(cx, BlinkManager::disable);
1190        self.terminal.update(cx, |terminal, _| {
1191            terminal.focus_out();
1192            terminal.set_cursor_shape(CursorShape::Hollow);
1193        });
1194        cx.notify();
1195    }
1196}
1197
1198impl Render for TerminalView {
1199    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1200        // TODO: this should be moved out of render
1201        self.scroll_handle.update(self.terminal.read(cx));
1202
1203        if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
1204            self.terminal.update(cx, |term, _| {
1205                let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
1206                match delta.cmp(&0) {
1207                    cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
1208                    cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
1209                    cmp::Ordering::Equal => {}
1210                }
1211            });
1212        }
1213
1214        let terminal_handle = self.terminal.clone();
1215        let terminal_view_handle = cx.entity();
1216
1217        let focused = self.focus_handle.is_focused(window);
1218
1219        div()
1220            .id("terminal-view")
1221            .size_full()
1222            .relative()
1223            .track_focus(&self.focus_handle(cx))
1224            .key_context(self.dispatch_context(cx))
1225            .on_action(cx.listener(TerminalView::send_text))
1226            .on_action(cx.listener(TerminalView::send_keystroke))
1227            .on_action(cx.listener(TerminalView::copy))
1228            .on_action(cx.listener(TerminalView::paste))
1229            .on_action(cx.listener(TerminalView::clear))
1230            .on_action(cx.listener(TerminalView::scroll_line_up))
1231            .on_action(cx.listener(TerminalView::scroll_line_down))
1232            .on_action(cx.listener(TerminalView::scroll_page_up))
1233            .on_action(cx.listener(TerminalView::scroll_page_down))
1234            .on_action(cx.listener(TerminalView::scroll_to_top))
1235            .on_action(cx.listener(TerminalView::scroll_to_bottom))
1236            .on_action(cx.listener(TerminalView::toggle_vi_mode))
1237            .on_action(cx.listener(TerminalView::show_character_palette))
1238            .on_action(cx.listener(TerminalView::select_all))
1239            .on_action(cx.listener(TerminalView::rerun_task))
1240            .on_action(cx.listener(TerminalView::rename_terminal))
1241            .on_key_down(cx.listener(Self::key_down))
1242            .on_mouse_down(
1243                MouseButton::Right,
1244                cx.listener(|this, event: &MouseDownEvent, window, cx| {
1245                    if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) {
1246                        let had_selection = this.terminal.read(cx).last_content.selection.is_some();
1247                        if !had_selection {
1248                            this.terminal.update(cx, |terminal, _| {
1249                                terminal.select_word_at_event_position(event);
1250                            });
1251                        }
1252                        let has_selection = !had_selection
1253                            || this
1254                                .terminal
1255                                .read(cx)
1256                                .last_content
1257                                .selection_text
1258                                .as_ref()
1259                                .is_some_and(|text| !text.is_empty());
1260                        this.deploy_context_menu(event.position, has_selection, window, cx);
1261                        cx.notify();
1262                    }
1263                }),
1264            )
1265            .child(
1266                // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
1267                div()
1268                    .id("terminal-view-container")
1269                    .size_full()
1270                    .bg(cx.theme().colors().editor_background)
1271                    .child(TerminalElement::new(
1272                        terminal_handle,
1273                        terminal_view_handle,
1274                        self.workspace.clone(),
1275                        self.focus_handle.clone(),
1276                        focused,
1277                        self.should_show_cursor(focused, cx),
1278                        self.block_below_cursor.clone(),
1279                        self.mode.clone(),
1280                    ))
1281                    .when(self.content_mode(window, cx).is_scrollable(), |div| {
1282                        div.custom_scrollbars(
1283                            Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
1284                                .show_along(ScrollAxes::Vertical)
1285                                .with_track_along(
1286                                    ScrollAxes::Vertical,
1287                                    cx.theme().colors().editor_background,
1288                                )
1289                                .tracked_scroll_handle(&self.scroll_handle),
1290                            window,
1291                            cx,
1292                        )
1293                    }),
1294            )
1295            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1296                deferred(
1297                    anchored()
1298                        .position(*position)
1299                        .anchor(gpui::Anchor::TopLeft)
1300                        .child(menu.clone()),
1301                )
1302                .with_priority(1)
1303            }))
1304    }
1305}
1306
1307impl Item for TerminalView {
1308    type Event = ItemEvent;
1309
1310    fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
1311        Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
1312            let terminal = self.terminal().read(cx);
1313            let title = terminal.title(false);
1314            let pid = terminal.pid_getter()?.fallback_pid();
1315
1316            move |_, _| {
1317                v_flex()
1318                    .gap_1()
1319                    .child(Label::new(title.clone()))
1320                    .child(h_flex().flex_grow().child(Divider::horizontal()))
1321                    .child(
1322                        Label::new(format!("Process ID (PID): {}", pid))
1323                            .color(Color::Muted)
1324                            .size(LabelSize::Small),
1325                    )
1326                    .into_any_element()
1327            }
1328        }))))
1329    }
1330
1331    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
1332        let terminal = self.terminal().read(cx);
1333        let title = self
1334            .custom_title
1335            .as_ref()
1336            .filter(|title| !title.trim().is_empty())
1337            .cloned()
1338            .unwrap_or_else(|| terminal.title(true));
1339
1340        let (icon, icon_color, rerun_button) = match terminal.task() {
1341            Some(terminal_task) => match &terminal_task.status {
1342                TaskStatus::Running => (
1343                    IconName::PlayFilled,
1344                    Color::Disabled,
1345                    TerminalView::rerun_button(terminal_task),
1346                ),
1347                TaskStatus::Unknown => (
1348                    IconName::Warning,
1349                    Color::Warning,
1350                    TerminalView::rerun_button(terminal_task),
1351                ),
1352                TaskStatus::Completed { success } => {
1353                    let rerun_button = TerminalView::rerun_button(terminal_task);
1354
1355                    if *success {
1356                        (IconName::Check, Color::Success, rerun_button)
1357                    } else {
1358                        (IconName::XCircle, Color::Error, rerun_button)
1359                    }
1360                }
1361            },
1362            None => (IconName::Terminal, Color::Muted, None),
1363        };
1364
1365        let self_handle = self.self_handle.clone();
1366        h_flex()
1367            .gap_1()
1368            .group("term-tab-icon")
1369            .when(!params.selected, |this| {
1370                this.track_focus(&self.focus_handle)
1371            })
1372            .on_action(move |action: &RenameTerminal, window, cx| {
1373                self_handle
1374                    .update(cx, |this, cx| this.rename_terminal(action, window, cx))
1375                    .ok();
1376            })
1377            .child(
1378                h_flex()
1379                    .group("term-tab-icon")
1380                    .child(
1381                        div()
1382                            .when(rerun_button.is_some(), |this| {
1383                                this.hover(|style| style.invisible().w_0())
1384                            })
1385                            .child(Icon::new(icon).color(icon_color)),
1386                    )
1387                    .when_some(rerun_button, |this, rerun_button| {
1388                        this.child(
1389                            div()
1390                                .absolute()
1391                                .visible_on_hover("term-tab-icon")
1392                                .child(rerun_button),
1393                        )
1394                    }),
1395            )
1396            .child(
1397                div()
1398                    .relative()
1399                    .child(
1400                        Label::new(title)
1401                            .color(params.text_color())
1402                            .when(self.is_renaming(), |this| this.alpha(0.)),
1403                    )
1404                    .when_some(self.rename_editor.clone(), |this, editor| {
1405                        let self_handle = self.self_handle.clone();
1406                        let self_handle_cancel = self.self_handle.clone();
1407                        this.child(
1408                            div()
1409                                .absolute()
1410                                .top_0()
1411                                .left_0()
1412                                .size_full()
1413                                .child(editor)
1414                                .on_action(move |_: &menu::Confirm, window, cx| {
1415                                    self_handle
1416                                        .update(cx, |this, cx| {
1417                                            this.finish_renaming(true, window, cx)
1418                                        })
1419                                        .ok();
1420                                })
1421                                .on_action(move |_: &menu::Cancel, window, cx| {
1422                                    self_handle_cancel
1423                                        .update(cx, |this, cx| {
1424                                            this.finish_renaming(false, window, cx)
1425                                        })
1426                                        .ok();
1427                                }),
1428                        )
1429                    }),
1430            )
1431            .into_any()
1432    }
1433
1434    fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString {
1435        if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) {
1436            return custom_title.clone().into();
1437        }
1438        let terminal = self.terminal().read(cx);
1439        terminal.title(detail == 0).into()
1440    }
1441
1442    fn telemetry_event_text(&self) -> Option<&'static str> {
1443        None
1444    }
1445
1446    fn handle_drop(
1447        &self,
1448        active_pane: &Pane,
1449        dropped: &dyn Any,
1450        window: &mut Window,
1451        cx: &mut App,
1452    ) -> bool {
1453        let Some(project) = self.project.upgrade() else {
1454            return false;
1455        };
1456
1457        if let Some(paths) = dropped.downcast_ref::<ExternalPaths>() {
1458            let is_local = project.read(cx).is_local();
1459            if is_local {
1460                self.add_paths_to_terminal(paths.paths(), window, cx);
1461                return true;
1462            }
1463
1464            return false;
1465        } else if let Some(tab) = dropped.downcast_ref::<DraggedTab>() {
1466            let Some(self_handle) = self.self_handle.upgrade() else {
1467                return false;
1468            };
1469
1470            let Some(workspace) = self.workspace.upgrade() else {
1471                return false;
1472            };
1473
1474            let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else {
1475                return false;
1476            };
1477
1478            let item = if tab.pane == this_pane {
1479                active_pane.item_for_index(tab.ix)
1480            } else {
1481                tab.pane.read(cx).item_for_index(tab.ix)
1482            };
1483
1484            let Some(item) = item else {
1485                return false;
1486            };
1487
1488            if item.downcast::<TerminalView>().is_some() {
1489                let Some(split_direction) = active_pane.drag_split_direction() else {
1490                    return false;
1491                };
1492
1493                let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
1494                    return false;
1495                };
1496
1497                if !terminal_panel.read(cx).center.panes().contains(&&this_pane) {
1498                    return false;
1499                }
1500
1501                let source = tab.pane.clone();
1502                let item_id_to_move = item.item_id();
1503                let is_zoomed = {
1504                    let terminal_panel = terminal_panel.read(cx);
1505                    if terminal_panel.active_pane == this_pane {
1506                        active_pane.is_zoomed()
1507                    } else {
1508                        terminal_panel.active_pane.read(cx).is_zoomed()
1509                    }
1510                };
1511
1512                let workspace = workspace.downgrade();
1513                let terminal_panel = terminal_panel.downgrade();
1514                // Defer the split operation to avoid re-entrancy panic.
1515                // The pane may be the one currently being updated, so we cannot
1516                // call mark_positions (via split) synchronously.
1517                window
1518                    .spawn(cx, async move |cx| {
1519                        cx.update(|window, cx| {
1520                            let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| {
1521                                let new_pane = terminal_panel::new_terminal_pane(
1522                                    workspace, project, is_zoomed, window, cx,
1523                                );
1524                                terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
1525                                terminal_panel.center.split(
1526                                    &this_pane,
1527                                    &new_pane,
1528                                    split_direction,
1529                                    cx,
1530                                );
1531                                anyhow::Ok(new_pane)
1532                            }) else {
1533                                return;
1534                            };
1535
1536                            let Some(new_pane) = new_pane.log_err() else {
1537                                return;
1538                            };
1539
1540                            workspace::move_item(
1541                                &source,
1542                                &new_pane,
1543                                item_id_to_move,
1544                                new_pane.read(cx).active_item_index(),
1545                                true,
1546                                window,
1547                                cx,
1548                            );
1549                        })
1550                        .ok();
1551                    })
1552                    .detach();
1553
1554                return true;
1555            } else {
1556                if let Some(project_path) = item.project_path(cx)
1557                    && let Some(path) = project.read(cx).absolute_path(&project_path, cx)
1558                {
1559                    self.add_paths_to_terminal(&[path], window, cx);
1560                    return true;
1561                }
1562            }
1563
1564            return false;
1565        } else if let Some(selection) = dropped.downcast_ref::<DraggedSelection>() {
1566            let project = project.read(cx);
1567            let paths = selection
1568                .items()
1569                .map(|selected_entry| selected_entry.entry_id)
1570                .filter_map(|entry_id| project.path_for_entry(entry_id, cx))
1571                .filter_map(|project_path| project.absolute_path(&project_path, cx))
1572                .collect::<Vec<_>>();
1573
1574            if !paths.is_empty() {
1575                self.add_paths_to_terminal(&paths, window, cx);
1576            }
1577
1578            return true;
1579        } else if let Some(&entry_id) = dropped.downcast_ref::<ProjectEntryId>() {
1580            let project = project.read(cx);
1581            if let Some(path) = project
1582                .path_for_entry(entry_id, cx)
1583                .and_then(|project_path| project.absolute_path(&project_path, cx))
1584            {
1585                self.add_paths_to_terminal(&[path], window, cx);
1586            }
1587
1588            return true;
1589        }
1590
1591        false
1592    }
1593
1594    fn tab_extra_context_menu_actions(
1595        &self,
1596        _window: &mut Window,
1597        cx: &mut Context<Self>,
1598    ) -> Vec<(SharedString, Box<dyn gpui::Action>)> {
1599        let terminal = self.terminal.read(cx);
1600        if terminal.task().is_none() {
1601            vec![("Rename".into(), Box::new(RenameTerminal))]
1602        } else {
1603            Vec::new()
1604        }
1605    }
1606
1607    fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1608        workspace::item::ItemBufferKind::Singleton
1609    }
1610
1611    fn can_split(&self) -> bool {
1612        true
1613    }
1614
1615    fn clone_on_split(
1616        &self,
1617        workspace_id: Option<WorkspaceId>,
1618        window: &mut Window,
1619        cx: &mut Context<Self>,
1620    ) -> Task<Option<Entity<Self>>> {
1621        let Ok(terminal) = self.project.update(cx, |project, cx| {
1622            let cwd = project
1623                .active_project_directory(cx)
1624                .map(|it| it.to_path_buf());
1625            project.clone_terminal(self.terminal(), cx, cwd)
1626        }) else {
1627            return Task::ready(None);
1628        };
1629        cx.spawn_in(window, async move |this, cx| {
1630            let terminal = terminal.await.log_err()?;
1631            this.update_in(cx, |this, window, cx| {
1632                cx.new(|cx| {
1633                    TerminalView::new(
1634                        terminal,
1635                        this.workspace.clone(),
1636                        workspace_id,
1637                        this.project.clone(),
1638                        window,
1639                        cx,
1640                    )
1641                })
1642            })
1643            .ok()
1644        })
1645    }
1646
1647    fn is_dirty(&self, cx: &App) -> bool {
1648        match self.terminal.read(cx).task() {
1649            Some(task) => task.status == TaskStatus::Running,
1650            None => self.has_bell(),
1651        }
1652    }
1653
1654    fn has_conflict(&self, _cx: &App) -> bool {
1655        false
1656    }
1657
1658    fn can_save_as(&self, _cx: &App) -> bool {
1659        false
1660    }
1661
1662    fn as_searchable(
1663        &self,
1664        handle: &Entity<Self>,
1665        _: &App,
1666    ) -> Option<Box<dyn SearchableItemHandle>> {
1667        Some(Box::new(handle.clone()))
1668    }
1669
1670    fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
1671        if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() {
1672            ToolbarItemLocation::PrimaryLeft
1673        } else {
1674            ToolbarItemLocation::Hidden
1675        }
1676    }
1677
1678    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
1679        Some((
1680            vec![HighlightedText {
1681                text: self.terminal().read(cx).breadcrumb_text.clone().into(),
1682                highlights: vec![],
1683            }],
1684            None,
1685        ))
1686    }
1687
1688    fn added_to_workspace(
1689        &mut self,
1690        workspace: &mut Workspace,
1691        _: &mut Window,
1692        cx: &mut Context<Self>,
1693    ) {
1694        if self.terminal().read(cx).task().is_none() {
1695            if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) {
1696                log::debug!(
1697                    "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}",
1698                );
1699                let db = TerminalDb::global(cx);
1700                let entity_id = cx.entity_id().as_u64();
1701                cx.background_spawn(async move {
1702                    db.update_workspace_id(new_id, old_id, entity_id).await
1703                })
1704                .detach();
1705            }
1706            self.workspace_id = workspace.database_id();
1707        }
1708    }
1709
1710    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
1711        f(*event)
1712    }
1713}
1714
1715impl SerializableItem for TerminalView {
1716    fn serialized_item_kind() -> &'static str {
1717        "Terminal"
1718    }
1719
1720    fn cleanup(
1721        workspace_id: WorkspaceId,
1722        alive_items: Vec<workspace::ItemId>,
1723        _window: &mut Window,
1724        cx: &mut App,
1725    ) -> Task<anyhow::Result<()>> {
1726        let db = TerminalDb::global(cx);
1727        delete_unloaded_items(alive_items, workspace_id, "terminals", &db, cx)
1728    }
1729
1730    fn serialize(
1731        &mut self,
1732        _workspace: &mut Workspace,
1733        item_id: workspace::ItemId,
1734        _closing: bool,
1735        _: &mut Window,
1736        cx: &mut Context<Self>,
1737    ) -> Option<Task<anyhow::Result<()>>> {
1738        let terminal = self.terminal().read(cx);
1739        if terminal.task().is_some() {
1740            return None;
1741        }
1742
1743        if !self.needs_serialize {
1744            return None;
1745        }
1746
1747        let workspace_id = self.workspace_id?;
1748        let cwd = terminal.working_directory();
1749        let custom_title = self.custom_title.clone();
1750        self.needs_serialize = false;
1751
1752        let db = TerminalDb::global(cx);
1753        Some(cx.background_spawn(async move {
1754            if let Some(cwd) = cwd {
1755                db.save_working_directory(item_id, workspace_id, cwd)
1756                    .await?;
1757            }
1758            db.save_custom_title(item_id, workspace_id, custom_title)
1759                .await?;
1760            Ok(())
1761        }))
1762    }
1763
1764    fn should_serialize(&self, _: &Self::Event) -> bool {
1765        self.needs_serialize
1766    }
1767
1768    fn deserialize(
1769        project: Entity<Project>,
1770        workspace: WeakEntity<Workspace>,
1771        workspace_id: WorkspaceId,
1772        item_id: workspace::ItemId,
1773        window: &mut Window,
1774        cx: &mut App,
1775    ) -> Task<anyhow::Result<Entity<Self>>> {
1776        window.spawn(cx, async move |cx| {
1777            let (cwd, custom_title) = cx
1778                .update(|_window, cx| {
1779                    let db = TerminalDb::global(cx);
1780                    let from_db = db
1781                        .get_working_directory(item_id, workspace_id)
1782                        .log_err()
1783                        .flatten();
1784                    let cwd = if from_db
1785                        .as_ref()
1786                        .is_some_and(|from_db| !from_db.as_os_str().is_empty())
1787                    {
1788                        from_db
1789                    } else {
1790                        workspace
1791                            .upgrade()
1792                            .and_then(|workspace| default_working_directory(workspace.read(cx), cx))
1793                    };
1794                    let custom_title = db
1795                        .get_custom_title(item_id, workspace_id)
1796                        .log_err()
1797                        .flatten()
1798                        .filter(|title| !title.trim().is_empty());
1799                    (cwd, custom_title)
1800                })
1801                .ok()
1802                .unwrap_or((None, None));
1803
1804            let terminal = project
1805                .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))
1806                .await?;
1807            cx.update(|window, cx| {
1808                cx.new(|cx| {
1809                    let mut view = TerminalView::new(
1810                        terminal,
1811                        workspace,
1812                        Some(workspace_id),
1813                        project.downgrade(),
1814                        window,
1815                        cx,
1816                    );
1817                    if custom_title.is_some() {
1818                        view.custom_title = custom_title;
1819                    }
1820                    view
1821                })
1822            })
1823        })
1824    }
1825}
1826
1827impl SearchableItem for TerminalView {
1828    type Match = RangeInclusive<AlacPoint>;
1829
1830    fn supported_options(&self) -> SearchOptions {
1831        SearchOptions {
1832            case: false,
1833            word: false,
1834            regex: true,
1835            replacement: false,
1836            selection: false,
1837            select_all: false,
1838            find_in_results: false,
1839        }
1840    }
1841
1842    /// Clear stored matches
1843    fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
1844        self.terminal().update(cx, |term, _| term.matches.clear())
1845    }
1846
1847    /// Store matches returned from find_matches somewhere for rendering
1848    fn update_matches(
1849        &mut self,
1850        matches: &[Self::Match],
1851        _active_match_index: Option<usize>,
1852        _token: SearchToken,
1853        _window: &mut Window,
1854        cx: &mut Context<Self>,
1855    ) {
1856        self.terminal()
1857            .update(cx, |term, _| term.matches = matches.to_vec())
1858    }
1859
1860    /// Returns the selection content to pre-load into this search
1861    fn query_suggestion(
1862        &mut self,
1863        _ignore_settings: bool,
1864        _window: &mut Window,
1865        cx: &mut Context<Self>,
1866    ) -> String {
1867        self.terminal()
1868            .read(cx)
1869            .last_content
1870            .selection_text
1871            .clone()
1872            .unwrap_or_default()
1873    }
1874
1875    /// Focus match at given index into the Vec of matches
1876    fn activate_match(
1877        &mut self,
1878        index: usize,
1879        _: &[Self::Match],
1880        _token: SearchToken,
1881        _window: &mut Window,
1882        cx: &mut Context<Self>,
1883    ) {
1884        self.terminal()
1885            .update(cx, |term, _| term.activate_match(index));
1886        cx.notify();
1887    }
1888
1889    /// Add selections for all matches given.
1890    fn select_matches(
1891        &mut self,
1892        matches: &[Self::Match],
1893        _token: SearchToken,
1894        _: &mut Window,
1895        cx: &mut Context<Self>,
1896    ) {
1897        self.terminal()
1898            .update(cx, |term, _| term.select_matches(matches));
1899        cx.notify();
1900    }
1901
1902    /// Get all of the matches for this query, should be done on the background
1903    fn find_matches(
1904        &mut self,
1905        query: Arc<SearchQuery>,
1906        _: &mut Window,
1907        cx: &mut Context<Self>,
1908    ) -> Task<Vec<Self::Match>> {
1909        if let Some(s) = regex_search_for_query(&query) {
1910            self.terminal()
1911                .update(cx, |term, cx| term.find_matches(s, cx))
1912        } else {
1913            Task::ready(vec![])
1914        }
1915    }
1916
1917    /// Reports back to the search toolbar what the active match should be (the selection)
1918    fn active_match_index(
1919        &mut self,
1920        direction: Direction,
1921        matches: &[Self::Match],
1922        _token: SearchToken,
1923        _: &mut Window,
1924        cx: &mut Context<Self>,
1925    ) -> Option<usize> {
1926        // Selection head might have a value if there's a selection that isn't
1927        // associated with a match. Therefore, if there are no matches, we should
1928        // report None, no matter the state of the terminal
1929
1930        if !matches.is_empty() {
1931            if let Some(selection_head) = self.terminal().read(cx).selection_head {
1932                // If selection head is contained in a match. Return that match
1933                match direction {
1934                    Direction::Prev => {
1935                        // If no selection before selection head, return the first match
1936                        Some(
1937                            matches
1938                                .iter()
1939                                .enumerate()
1940                                .rev()
1941                                .find(|(_, search_match)| {
1942                                    search_match.contains(&selection_head)
1943                                        || search_match.start() < &selection_head
1944                                })
1945                                .map(|(ix, _)| ix)
1946                                .unwrap_or(0),
1947                        )
1948                    }
1949                    Direction::Next => {
1950                        // If no selection after selection head, return the last match
1951                        Some(
1952                            matches
1953                                .iter()
1954                                .enumerate()
1955                                .find(|(_, search_match)| {
1956                                    search_match.contains(&selection_head)
1957                                        || search_match.start() > &selection_head
1958                                })
1959                                .map(|(ix, _)| ix)
1960                                .unwrap_or(matches.len().saturating_sub(1)),
1961                        )
1962                    }
1963                }
1964            } else {
1965                // Matches found but no active selection, return the first last one (closest to cursor)
1966                Some(matches.len().saturating_sub(1))
1967            }
1968        } else {
1969            None
1970        }
1971    }
1972    fn replace(
1973        &mut self,
1974        _: &Self::Match,
1975        _: &SearchQuery,
1976        _token: SearchToken,
1977        _window: &mut Window,
1978        _: &mut Context<Self>,
1979    ) {
1980        // Replacement is not supported in terminal view, so this is a no-op.
1981    }
1982}
1983
1984/// Gets the working directory for the given workspace, respecting the user's settings.
1985/// Falls back to home directory when no project directory is available.
1986///
1987/// For remote projects, local-only resolution (home dir fallback, shell expansion,
1988/// local `is_dir` checks) is skipped -- returning `None` lets the remote shell
1989/// open in the remote user's home directory by default.
1990pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
1991    let is_remote = workspace.project().read(cx).is_remote();
1992    let directory = match &TerminalSettings::get_global(cx).working_directory {
1993        WorkingDirectory::CurrentFileDirectory => workspace
1994            .project()
1995            .read(cx)
1996            .active_entry_directory(cx)
1997            .or_else(|| current_project_directory(workspace, cx)),
1998        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx),
1999        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
2000        WorkingDirectory::AlwaysHome => None,
2001        WorkingDirectory::Always { directory } if !is_remote => shellexpand::full(directory)
2002            .ok()
2003            .map(|dir| Path::new(&dir.to_string()).to_path_buf())
2004            .filter(|dir| dir.is_dir()),
2005        WorkingDirectory::Always { .. } => None,
2006    };
2007
2008    if is_remote {
2009        directory
2010    } else {
2011        directory.or_else(dirs::home_dir)
2012    }
2013}
2014
2015fn current_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
2016    workspace
2017        .project()
2018        .read(cx)
2019        .active_project_directory(cx)
2020        .as_deref()
2021        .map(Path::to_path_buf)
2022        .or_else(|| first_project_directory(workspace, cx))
2023}
2024
2025///Gets the first project's home directory, or the home directory
2026fn first_project_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
2027    let worktree = workspace.worktrees(cx).next()?.read(cx);
2028    let worktree_path = worktree.abs_path();
2029    if worktree.root_entry()?.is_dir() {
2030        Some(worktree_path.to_path_buf())
2031    } else {
2032        // If worktree is a file, return its parent directory
2033        worktree_path.parent().map(|p| p.to_path_buf())
2034    }
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039    use super::*;
2040    use gpui::TestAppContext;
2041    use project::{Entry, Project, ProjectPath, Worktree};
2042    use remote::RemoteClient;
2043    use std::path::{Path, PathBuf};
2044    use util::paths::PathStyle;
2045    use util::rel_path::RelPath;
2046    use workspace::item::test::{TestItem, TestProjectItem};
2047    use workspace::{AppState, MultiWorkspace, SelectedEntry};
2048
2049    fn expected_drop_text(paths: &[PathBuf]) -> String {
2050        let mut text = String::new();
2051        for path in paths {
2052            text.push(' ');
2053            text.push_str(&format!("{path:?}"));
2054        }
2055        text.push(' ');
2056        text
2057    }
2058
2059    fn assert_drop_writes_to_terminal(
2060        pane: &Entity<Pane>,
2061        terminal_view_index: usize,
2062        terminal: &Entity<Terminal>,
2063        dropped: &dyn Any,
2064        expected_text: &str,
2065        window: &mut Window,
2066        cx: &mut Context<MultiWorkspace>,
2067    ) {
2068        let _ = terminal.update(cx, |terminal, _| terminal.take_input_log());
2069
2070        let handled = pane.update(cx, |pane, cx| {
2071            pane.item_for_index(terminal_view_index)
2072                .unwrap()
2073                .handle_drop(pane, dropped, window, cx)
2074        });
2075        assert!(handled, "handle_drop should return true for {:?}", dropped);
2076
2077        let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log());
2078        assert_eq!(input_log.len(), 1, "expected exactly one write to terminal");
2079        let written =
2080            String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8");
2081        assert_eq!(written, expected_text);
2082    }
2083
2084    // Working directory calculation tests
2085
2086    // No Worktrees in project -> home_dir()
2087    #[gpui::test]
2088    async fn no_worktree(cx: &mut TestAppContext) {
2089        let (project, workspace) = init_test(cx).await;
2090        cx.read(|cx| {
2091            let workspace = workspace.read(cx);
2092            let active_entry = project.read(cx).active_entry();
2093
2094            //Make sure environment is as expected
2095            assert!(active_entry.is_none());
2096            assert!(workspace.worktrees(cx).next().is_none());
2097
2098            let res = default_working_directory(workspace, cx);
2099            assert_eq!(res, dirs::home_dir());
2100            let res = first_project_directory(workspace, cx);
2101            assert_eq!(res, None);
2102        });
2103    }
2104
2105    #[gpui::test]
2106    async fn remote_no_worktree_uses_remote_shell_default_cwd(
2107        cx: &mut TestAppContext,
2108        server_cx: &mut TestAppContext,
2109    ) {
2110        let (_project, workspace) = init_remote_test(cx, server_cx).await;
2111
2112        cx.read(|cx| {
2113            let workspace = workspace.read(cx);
2114
2115            assert!(workspace.project().read(cx).is_remote());
2116            assert!(workspace.worktrees(cx).next().is_none());
2117            assert_eq!(default_working_directory(workspace, cx), None);
2118        });
2119    }
2120
2121    // No active entry, but a worktree, worktree is a file -> parent directory
2122    #[gpui::test]
2123    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
2124        let (project, workspace) = init_test(cx).await;
2125
2126        create_file_wt(project.clone(), "/root.txt", cx).await;
2127        cx.read(|cx| {
2128            let workspace = workspace.read(cx);
2129            let active_entry = project.read(cx).active_entry();
2130
2131            //Make sure environment is as expected
2132            assert!(active_entry.is_none());
2133            assert!(workspace.worktrees(cx).next().is_some());
2134
2135            let res = default_working_directory(workspace, cx);
2136            assert_eq!(res, Some(Path::new("/").to_path_buf()));
2137            let res = first_project_directory(workspace, cx);
2138            assert_eq!(res, Some(Path::new("/").to_path_buf()));
2139        });
2140    }
2141
2142    // No active entry, but a worktree, worktree is a folder -> worktree_folder
2143    #[gpui::test]
2144    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2145        let (project, workspace) = init_test(cx).await;
2146
2147        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2148        cx.update(|cx| {
2149            let workspace = workspace.read(cx);
2150            let active_entry = project.read(cx).active_entry();
2151
2152            assert!(active_entry.is_none());
2153            assert!(workspace.worktrees(cx).next().is_some());
2154
2155            let res = default_working_directory(workspace, cx);
2156            assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2157            let res = first_project_directory(workspace, cx);
2158            assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2159        });
2160    }
2161
2162    // Active entry with a work tree, worktree is a file -> worktree_folder()
2163    #[gpui::test]
2164    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
2165        let (project, workspace) = init_test(cx).await;
2166
2167        let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2168        let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
2169        insert_active_entry_for(wt2, entry2, project.clone(), cx);
2170
2171        cx.update(|cx| {
2172            let workspace = workspace.read(cx);
2173            let active_entry = project.read(cx).active_entry();
2174
2175            assert!(active_entry.is_some());
2176
2177            let res = default_working_directory(workspace, cx);
2178            assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2179            let res = first_project_directory(workspace, cx);
2180            assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2181        });
2182    }
2183
2184    // Active entry, with a worktree, worktree is a folder -> worktree_folder
2185    #[gpui::test]
2186    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
2187        let (project, workspace) = init_test(cx).await;
2188
2189        let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
2190        let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
2191        insert_active_entry_for(wt2, entry2, project.clone(), cx);
2192
2193        cx.update(|cx| {
2194            let workspace = workspace.read(cx);
2195            let active_entry = project.read(cx).active_entry();
2196
2197            assert!(active_entry.is_some());
2198
2199            let res = default_working_directory(workspace, cx);
2200            assert_eq!(res, Some(Path::new("/root2/").to_path_buf()));
2201            let res = first_project_directory(workspace, cx);
2202            assert_eq!(res, Some(Path::new("/root1/").to_path_buf()));
2203        });
2204    }
2205
2206    // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory)
2207    #[gpui::test]
2208    async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) {
2209        let (project, _workspace) = init_test(cx).await;
2210
2211        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2212
2213        cx.update(|cx| {
2214            assert!(project.read(cx).active_entry().is_none());
2215
2216            let res = project.read(cx).active_entry_directory(cx);
2217            assert_eq!(res, None);
2218        });
2219    }
2220
2221    // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory)
2222    #[gpui::test]
2223    async fn active_entry_directory_active_file(cx: &mut TestAppContext) {
2224        let (project, _workspace) = init_test(cx).await;
2225
2226        let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2227        let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await;
2228        insert_active_entry_for(wt, entry, project.clone(), cx);
2229
2230        cx.update(|cx| {
2231            let res = project.read(cx).active_entry_directory(cx);
2232            assert_eq!(res, Some(Path::new("/root/src").to_path_buf()));
2233        });
2234    }
2235
2236    // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory)
2237    #[gpui::test]
2238    async fn active_entry_directory_active_dir(cx: &mut TestAppContext) {
2239        let (project, _workspace) = init_test(cx).await;
2240
2241        let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await;
2242        insert_active_entry_for(wt, entry, project.clone(), cx);
2243
2244        cx.update(|cx| {
2245            let res = project.read(cx).active_entry_directory(cx);
2246            assert_eq!(res, Some(Path::new("/root/").to_path_buf()));
2247        });
2248    }
2249
2250    /// Creates a worktree with 1 file: /root.txt
2251    pub async fn init_test(cx: &mut TestAppContext) -> (Entity<Project>, Entity<Workspace>) {
2252        let (project, workspace, _) = init_test_with_window(cx).await;
2253        (project, workspace)
2254    }
2255
2256    /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle.
2257    async fn init_test_with_window(
2258        cx: &mut TestAppContext,
2259    ) -> (
2260        Entity<Project>,
2261        Entity<Workspace>,
2262        gpui::WindowHandle<MultiWorkspace>,
2263    ) {
2264        let params = cx.update(AppState::test);
2265        cx.update(|cx| {
2266            theme_settings::init(theme::LoadThemes::JustBase, cx);
2267        });
2268
2269        let project = Project::test(params.fs.clone(), [], cx).await;
2270        let window_handle =
2271            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2272        let workspace = window_handle
2273            .read_with(cx, |mw, _| mw.workspace().clone())
2274            .unwrap();
2275
2276        (project, workspace, window_handle)
2277    }
2278
2279    async fn init_remote_test(
2280        cx: &mut TestAppContext,
2281        server_cx: &mut TestAppContext,
2282    ) -> (Entity<Project>, Entity<Workspace>) {
2283        cx.update(|cx| {
2284            release_channel::init(semver::Version::new(0, 0, 0), cx);
2285        });
2286        server_cx.update(|cx| {
2287            release_channel::init(semver::Version::new(0, 0, 0), cx);
2288        });
2289
2290        let params = cx.update(AppState::test);
2291        let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
2292        let ping_handler = server_cx.new(|_| ());
2293        server_session.add_request_handler::<rpc::proto::Ping, _, _, _>(
2294            ping_handler.downgrade(),
2295            |_entity, _envelope, _cx| async { Ok(rpc::proto::Ack {}) },
2296        );
2297        drop(connect_guard);
2298
2299        let remote_client = RemoteClient::connect_mock(opts, cx).await;
2300        let project = cx.update(|cx| {
2301            Project::remote(
2302                remote_client,
2303                params.client.clone(),
2304                params.node_runtime.clone(),
2305                params.user_store.clone(),
2306                params.languages.clone(),
2307                params.fs.clone(),
2308                false,
2309                cx,
2310            )
2311        });
2312
2313        let window_handle = cx.add_window({
2314            let params = params.clone();
2315            let project_for_workspace = project.clone();
2316            move |window, cx| {
2317                window.activate_window();
2318                let workspace = cx.new(|cx| {
2319                    Workspace::new(
2320                        None,
2321                        project_for_workspace.clone(),
2322                        params.clone(),
2323                        window,
2324                        cx,
2325                    )
2326                });
2327                MultiWorkspace::new(workspace, window, cx)
2328            }
2329        });
2330        let workspace = window_handle
2331            .read_with(cx, |mw, _| mw.workspace().clone())
2332            .unwrap();
2333
2334        (project, workspace)
2335    }
2336
2337    /// Creates a file in the given worktree and returns its entry.
2338    async fn create_file_in_worktree(
2339        worktree: Entity<Worktree>,
2340        relative_path: impl AsRef<Path>,
2341        cx: &mut TestAppContext,
2342    ) -> Entry {
2343        cx.update(|cx| {
2344            worktree.update(cx, |worktree, cx| {
2345                worktree.create_entry(
2346                    RelPath::new(relative_path.as_ref(), PathStyle::local())
2347                        .unwrap()
2348                        .as_ref()
2349                        .into(),
2350                    false,
2351                    None,
2352                    cx,
2353                )
2354            })
2355        })
2356        .await
2357        .unwrap()
2358        .into_included()
2359        .unwrap()
2360    }
2361
2362    /// Creates a worktree with 1 folder: /root{suffix}/
2363    async fn create_folder_wt(
2364        project: Entity<Project>,
2365        path: impl AsRef<Path>,
2366        cx: &mut TestAppContext,
2367    ) -> (Entity<Worktree>, Entry) {
2368        create_wt(project, true, path, cx).await
2369    }
2370
2371    /// Creates a worktree with 1 file: /root{suffix}.txt
2372    async fn create_file_wt(
2373        project: Entity<Project>,
2374        path: impl AsRef<Path>,
2375        cx: &mut TestAppContext,
2376    ) -> (Entity<Worktree>, Entry) {
2377        create_wt(project, false, path, cx).await
2378    }
2379
2380    async fn create_wt(
2381        project: Entity<Project>,
2382        is_dir: bool,
2383        path: impl AsRef<Path>,
2384        cx: &mut TestAppContext,
2385    ) -> (Entity<Worktree>, Entry) {
2386        let (wt, _) = project
2387            .update(cx, |project, cx| {
2388                project.find_or_create_worktree(path, true, cx)
2389            })
2390            .await
2391            .unwrap();
2392
2393        let entry = cx
2394            .update(|cx| {
2395                wt.update(cx, |wt, cx| {
2396                    wt.create_entry(RelPath::empty().into(), is_dir, None, cx)
2397                })
2398            })
2399            .await
2400            .unwrap()
2401            .into_included()
2402            .unwrap();
2403
2404        (wt, entry)
2405    }
2406
2407    pub fn insert_active_entry_for(
2408        wt: Entity<Worktree>,
2409        entry: Entry,
2410        project: Entity<Project>,
2411        cx: &mut TestAppContext,
2412    ) {
2413        cx.update(|cx| {
2414            let p = ProjectPath {
2415                worktree_id: wt.read(cx).id(),
2416                path: entry.path,
2417            };
2418            project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
2419        });
2420    }
2421
2422    // Terminal drag/drop test
2423
2424    #[gpui::test]
2425    async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) {
2426        let (project, _workspace, window_handle) = init_test_with_window(cx).await;
2427
2428        let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await;
2429        let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await;
2430        let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await;
2431
2432        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2433        let first_path = project
2434            .read_with(cx, |project, cx| {
2435                project.absolute_path(
2436                    &ProjectPath {
2437                        worktree_id,
2438                        path: first_entry.path.clone(),
2439                    },
2440                    cx,
2441                )
2442            })
2443            .unwrap();
2444        let second_path = project
2445            .read_with(cx, |project, cx| {
2446                project.absolute_path(
2447                    &ProjectPath {
2448                        worktree_id,
2449                        path: second_entry.path.clone(),
2450                    },
2451                    cx,
2452                )
2453            })
2454            .unwrap();
2455
2456        let (active_pane, terminal, terminal_view, tab_item) = window_handle
2457            .update(cx, |multi_workspace, window, cx| {
2458                let workspace = multi_workspace.workspace().clone();
2459                let active_pane = workspace.read(cx).active_pane().clone();
2460
2461                let terminal = cx.new(|cx| {
2462                    terminal::TerminalBuilder::new_display_only(
2463                        CursorShape::default(),
2464                        terminal::terminal_settings::AlternateScroll::On,
2465                        None,
2466                        0,
2467                        cx.background_executor(),
2468                        PathStyle::local(),
2469                    )
2470                    .unwrap()
2471                    .subscribe(cx)
2472                });
2473                let terminal_view = cx.new(|cx| {
2474                    TerminalView::new(
2475                        terminal.clone(),
2476                        workspace.downgrade(),
2477                        None,
2478                        project.downgrade(),
2479                        window,
2480                        cx,
2481                    )
2482                });
2483
2484                active_pane.update(cx, |pane, cx| {
2485                    pane.add_item(
2486                        Box::new(terminal_view.clone()),
2487                        true,
2488                        false,
2489                        None,
2490                        window,
2491                        cx,
2492                    );
2493                });
2494
2495                let tab_project_item = cx.new(|_| TestProjectItem {
2496                    entry_id: Some(second_entry.id),
2497                    project_path: Some(ProjectPath {
2498                        worktree_id,
2499                        path: second_entry.path.clone(),
2500                    }),
2501                    is_dirty: false,
2502                });
2503                let tab_item =
2504                    cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item]));
2505                active_pane.update(cx, |pane, cx| {
2506                    pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx);
2507                });
2508
2509                (active_pane, terminal, terminal_view, tab_item)
2510            })
2511            .unwrap();
2512
2513        cx.run_until_parked();
2514
2515        window_handle
2516            .update(cx, |multi_workspace, window, cx| {
2517                let workspace = multi_workspace.workspace().clone();
2518                let terminal_view_index =
2519                    active_pane.read(cx).index_for_item(&terminal_view).unwrap();
2520                let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap();
2521
2522                assert!(
2523                    workspace.read(cx).pane_for(&terminal_view).is_some(),
2524                    "terminal view not registered with workspace after run_until_parked"
2525                );
2526
2527                // Dragging an external file should write its path to the terminal
2528                let external_paths = ExternalPaths(vec![first_path.clone()].into());
2529                assert_drop_writes_to_terminal(
2530                    &active_pane,
2531                    terminal_view_index,
2532                    &terminal,
2533                    &external_paths,
2534                    &expected_drop_text(std::slice::from_ref(&first_path)),
2535                    window,
2536                    cx,
2537                );
2538
2539                // Dragging a tab should write the path of the tab's item to the terminal
2540                let dragged_tab = DraggedTab {
2541                    pane: active_pane.clone(),
2542                    item: Box::new(tab_item.clone()),
2543                    ix: dragged_tab_index,
2544                    detail: 0,
2545                    is_active: false,
2546                };
2547                assert_drop_writes_to_terminal(
2548                    &active_pane,
2549                    terminal_view_index,
2550                    &terminal,
2551                    &dragged_tab,
2552                    &expected_drop_text(std::slice::from_ref(&second_path)),
2553                    window,
2554                    cx,
2555                );
2556
2557                // Dragging multiple selections should write both paths to the terminal
2558                let dragged_selection = DraggedSelection {
2559                    active_selection: SelectedEntry {
2560                        worktree_id,
2561                        entry_id: first_entry.id,
2562                    },
2563                    marked_selections: Arc::from([
2564                        SelectedEntry {
2565                            worktree_id,
2566                            entry_id: first_entry.id,
2567                        },
2568                        SelectedEntry {
2569                            worktree_id,
2570                            entry_id: second_entry.id,
2571                        },
2572                    ]),
2573                };
2574                assert_drop_writes_to_terminal(
2575                    &active_pane,
2576                    terminal_view_index,
2577                    &terminal,
2578                    &dragged_selection,
2579                    &expected_drop_text(&[first_path.clone(), second_path.clone()]),
2580                    window,
2581                    cx,
2582                );
2583
2584                // Dropping a project entry should write the entry's path to the terminal
2585                let dropped_entry_id = first_entry.id;
2586                assert_drop_writes_to_terminal(
2587                    &active_pane,
2588                    terminal_view_index,
2589                    &terminal,
2590                    &dropped_entry_id,
2591                    &expected_drop_text(&[first_path]),
2592                    window,
2593                    cx,
2594                );
2595            })
2596            .unwrap();
2597    }
2598
2599    // Terminal rename tests
2600
2601    #[gpui::test]
2602    async fn test_custom_title_initially_none(cx: &mut TestAppContext) {
2603        cx.executor().allow_parking();
2604
2605        let (project, workspace) = init_test(cx).await;
2606
2607        let terminal = project
2608            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2609            .await
2610            .unwrap();
2611
2612        let terminal_view = cx
2613            .add_window(|window, cx| {
2614                TerminalView::new(
2615                    terminal,
2616                    workspace.downgrade(),
2617                    None,
2618                    project.downgrade(),
2619                    window,
2620                    cx,
2621                )
2622            })
2623            .root(cx)
2624            .unwrap();
2625
2626        terminal_view.update(cx, |view, _cx| {
2627            assert!(view.custom_title().is_none());
2628        });
2629    }
2630
2631    #[gpui::test]
2632    async fn test_set_custom_title(cx: &mut TestAppContext) {
2633        cx.executor().allow_parking();
2634
2635        let (project, workspace) = init_test(cx).await;
2636
2637        let terminal = project
2638            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2639            .await
2640            .unwrap();
2641
2642        let terminal_view = cx
2643            .add_window(|window, cx| {
2644                TerminalView::new(
2645                    terminal,
2646                    workspace.downgrade(),
2647                    None,
2648                    project.downgrade(),
2649                    window,
2650                    cx,
2651                )
2652            })
2653            .root(cx)
2654            .unwrap();
2655
2656        terminal_view.update(cx, |view, cx| {
2657            view.set_custom_title(Some("frontend".to_string()), cx);
2658            assert_eq!(view.custom_title(), Some("frontend"));
2659        });
2660    }
2661
2662    #[gpui::test]
2663    async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) {
2664        cx.executor().allow_parking();
2665
2666        let (project, workspace) = init_test(cx).await;
2667
2668        let terminal = project
2669            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2670            .await
2671            .unwrap();
2672
2673        let terminal_view = cx
2674            .add_window(|window, cx| {
2675                TerminalView::new(
2676                    terminal,
2677                    workspace.downgrade(),
2678                    None,
2679                    project.downgrade(),
2680                    window,
2681                    cx,
2682                )
2683            })
2684            .root(cx)
2685            .unwrap();
2686
2687        terminal_view.update(cx, |view, cx| {
2688            view.set_custom_title(Some("test".to_string()), cx);
2689            assert_eq!(view.custom_title(), Some("test"));
2690
2691            view.set_custom_title(Some("".to_string()), cx);
2692            assert!(view.custom_title().is_none());
2693
2694            view.set_custom_title(Some("  ".to_string()), cx);
2695            assert!(view.custom_title().is_none());
2696        });
2697    }
2698
2699    #[gpui::test]
2700    async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) {
2701        cx.executor().allow_parking();
2702
2703        let (project, workspace) = init_test(cx).await;
2704
2705        let terminal = project
2706            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2707            .await
2708            .unwrap();
2709
2710        let terminal_view = cx
2711            .add_window(|window, cx| {
2712                TerminalView::new(
2713                    terminal,
2714                    workspace.downgrade(),
2715                    None,
2716                    project.downgrade(),
2717                    window,
2718                    cx,
2719                )
2720            })
2721            .root(cx)
2722            .unwrap();
2723
2724        terminal_view.update(cx, |view, cx| {
2725            view.needs_serialize = false;
2726            view.set_custom_title(Some("new_label".to_string()), cx);
2727            assert!(view.needs_serialize);
2728        });
2729    }
2730
2731    #[gpui::test]
2732    async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) {
2733        cx.executor().allow_parking();
2734
2735        let (project, workspace) = init_test(cx).await;
2736
2737        let terminal = project
2738            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2739            .await
2740            .unwrap();
2741
2742        let terminal_view = cx
2743            .add_window(|window, cx| {
2744                TerminalView::new(
2745                    terminal,
2746                    workspace.downgrade(),
2747                    None,
2748                    project.downgrade(),
2749                    window,
2750                    cx,
2751                )
2752            })
2753            .root(cx)
2754            .unwrap();
2755
2756        terminal_view.update(cx, |view, cx| {
2757            view.set_custom_title(Some("my-server".to_string()), cx);
2758            let text = view.tab_content_text(0, cx);
2759            assert_eq!(text.as_ref(), "my-server");
2760        });
2761
2762        terminal_view.update(cx, |view, cx| {
2763            view.set_custom_title(None, cx);
2764            let text = view.tab_content_text(0, cx);
2765            assert_ne!(text.as_ref(), "my-server");
2766        });
2767    }
2768
2769    #[gpui::test]
2770    async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty(
2771        cx: &mut TestAppContext,
2772    ) {
2773        cx.executor().allow_parking();
2774
2775        let (project, workspace) = init_test(cx).await;
2776
2777        let terminal = project
2778            .update(cx, |project, cx| project.create_terminal_shell(None, cx))
2779            .await
2780            .unwrap();
2781
2782        let terminal_view = cx
2783            .add_window(|window, cx| {
2784                TerminalView::new(
2785                    terminal,
2786                    workspace.downgrade(),
2787                    None,
2788                    project.downgrade(),
2789                    window,
2790                    cx,
2791                )
2792            })
2793            .root(cx)
2794            .unwrap();
2795
2796        terminal_view.update(cx, |view, cx| {
2797            view.custom_title = Some("".to_string());
2798            let text = view.tab_content_text(0, cx);
2799            assert!(
2800                !text.is_empty(),
2801                "Tab should show terminal title, not empty string; got: '{}'",
2802                text
2803            );
2804        });
2805
2806        terminal_view.update(cx, |view, cx| {
2807            view.custom_title = Some("   ".to_string());
2808            let text = view.tab_content_text(0, cx);
2809            assert!(
2810                !text.is_empty() && text.as_ref() != "   ",
2811                "Tab should show terminal title, not whitespace; got: '{}'",
2812                text
2813            );
2814        });
2815    }
2816}