terminal_view.rs

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