terminal_view.rs

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