terminal_view.rs

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