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