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