Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:09:50.252Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

terminal_view.rs

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