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