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