mod persistence; pub mod terminal_element; pub mod terminal_panel; mod terminal_path_like_target; pub mod terminal_scrollbar; use editor::{ Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager, ui_scrollbar_settings_from_raw, }; use gpui::{ Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, TaskExt, WeakEntity, actions, anchored, deferred, div, }; use itertools::Itertools; use menu; use persistence::TerminalDb; use project::{Project, ProjectEntryId, search::SearchQuery}; use schemars::JsonSchema; use serde::Deserialize; use settings::{Settings, SettingsStore, TerminalBell, TerminalBlink, WorkingDirectory}; use std::{ any::Any, cmp, ops::{Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, time::Duration, }; use task::TaskId; use terminal::{ Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, PasteText, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskState, TaskStatus, Terminal, TerminalBounds, ToggleViMode, alacritty_terminal::{ index::Point as AlacPoint, term::{TermMode, point_to_viewport, search::RegexSearch}, }, terminal_settings::{CursorShape, TerminalSettings}, }; use terminal_element::TerminalElement; use terminal_panel::TerminalPanel; use terminal_path_like_target::{hover_path_like_target, open_path_like_target}; use terminal_scrollbar::TerminalScrollHandle; use ui::{ ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, scrollbars::{self, ScrollbarVisibility}, }; use util::ResultExt; use workspace::{ CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, item::{ HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, }, register_serializable_item, searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }, }; use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist}; struct ImeState { marked_text: String, } const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); /// Event to transmit the scroll from the element to the view #[derive(Clone, Debug, PartialEq)] pub struct ScrollTerminal(pub i32); /// Sends the specified text directly to the terminal. #[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] #[action(namespace = terminal)] pub struct SendText(String); /// Sends a keystroke sequence to the terminal. #[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] #[action(namespace = terminal)] pub struct SendKeystroke(String); actions!( terminal, [ /// Reruns the last executed task in the terminal. RerunTask, ] ); /// Renames the terminal tab. #[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Action)] #[action(namespace = terminal)] pub struct RenameTerminal; pub fn init(cx: &mut App) { terminal_panel::init(cx); register_serializable_item::(cx); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { workspace.register_action(TerminalView::deploy); }) .detach(); } pub struct BlockProperties { pub height: u8, pub render: Box AnyElement>, } pub struct BlockContext<'a, 'b> { pub window: &'a mut Window, pub context: &'b mut App, pub dimensions: TerminalBounds, } ///A terminal view, maintains the PTY's file handles and communicates with the terminal pub struct TerminalView { terminal: Entity, workspace: WeakEntity, project: WeakEntity, focus_handle: FocusHandle, //Currently using iTerm bell, show bell emoji in tab until input is received has_bell: bool, context_menu: Option<(Entity, Point, Subscription)>, cursor_shape: CursorShape, blink_manager: Entity, mode: TerminalMode, blinking_terminal_enabled: bool, needs_serialize: bool, custom_title: Option, hover: Option, hover_tooltip_update: Task<()>, workspace_id: Option, show_breadcrumbs: bool, block_below_cursor: Option>, scroll_top: Pixels, scroll_handle: TerminalScrollHandle, ime_state: Option, self_handle: WeakEntity, rename_editor: Option>, rename_editor_subscription: Option, _subscriptions: Vec, _terminal_subscriptions: Vec, } #[derive(Default, Clone)] pub enum TerminalMode { #[default] Standalone, Embedded { max_lines_when_unfocused: Option, }, } #[derive(Clone)] pub enum ContentMode { Scrollable, Inline { displayed_lines: usize, total_lines: usize, }, } impl ContentMode { pub fn is_limited(&self) -> bool { match self { ContentMode::Scrollable => false, ContentMode::Inline { displayed_lines, total_lines, } => displayed_lines < total_lines, } } pub fn is_scrollable(&self) -> bool { matches!(self, ContentMode::Scrollable) } } #[derive(Debug)] #[cfg_attr(test, derive(Clone, Eq, PartialEq))] struct HoverTarget { tooltip: String, hovered_word: HoveredWord, } impl EventEmitter for TerminalView {} impl EventEmitter for TerminalView {} impl EventEmitter for TerminalView {} impl Focusable for TerminalView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl TerminalView { ///Create a new Terminal in the current working directory or the user's home directory pub fn deploy( workspace: &mut Workspace, action: &NewCenterTerminal, window: &mut Window, cx: &mut Context, ) { let local = action.local; let working_directory = default_working_directory(workspace, cx); TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| { if local { project.create_local_terminal(cx) } else { project.create_terminal_shell(working_directory, cx) } }) .detach_and_log_err(cx); } pub fn new( terminal: Entity, workspace: WeakEntity, workspace_id: Option, project: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { let workspace_handle = workspace.clone(); let terminal_subscriptions = subscribe_for_terminal_events(&terminal, workspace, window, cx); let focus_handle = cx.focus_handle(); let focus_in = cx.on_focus_in(&focus_handle, window, |terminal_view, window, cx| { terminal_view.focus_in(window, cx); }); let focus_out = cx.on_focus_out( &focus_handle, window, |terminal_view, _event, window, cx| { terminal_view.focus_out(window, cx); }, ); let cursor_shape = TerminalSettings::get_global(cx).cursor_shape; let scroll_handle = TerminalScrollHandle::new(terminal.read(cx)); let blink_manager = cx.new(|cx| { BlinkManager::new( CURSOR_BLINK_INTERVAL, |cx| { !matches!( TerminalSettings::get_global(cx).blinking, TerminalBlink::Off ) }, cx, ) }); let subscriptions = vec![ focus_in, focus_out, cx.observe(&blink_manager, |_, _, cx| cx.notify()), cx.observe_global::(Self::settings_changed), ]; Self { terminal, workspace: workspace_handle, project, has_bell: false, focus_handle, context_menu: None, cursor_shape, blink_manager, blinking_terminal_enabled: false, hover: None, hover_tooltip_update: Task::ready(()), mode: TerminalMode::Standalone, workspace_id, show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs, block_below_cursor: None, scroll_top: Pixels::ZERO, scroll_handle, needs_serialize: false, custom_title: None, ime_state: None, self_handle: cx.entity().downgrade(), rename_editor: None, rename_editor_subscription: None, _subscriptions: subscriptions, _terminal_subscriptions: terminal_subscriptions, } } /// Enable 'embedded' mode where the terminal displays the full content with an optional limit of lines. pub fn set_embedded_mode( &mut self, max_lines_when_unfocused: Option, cx: &mut Context, ) { self.mode = TerminalMode::Embedded { max_lines_when_unfocused, }; cx.notify(); } const MAX_EMBEDDED_LINES: usize = 1_000; /// Returns the current `ContentMode` depending on the set `TerminalMode` and the current number of lines /// /// Note: Even in embedded mode, the terminal will fallback to scrollable when its content exceeds `MAX_EMBEDDED_LINES` pub fn content_mode(&self, window: &Window, cx: &App) -> ContentMode { match &self.mode { TerminalMode::Standalone => ContentMode::Scrollable, TerminalMode::Embedded { max_lines_when_unfocused, } => { let total_lines = self.terminal.read(cx).total_lines(); if total_lines > Self::MAX_EMBEDDED_LINES { ContentMode::Scrollable } else { let mut displayed_lines = total_lines; if !self.focus_handle.is_focused(window) && let Some(max_lines) = max_lines_when_unfocused { displayed_lines = displayed_lines.min(*max_lines) } ContentMode::Inline { displayed_lines, total_lines, } } } } } /// Sets the marked (pre-edit) text from the IME. pub(crate) fn set_marked_text(&mut self, text: String, cx: &mut Context) { if text.is_empty() { return self.clear_marked_text(cx); } self.ime_state = Some(ImeState { marked_text: text }); cx.notify(); } /// Gets the current marked range (UTF-16). pub(crate) fn marked_text_range(&self) -> Option> { self.ime_state .as_ref() .map(|state| 0..state.marked_text.encode_utf16().count()) } /// Clears the marked (pre-edit) text state. pub(crate) fn clear_marked_text(&mut self, cx: &mut Context) { if self.ime_state.is_some() { self.ime_state = None; cx.notify(); } } /// Commits (sends) the given text to the PTY. Called by InputHandler::replace_text_in_range. pub(crate) fn commit_text(&mut self, text: &str, cx: &mut Context) { if !text.is_empty() { self.terminal.update(cx, |term, _| { term.input(text.to_string().into_bytes()); }); } } pub(crate) fn terminal_bounds(&self, cx: &App) -> TerminalBounds { self.terminal.read(cx).last_content().terminal_bounds } pub fn entity(&self) -> &Entity { &self.terminal } pub fn has_bell(&self) -> bool { self.has_bell } pub fn custom_title(&self) -> Option<&str> { self.custom_title.as_deref() } pub fn set_custom_title(&mut self, label: Option, cx: &mut Context) { let label = label.filter(|l| !l.trim().is_empty()); if self.custom_title != label { self.custom_title = label; self.needs_serialize = true; cx.emit(ItemEvent::UpdateTab); cx.notify(); } } pub fn is_renaming(&self) -> bool { self.rename_editor.is_some() } pub fn rename_editor_is_focused(&self, window: &Window, cx: &App) -> bool { self.rename_editor .as_ref() .is_some_and(|editor| editor.focus_handle(cx).is_focused(window)) } fn finish_renaming(&mut self, save: bool, window: &mut Window, cx: &mut Context) { let Some(editor) = self.rename_editor.take() else { return; }; self.rename_editor_subscription = None; if save { let new_label = editor.read(cx).text(cx).trim().to_string(); let label = if new_label.is_empty() { None } else { // Only set custom_title if the text differs from the terminal's dynamic title. // This prevents subtle layout changes when clicking away without making changes. let terminal_title = self.terminal.read(cx).title(true); if new_label == terminal_title { None } else { Some(new_label) } }; self.set_custom_title(label, cx); } cx.notify(); self.focus_handle.focus(window, cx); } pub fn rename_terminal( &mut self, _: &RenameTerminal, window: &mut Window, cx: &mut Context, ) { if self.terminal.read(cx).task().is_some() { return; } let current_label = self .custom_title .clone() .unwrap_or_else(|| self.terminal.read(cx).title(true)); let rename_editor = cx.new(|cx| Editor::single_line(window, cx)); let rename_editor_subscription = cx.subscribe_in(&rename_editor, window, { let rename_editor = rename_editor.clone(); move |_this, _, event, window, cx| { if let editor::EditorEvent::Blurred = event { // Defer to let focus settle (avoids canceling during double-click). let rename_editor = rename_editor.clone(); cx.defer_in(window, move |this, window, cx| { let still_current = this .rename_editor .as_ref() .is_some_and(|current| current == &rename_editor); if still_current && !rename_editor.focus_handle(cx).is_focused(window) { this.finish_renaming(false, window, cx); } }); } } }); self.rename_editor = Some(rename_editor.clone()); self.rename_editor_subscription = Some(rename_editor_subscription); rename_editor.update(cx, |editor, cx| { editor.set_text(current_label, window, cx); editor.select_all(&SelectAll, window, cx); editor.focus_handle(cx).focus(window, cx); }); cx.notify(); } pub fn clear_bell(&mut self, cx: &mut Context) { self.has_bell = false; cx.emit(Event::Wakeup); } pub fn deploy_context_menu( &mut self, position: Point, has_selection: bool, window: &mut Window, cx: &mut Context, ) { let assistant_enabled = self .workspace .upgrade() .and_then(|workspace| workspace.read(cx).panel::(cx)) .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled()); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()) .action("New Terminal", Box::new(NewTerminal::default())) .action( "New Center Terminal", Box::new(NewCenterTerminal::default()), ) .separator() .action("Copy", Box::new(Copy)) .action("Paste", Box::new(Paste)) .action("Paste Text", Box::new(PasteText)) .action("Select All", Box::new(SelectAll)) .action("Clear", Box::new(Clear)) .when(assistant_enabled, |menu| { menu.separator() .action("Inline Assist", Box::new(InlineAssist::default())) .when(has_selection, |menu| { menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) }) }) .separator() .action( "Close Terminal Tab", Box::new(CloseActiveItem { save_intent: None, close_pinned: true, }), ) }); window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, |this, _, _: &DismissEvent, window, cx| { if this.context_menu.as_ref().is_some_and(|context_menu| { context_menu.0.focus_handle(cx).contains_focused(window, cx) }) { cx.focus_self(window); } this.context_menu.take(); cx.notify(); }, ); self.context_menu = Some((context_menu, position, subscription)); } fn settings_changed(&mut self, cx: &mut Context) { let settings = TerminalSettings::get_global(cx); let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs; self.show_breadcrumbs = settings.toolbar.breadcrumbs; let should_blink = match settings.blinking { TerminalBlink::Off => false, TerminalBlink::On => true, TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, }; let new_cursor_shape = settings.cursor_shape; let old_cursor_shape = self.cursor_shape; if old_cursor_shape != new_cursor_shape { self.cursor_shape = new_cursor_shape; self.terminal.update(cx, |term, _| { term.set_cursor_shape(self.cursor_shape); }); } self.blink_manager.update( cx, if should_blink { BlinkManager::enable } else { BlinkManager::disable }, ); if breadcrumb_visibility_changed { cx.emit(ItemEvent::UpdateBreadcrumbs); } cx.notify(); } fn show_character_palette( &mut self, _: &ShowCharacterPalette, window: &mut Window, cx: &mut Context, ) { if self .terminal .read(cx) .last_content .mode .contains(TermMode::ALT_SCREEN) { self.terminal.update(cx, |term, cx| { term.try_keystroke( &Keystroke::parse("ctrl-cmd-space").unwrap(), TerminalSettings::get_global(cx).option_as_meta, ) }); } else { window.show_character_palette(); } } fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.select_all()); cx.notify(); } fn rerun_task(&mut self, _: &RerunTask, window: &mut Window, cx: &mut Context) { let task = self .terminal .read(cx) .task() .map(|task| terminal_rerun_override(&task.spawned_task.id)) .unwrap_or_default(); window.dispatch_action(Box::new(task), cx); } fn clear(&mut self, _: &Clear, _: &mut Window, cx: &mut Context) { self.scroll_top = px(0.); self.terminal.update(cx, |term, _| term.clear()); cx.notify(); } fn max_scroll_top(&self, cx: &App) -> Pixels { let terminal = self.terminal.read(cx); let Some(block) = self.block_below_cursor.as_ref() else { return Pixels::ZERO; }; let line_height = terminal.last_content().terminal_bounds.line_height; let viewport_lines = terminal.viewport_lines(); let cursor = point_to_viewport( terminal.last_content.display_offset, terminal.last_content.cursor.point, ) .unwrap_or_default(); let max_scroll_top_in_lines = (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1)); max_scroll_top_in_lines as f32 * line_height } fn scroll_wheel(&mut self, event: &ScrollWheelEvent, cx: &mut Context) { let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { let line_height = terminal_content.terminal_bounds.line_height; let y_delta = event.delta.pixel_delta(line_height).y; if y_delta < Pixels::ZERO || self.scroll_top > Pixels::ZERO { self.scroll_top = cmp::max( Pixels::ZERO, cmp::min(self.scroll_top - y_delta, self.max_scroll_top(cx)), ); cx.notify(); return; } } self.terminal.update(cx, |term, cx| { term.scroll_wheel( event, TerminalSettings::get_global(cx).scroll_multiplier.max(0.01), ) }); } fn scroll_line_up(&mut self, _: &ScrollLineUp, _: &mut Window, cx: &mut Context) { let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 && self.scroll_top > Pixels::ZERO { let line_height = terminal_content.terminal_bounds.line_height; self.scroll_top = cmp::max(self.scroll_top - line_height, Pixels::ZERO); return; } self.terminal.update(cx, |term, _| term.scroll_line_up()); cx.notify(); } fn scroll_line_down(&mut self, _: &ScrollLineDown, _: &mut Window, cx: &mut Context) { let terminal_content = self.terminal.read(cx).last_content(); if self.block_below_cursor.is_some() && terminal_content.display_offset == 0 { let max_scroll_top = self.max_scroll_top(cx); if self.scroll_top < max_scroll_top { let line_height = terminal_content.terminal_bounds.line_height; self.scroll_top = cmp::min(self.scroll_top + line_height, max_scroll_top); } return; } self.terminal.update(cx, |term, _| term.scroll_line_down()); cx.notify(); } fn scroll_page_up(&mut self, _: &ScrollPageUp, _: &mut Window, cx: &mut Context) { if self.scroll_top == Pixels::ZERO { self.terminal.update(cx, |term, _| term.scroll_page_up()); } else { let line_height = self .terminal .read(cx) .last_content .terminal_bounds .line_height(); let visible_block_lines = (self.scroll_top / line_height) as usize; let viewport_lines = self.terminal.read(cx).viewport_lines(); let visible_content_lines = viewport_lines - visible_block_lines; if visible_block_lines >= viewport_lines { self.scroll_top = ((visible_block_lines - viewport_lines) as f32) * line_height; } else { self.scroll_top = px(0.); self.terminal .update(cx, |term, _| term.scroll_up_by(visible_content_lines)); } } cx.notify(); } fn scroll_page_down(&mut self, _: &ScrollPageDown, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.scroll_page_down()); let terminal = self.terminal.read(cx); if terminal.last_content().display_offset < terminal.viewport_lines() { self.scroll_top = self.max_scroll_top(cx); } cx.notify(); } fn scroll_to_top(&mut self, _: &ScrollToTop, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.scroll_to_top()); cx.notify(); } fn scroll_to_bottom(&mut self, _: &ScrollToBottom, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.scroll_to_bottom()); if self.block_below_cursor.is_some() { self.scroll_top = self.max_scroll_top(cx); } cx.notify(); } fn toggle_vi_mode(&mut self, _: &ToggleViMode, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.toggle_vi_mode()); cx.notify(); } pub fn should_show_cursor(&self, focused: bool, cx: &mut Context) -> bool { // Hide cursor when in embedded mode and not focused (read-only output like Agent panel) if let TerminalMode::Embedded { .. } = &self.mode { if !focused { return false; } } // For Standalone mode: always show cursor when not focused or in special modes if !focused || self .terminal .read(cx) .last_content .mode .contains(TermMode::ALT_SCREEN) { return true; } // When focused, check blinking settings and blink manager state match TerminalSettings::get_global(cx).blinking { TerminalBlink::Off => true, TerminalBlink::TerminalControlled => { !self.blinking_terminal_enabled || self.blink_manager.read(cx).visible() } TerminalBlink::On => self.blink_manager.read(cx).visible(), } } pub fn pause_cursor_blinking(&mut self, _window: &mut Window, cx: &mut Context) { self.blink_manager.update(cx, BlinkManager::pause_blinking); } pub fn terminal(&self) -> &Entity { &self.terminal } pub fn set_block_below_cursor( &mut self, block: BlockProperties, window: &mut Window, cx: &mut Context, ) { self.block_below_cursor = Some(Rc::new(block)); self.scroll_to_bottom(&ScrollToBottom, window, cx); cx.notify(); } pub fn clear_block_below_cursor(&mut self, cx: &mut Context) { self.block_below_cursor = None; self.scroll_top = Pixels::ZERO; cx.notify(); } ///Attempt to paste the clipboard into the terminal fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { self.terminal.update(cx, |term, _| term.copy(None)); cx.notify(); } ///Attempt to paste the clipboard into the terminal fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { let Some(clipboard) = cx.read_from_clipboard() else { return; }; match clipboard.entries().first() { Some(ClipboardEntry::Image(image)) if !image.bytes.is_empty() => { self.forward_ctrl_v(cx); } Some(ClipboardEntry::ExternalPaths(paths)) => { self.add_paths_to_terminal(paths.paths(), window, cx); } _ => { if let Some(text) = clipboard.text() { self.terminal .update(cx, |terminal, _cx| terminal.paste(&text)); } } } } ///Attempt to paste the clipboard text into the terminal fn paste_text(&mut self, _: &PasteText, _: &mut Window, cx: &mut Context) { let Some(clipboard) = cx.read_from_clipboard() else { return; }; if let Some(text) = clipboard.text() { self.terminal .update(cx, |terminal, _cx| terminal.paste(&text)); } } /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly /// and attach images using their native workflows. fn forward_ctrl_v(&self, cx: &mut Context) { self.terminal.update(cx, |term, _| { term.input(vec![0x16]); }); } fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) { let mut text = paths.iter().map(|path| format!(" {path:?}")).join(""); text.push(' '); window.focus(&self.focus_handle(cx), cx); self.terminal.update(cx, |terminal, _| { terminal.paste(&text); }); } fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { self.clear_bell(cx); self.blink_manager.update(cx, BlinkManager::pause_blinking); self.terminal.update(cx, |term, _| { term.input(text.0.to_string().into_bytes()); }); } fn send_keystroke(&mut self, text: &SendKeystroke, _: &mut Window, cx: &mut Context) { if let Some(keystroke) = Keystroke::parse(&text.0).log_err() { self.clear_bell(cx); self.blink_manager.update(cx, BlinkManager::pause_blinking); self.process_keystroke(&keystroke, cx); } } fn dispatch_context(&self, cx: &App) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("Terminal"); if self.terminal.read(cx).vi_mode_enabled() { dispatch_context.add("vi_mode"); } let mode = self.terminal.read(cx).last_content.mode; dispatch_context.set( "screen", if mode.contains(TermMode::ALT_SCREEN) { "alt" } else { "normal" }, ); if mode.contains(TermMode::APP_CURSOR) { dispatch_context.add("DECCKM"); } if mode.contains(TermMode::APP_KEYPAD) { dispatch_context.add("DECPAM"); } else { dispatch_context.add("DECPNM"); } if mode.contains(TermMode::SHOW_CURSOR) { dispatch_context.add("DECTCEM"); } if mode.contains(TermMode::LINE_WRAP) { dispatch_context.add("DECAWM"); } if mode.contains(TermMode::ORIGIN) { dispatch_context.add("DECOM"); } if mode.contains(TermMode::INSERT) { dispatch_context.add("IRM"); } //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html if mode.contains(TermMode::LINE_FEED_NEW_LINE) { dispatch_context.add("LNM"); } if mode.contains(TermMode::FOCUS_IN_OUT) { dispatch_context.add("report_focus"); } if mode.contains(TermMode::ALTERNATE_SCROLL) { dispatch_context.add("alternate_scroll"); } if mode.contains(TermMode::BRACKETED_PASTE) { dispatch_context.add("bracketed_paste"); } if mode.intersects(TermMode::MOUSE_MODE) { dispatch_context.add("any_mouse_reporting"); } { let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) { "click" } else if mode.contains(TermMode::MOUSE_DRAG) { "drag" } else if mode.contains(TermMode::MOUSE_MOTION) { "motion" } else { "off" }; dispatch_context.set("mouse_reporting", mouse_reporting); } { let format = if mode.contains(TermMode::SGR_MOUSE) { "sgr" } else if mode.contains(TermMode::UTF8_MOUSE) { "utf8" } else { "normal" }; dispatch_context.set("mouse_format", format); }; if self.terminal.read(cx).last_content.selection.is_some() { dispatch_context.add("selection"); } dispatch_context } fn set_terminal( &mut self, terminal: Entity, window: &mut Window, cx: &mut Context, ) { self._terminal_subscriptions = subscribe_for_terminal_events(&terminal, self.workspace.clone(), window, cx); self.terminal = terminal; } fn rerun_button(task: &TaskState) -> Option { if !task.spawned_task.show_rerun { return None; } let task_id = task.spawned_task.id.clone(); Some( IconButton::new("rerun-icon", IconName::Rerun) .icon_size(IconSize::Small) .size(ButtonSize::Compact) .icon_color(Color::Default) .shape(ui::IconButtonShape::Square) .tooltip(move |_window, cx| Tooltip::for_action("Rerun task", &RerunTask, cx)) .on_click(move |_, window, cx| { window.dispatch_action(Box::new(terminal_rerun_override(&task_id)), cx); }), ) } } fn terminal_rerun_override(task: &TaskId) -> zed_actions::Rerun { zed_actions::Rerun { task_id: Some(task.0.clone()), allow_concurrent_runs: Some(true), use_new_terminal: Some(false), reevaluate_context: false, } } fn subscribe_for_terminal_events( terminal: &Entity, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Vec { let terminal_subscription = cx.observe(terminal, |_, _, cx| cx.notify()); let mut previous_cwd = None; let terminal_events_subscription = cx.subscribe_in( terminal, window, move |terminal_view, terminal, event, window, cx| { let current_cwd = terminal.read(cx).working_directory(); if current_cwd != previous_cwd { previous_cwd = current_cwd; terminal_view.needs_serialize = true; } match event { Event::Wakeup => { cx.notify(); cx.emit(Event::Wakeup); cx.emit(ItemEvent::UpdateTab); cx.emit(SearchEvent::MatchesInvalidated); } Event::Bell => { terminal_view.has_bell = true; if let TerminalBell::System = TerminalSettings::get_global(cx).bell { window.play_system_bell(); } cx.emit(Event::Wakeup); } Event::BlinkChanged(blinking) => { terminal_view.blinking_terminal_enabled = *blinking; // If in terminal-controlled mode and focused, update blink manager if matches!( TerminalSettings::get_global(cx).blinking, TerminalBlink::TerminalControlled ) && terminal_view.focus_handle.is_focused(window) { terminal_view.blink_manager.update(cx, |manager, cx| { if *blinking { manager.enable(cx); } else { manager.disable(cx); } }); } } Event::TitleChanged => { cx.emit(ItemEvent::UpdateTab); } Event::NewNavigationTarget(maybe_navigation_target) => { match maybe_navigation_target .as_ref() .zip(terminal.read(cx).last_content.last_hovered_word.as_ref()) { Some((MaybeNavigationTarget::Url(url), hovered_word)) => { if Some(hovered_word) != terminal_view .hover .as_ref() .map(|hover| &hover.hovered_word) { terminal_view.hover = Some(HoverTarget { tooltip: url.clone(), hovered_word: hovered_word.clone(), }); terminal_view.hover_tooltip_update = Task::ready(()); cx.notify(); } } Some((MaybeNavigationTarget::PathLike(path_like_target), hovered_word)) => { if Some(hovered_word) != terminal_view .hover .as_ref() .map(|hover| &hover.hovered_word) { terminal_view.hover = None; terminal_view.hover_tooltip_update = hover_path_like_target( &workspace, hovered_word.clone(), path_like_target, cx, ); cx.notify(); } } None => { terminal_view.hover = None; terminal_view.hover_tooltip_update = Task::ready(()); cx.notify(); } } } Event::Open(maybe_navigation_target) => match maybe_navigation_target { MaybeNavigationTarget::Url(url) => cx.open_url(url), MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target( &workspace, terminal_view, path_like_target, window, cx, ), }, Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs), Event::CloseTerminal => cx.emit(ItemEvent::CloseItem), Event::SelectionsChanged => { window.invalidate_character_coordinates(); cx.emit(SearchEvent::ActiveMatchChanged) } } }, ); vec![terminal_subscription, terminal_events_subscription] } fn regex_search_for_query(query: &SearchQuery) -> Option { let str = query.as_str(); if query.is_regex() { if str == "." { return None; } RegexSearch::new(str).ok() } else { RegexSearch::new(®ex::escape(str)).ok() } } #[derive(Default)] struct TerminalScrollbarSettingsWrapper; impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper { fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar { TerminalSettings::get_global(cx) .scrollbar .show .map(ui_scrollbar_settings_from_raw) .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show) } } impl TerminalView { /// Attempts to process a keystroke in the terminal. Returns true if handled. /// /// In vi mode, explicitly triggers a re-render because vi navigation (like j/k) /// updates the cursor locally without sending data to the shell, so there's no /// shell output to automatically trigger a re-render. fn process_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) -> bool { let (handled, vi_mode_enabled) = self.terminal.update(cx, |term, cx| { ( term.try_keystroke(keystroke, TerminalSettings::get_global(cx).option_as_meta), term.vi_mode_enabled(), ) }); if handled && vi_mode_enabled { cx.notify(); } handled } fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { self.clear_bell(cx); self.pause_cursor_blinking(window, cx); if self.process_keystroke(&event.keystroke, cx) { cx.stop_propagation(); } } fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { self.terminal.update(cx, |terminal, _| { terminal.set_cursor_shape(self.cursor_shape); terminal.focus_in(); }); let should_blink = match TerminalSettings::get_global(cx).blinking { TerminalBlink::Off => false, TerminalBlink::On => true, TerminalBlink::TerminalControlled => self.blinking_terminal_enabled, }; if should_blink { self.blink_manager.update(cx, BlinkManager::enable); } window.invalidate_character_coordinates(); cx.notify(); } fn focus_out(&mut self, _window: &mut Window, cx: &mut Context) { self.blink_manager.update(cx, BlinkManager::disable); self.terminal.update(cx, |terminal, _| { terminal.focus_out(); terminal.set_cursor_shape(CursorShape::Hollow); }); cx.notify(); } } impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // TODO: this should be moved out of render self.scroll_handle.update(self.terminal.read(cx)); if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() { self.terminal.update(cx, |term, _| { let delta = new_display_offset as i32 - term.last_content.display_offset as i32; match delta.cmp(&0) { cmp::Ordering::Greater => term.scroll_up_by(delta as usize), cmp::Ordering::Less => term.scroll_down_by(-delta as usize), cmp::Ordering::Equal => {} } }); } let terminal_handle = self.terminal.clone(); let terminal_view_handle = cx.entity(); let focused = self.focus_handle.is_focused(window); div() .id("terminal-view") .size_full() .relative() .track_focus(&self.focus_handle(cx)) .key_context(self.dispatch_context(cx)) .on_action(cx.listener(TerminalView::send_text)) .on_action(cx.listener(TerminalView::send_keystroke)) .on_action(cx.listener(TerminalView::copy)) .on_action(cx.listener(TerminalView::paste)) .on_action(cx.listener(TerminalView::paste_text)) .on_action(cx.listener(TerminalView::clear)) .on_action(cx.listener(TerminalView::scroll_line_up)) .on_action(cx.listener(TerminalView::scroll_line_down)) .on_action(cx.listener(TerminalView::scroll_page_up)) .on_action(cx.listener(TerminalView::scroll_page_down)) .on_action(cx.listener(TerminalView::scroll_to_top)) .on_action(cx.listener(TerminalView::scroll_to_bottom)) .on_action(cx.listener(TerminalView::toggle_vi_mode)) .on_action(cx.listener(TerminalView::show_character_palette)) .on_action(cx.listener(TerminalView::select_all)) .on_action(cx.listener(TerminalView::rerun_task)) .on_action(cx.listener(TerminalView::rename_terminal)) .on_key_down(cx.listener(Self::key_down)) .on_mouse_down( MouseButton::Right, cx.listener(|this, event: &MouseDownEvent, window, cx| { if !this.terminal.read(cx).mouse_mode(event.modifiers.shift) { let had_selection = this.terminal.read(cx).last_content.selection.is_some(); if !had_selection { this.terminal.update(cx, |terminal, _| { terminal.select_word_at_event_position(event); }); } let has_selection = !had_selection || this .terminal .read(cx) .last_content .selection_text .as_ref() .is_some_and(|text| !text.is_empty()); this.deploy_context_menu(event.position, has_selection, window, cx); cx.notify(); } }), ) .child( // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu div() .id("terminal-view-container") .size_full() .bg(cx.theme().colors().editor_background) .child(TerminalElement::new( terminal_handle, terminal_view_handle, self.workspace.clone(), self.focus_handle.clone(), focused, self.should_show_cursor(focused, cx), self.block_below_cursor.clone(), self.mode.clone(), )) .when(self.content_mode(window, cx).is_scrollable(), |div| { let colors = cx.theme().colors(); div.custom_scrollbars( Scrollbars::for_settings::() .show_along(ScrollAxes::Vertical) .with_stable_track_along( ScrollAxes::Vertical, colors.editor_background, ) .tracked_scroll_handle(&self.scroll_handle), window, cx, ) }), ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() .position(*position) .anchor(gpui::Anchor::TopLeft) .child(menu.clone()), ) .with_priority(1) })) } } impl Item for TerminalView { type Event = ItemEvent; fn tab_tooltip_content(&self, cx: &App) -> Option { Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ let terminal = self.terminal().read(cx); let title = terminal.title(false); let pid = terminal.pid_getter()?.fallback_pid(); move |_, _| { v_flex() .gap_1() .child(Label::new(title.clone())) .child(h_flex().flex_grow().child(Divider::horizontal())) .child( Label::new(format!("Process ID (PID): {}", pid)) .color(Color::Muted) .size(LabelSize::Small), ) .into_any_element() } })))) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { let terminal = self.terminal().read(cx); let title = self .custom_title .as_ref() .filter(|title| !title.trim().is_empty()) .cloned() .unwrap_or_else(|| terminal.title(true)); let (icon, icon_color, rerun_button) = match terminal.task() { Some(terminal_task) => match &terminal_task.status { TaskStatus::Running => ( IconName::PlayFilled, Color::Disabled, TerminalView::rerun_button(terminal_task), ), TaskStatus::Unknown => ( IconName::Warning, Color::Warning, TerminalView::rerun_button(terminal_task), ), TaskStatus::Completed { success } => { let rerun_button = TerminalView::rerun_button(terminal_task); if *success { (IconName::Check, Color::Success, rerun_button) } else { (IconName::XCircle, Color::Error, rerun_button) } } }, None => (IconName::Terminal, Color::Muted, None), }; let self_handle = self.self_handle.clone(); h_flex() .gap_1() .group("term-tab-icon") .when(!params.selected, |this| { this.track_focus(&self.focus_handle) }) .on_action(move |action: &RenameTerminal, window, cx| { self_handle .update(cx, |this, cx| this.rename_terminal(action, window, cx)) .ok(); }) .child( h_flex() .group("term-tab-icon") .child( div() .when(rerun_button.is_some(), |this| { this.hover(|style| style.invisible().w_0()) }) .child(Icon::new(icon).color(icon_color)), ) .when_some(rerun_button, |this, rerun_button| { this.child( div() .absolute() .visible_on_hover("term-tab-icon") .child(rerun_button), ) }), ) .child( div() .relative() .child( Label::new(title) .color(params.text_color()) .when(self.is_renaming(), |this| this.alpha(0.)), ) .when_some(self.rename_editor.clone(), |this, editor| { let self_handle = self.self_handle.clone(); let self_handle_cancel = self.self_handle.clone(); this.child( div() .absolute() .top_0() .left_0() .size_full() .child(editor) .on_action(move |_: &menu::Confirm, window, cx| { self_handle .update(cx, |this, cx| { this.finish_renaming(true, window, cx) }) .ok(); }) .on_action(move |_: &menu::Cancel, window, cx| { self_handle_cancel .update(cx, |this, cx| { this.finish_renaming(false, window, cx) }) .ok(); }), ) }), ) .into_any() } fn tab_content_text(&self, detail: usize, cx: &App) -> SharedString { if let Some(custom_title) = self.custom_title.as_ref().filter(|l| !l.trim().is_empty()) { return custom_title.clone().into(); } let terminal = self.terminal().read(cx); terminal.title(detail == 0).into() } fn telemetry_event_text(&self) -> Option<&'static str> { None } fn handle_drop( &self, active_pane: &Pane, dropped: &dyn Any, window: &mut Window, cx: &mut App, ) -> bool { let Some(project) = self.project.upgrade() else { return false; }; if let Some(paths) = dropped.downcast_ref::() { let is_local = project.read(cx).is_local(); if is_local { self.add_paths_to_terminal(paths.paths(), window, cx); return true; } return false; } else if let Some(tab) = dropped.downcast_ref::() { let Some(self_handle) = self.self_handle.upgrade() else { return false; }; let Some(workspace) = self.workspace.upgrade() else { return false; }; let Some(this_pane) = workspace.read(cx).pane_for(&self_handle) else { return false; }; let item = if tab.pane == this_pane { active_pane.item_for_index(tab.ix) } else { tab.pane.read(cx).item_for_index(tab.ix) }; let Some(item) = item else { return false; }; if item.downcast::().is_some() { let Some(split_direction) = active_pane.drag_split_direction() else { return false; }; let Some(terminal_panel) = workspace.read(cx).panel::(cx) else { return false; }; if !terminal_panel.read(cx).center.panes().contains(&&this_pane) { return false; } let source = tab.pane.clone(); let item_id_to_move = item.item_id(); let is_zoomed = { let terminal_panel = terminal_panel.read(cx); if terminal_panel.active_pane == this_pane { active_pane.is_zoomed() } else { terminal_panel.active_pane.read(cx).is_zoomed() } }; let workspace = workspace.downgrade(); let terminal_panel = terminal_panel.downgrade(); // Defer the split operation to avoid re-entrancy panic. // The pane may be the one currently being updated, so we cannot // call mark_positions (via split) synchronously. window .spawn(cx, async move |cx| { cx.update(|window, cx| { let Ok(new_pane) = terminal_panel.update(cx, |terminal_panel, cx| { let new_pane = terminal_panel::new_terminal_pane( workspace, project, is_zoomed, window, cx, ); terminal_panel.apply_tab_bar_buttons(&new_pane, cx); terminal_panel.center.split( &this_pane, &new_pane, split_direction, cx, ); anyhow::Ok(new_pane) }) else { return; }; let Some(new_pane) = new_pane.log_err() else { return; }; workspace::move_item( &source, &new_pane, item_id_to_move, new_pane.read(cx).active_item_index(), true, window, cx, ); }) .ok(); }) .detach(); return true; } else { if let Some(project_path) = item.project_path(cx) && let Some(path) = project.read(cx).absolute_path(&project_path, cx) { self.add_paths_to_terminal(&[path], window, cx); return true; } } return false; } else if let Some(selection) = dropped.downcast_ref::() { let project = project.read(cx); let paths = selection .items() .map(|selected_entry| selected_entry.entry_id) .filter_map(|entry_id| project.path_for_entry(entry_id, cx)) .filter_map(|project_path| project.absolute_path(&project_path, cx)) .collect::>(); if !paths.is_empty() { self.add_paths_to_terminal(&paths, window, cx); } return true; } else if let Some(&entry_id) = dropped.downcast_ref::() { let project = project.read(cx); if let Some(path) = project .path_for_entry(entry_id, cx) .and_then(|project_path| project.absolute_path(&project_path, cx)) { self.add_paths_to_terminal(&[path], window, cx); } return true; } false } fn tab_extra_context_menu_actions( &self, _window: &mut Window, cx: &mut Context, ) -> Vec<(SharedString, Box)> { let terminal = self.terminal.read(cx); if terminal.task().is_none() { vec![("Rename".into(), Box::new(RenameTerminal))] } else { Vec::new() } } fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { workspace::item::ItemBufferKind::Singleton } fn can_split(&self) -> bool { true } fn clone_on_split( &self, workspace_id: Option, window: &mut Window, cx: &mut Context, ) -> Task>> { let Ok(terminal) = self.project.update(cx, |project, cx| { let cwd = project .active_project_directory(cx) .map(|it| it.to_path_buf()); project.clone_terminal(self.terminal(), cx, cwd) }) else { return Task::ready(None); }; cx.spawn_in(window, async move |this, cx| { let terminal = terminal.await.log_err()?; this.update_in(cx, |this, window, cx| { cx.new(|cx| { TerminalView::new( terminal, this.workspace.clone(), workspace_id, this.project.clone(), window, cx, ) }) }) .ok() }) } fn is_dirty(&self, cx: &App) -> bool { match self.terminal.read(cx).task() { Some(task) => task.status == TaskStatus::Running, None => self.has_bell(), } } fn has_conflict(&self, _cx: &App) -> bool { false } fn can_save_as(&self, _cx: &App) -> bool { false } fn as_searchable( &self, handle: &Entity, _: &App, ) -> Option> { Some(Box::new(handle.clone())) } fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { if self.show_breadcrumbs && !self.terminal().read(cx).breadcrumb_text.trim().is_empty() { ToolbarItemLocation::PrimaryLeft } else { ToolbarItemLocation::Hidden } } fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { Some(( vec![HighlightedText { text: self.terminal().read(cx).breadcrumb_text.clone().into(), highlights: vec![], }], None, )) } fn added_to_workspace( &mut self, workspace: &mut Workspace, _: &mut Window, cx: &mut Context, ) { if self.terminal().read(cx).task().is_none() { if let Some((new_id, old_id)) = workspace.database_id().zip(self.workspace_id) { log::debug!( "Updating workspace id for the terminal, old: {old_id:?}, new: {new_id:?}", ); let db = TerminalDb::global(cx); let entity_id = cx.entity_id().as_u64(); cx.background_spawn(async move { db.update_workspace_id(new_id, old_id, entity_id).await }) .detach(); } self.workspace_id = workspace.database_id(); } } fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) { f(*event) } } impl SerializableItem for TerminalView { fn serialized_item_kind() -> &'static str { "Terminal" } fn cleanup( workspace_id: WorkspaceId, alive_items: Vec, _window: &mut Window, cx: &mut App, ) -> Task> { let db = TerminalDb::global(cx); delete_unloaded_items(alive_items, workspace_id, "terminals", &db, cx) } fn serialize( &mut self, _workspace: &mut Workspace, item_id: workspace::ItemId, _closing: bool, _: &mut Window, cx: &mut Context, ) -> Option>> { let terminal = self.terminal().read(cx); if terminal.task().is_some() { return None; } if !self.needs_serialize { return None; } let workspace_id = self.workspace_id?; let cwd = terminal.working_directory(); let custom_title = self.custom_title.clone(); self.needs_serialize = false; let db = TerminalDb::global(cx); Some(cx.background_spawn(async move { if let Some(cwd) = cwd { db.save_working_directory(item_id, workspace_id, cwd) .await?; } db.save_custom_title(item_id, workspace_id, custom_title) .await?; Ok(()) })) } fn should_serialize(&self, _: &Self::Event) -> bool { self.needs_serialize } fn deserialize( project: Entity, workspace: WeakEntity, workspace_id: WorkspaceId, item_id: workspace::ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { let (cwd, custom_title) = cx .update(|_window, cx| { let db = TerminalDb::global(cx); let from_db = db .get_working_directory(item_id, workspace_id) .log_err() .flatten(); let cwd = if from_db .as_ref() .is_some_and(|from_db| !from_db.as_os_str().is_empty()) { from_db } else { workspace .upgrade() .and_then(|workspace| default_working_directory(workspace.read(cx), cx)) }; let custom_title = db .get_custom_title(item_id, workspace_id) .log_err() .flatten() .filter(|title| !title.trim().is_empty()); (cwd, custom_title) }) .ok() .unwrap_or((None, None)); let terminal = project .update(cx, |project, cx| project.create_terminal_shell(cwd, cx)) .await?; cx.update(|window, cx| { cx.new(|cx| { let mut view = TerminalView::new( terminal, workspace, Some(workspace_id), project.downgrade(), window, cx, ); if custom_title.is_some() { view.custom_title = custom_title; } view }) }) }) } } impl SearchableItem for TerminalView { type Match = RangeInclusive; fn supported_options(&self) -> SearchOptions { SearchOptions { case: false, word: false, regex: true, replacement: false, selection: false, select_all: false, find_in_results: false, } } /// Clear stored matches fn clear_matches(&mut self, _window: &mut Window, cx: &mut Context) { self.terminal().update(cx, |term, _| term.matches.clear()) } /// Store matches returned from find_matches somewhere for rendering fn update_matches( &mut self, matches: &[Self::Match], _active_match_index: Option, _token: SearchToken, _window: &mut Window, cx: &mut Context, ) { self.terminal() .update(cx, |term, _| term.matches = matches.to_vec()) } /// Returns the selection content to pre-load into this search fn query_suggestion( &mut self, _ignore_settings: bool, _window: &mut Window, cx: &mut Context, ) -> String { self.terminal() .read(cx) .last_content .selection_text .clone() .unwrap_or_default() } /// Focus match at given index into the Vec of matches fn activate_match( &mut self, index: usize, _: &[Self::Match], _token: SearchToken, _window: &mut Window, cx: &mut Context, ) { self.terminal() .update(cx, |term, _| term.activate_match(index)); cx.notify(); } /// Add selections for all matches given. fn select_matches( &mut self, matches: &[Self::Match], _token: SearchToken, _: &mut Window, cx: &mut Context, ) { self.terminal() .update(cx, |term, _| term.select_matches(matches)); cx.notify(); } /// Get all of the matches for this query, should be done on the background fn find_matches( &mut self, query: Arc, _: &mut Window, cx: &mut Context, ) -> Task> { if let Some(s) = regex_search_for_query(&query) { self.terminal() .update(cx, |term, cx| term.find_matches(s, cx)) } else { Task::ready(vec![]) } } /// Reports back to the search toolbar what the active match should be (the selection) fn active_match_index( &mut self, direction: Direction, matches: &[Self::Match], _token: SearchToken, _: &mut Window, cx: &mut Context, ) -> Option { // Selection head might have a value if there's a selection that isn't // associated with a match. Therefore, if there are no matches, we should // report None, no matter the state of the terminal if !matches.is_empty() { if let Some(selection_head) = self.terminal().read(cx).selection_head { // If selection head is contained in a match. Return that match match direction { Direction::Prev => { // If no selection before selection head, return the first match Some( matches .iter() .enumerate() .rev() .find(|(_, search_match)| { search_match.contains(&selection_head) || search_match.start() < &selection_head }) .map(|(ix, _)| ix) .unwrap_or(0), ) } Direction::Next => { // If no selection after selection head, return the last match Some( matches .iter() .enumerate() .find(|(_, search_match)| { search_match.contains(&selection_head) || search_match.start() > &selection_head }) .map(|(ix, _)| ix) .unwrap_or(matches.len().saturating_sub(1)), ) } } } else { // Matches found but no active selection, return the first last one (closest to cursor) Some(matches.len().saturating_sub(1)) } } else { None } } fn replace( &mut self, _: &Self::Match, _: &SearchQuery, _token: SearchToken, _window: &mut Window, _: &mut Context, ) { // Replacement is not supported in terminal view, so this is a no-op. } } /// Gets the working directory for the given workspace, respecting the user's settings. /// Falls back to home directory when no project directory is available. /// /// For remote projects, local-only resolution (home dir fallback, shell expansion, /// local `is_dir` checks) is skipped -- returning `None` lets the remote shell /// open in the remote user's home directory by default. pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { let is_remote = workspace.project().read(cx).is_remote(); let directory = match &TerminalSettings::get_global(cx).working_directory { WorkingDirectory::CurrentFileDirectory => workspace .project() .read(cx) .active_entry_directory(cx) .or_else(|| current_project_directory(workspace, cx)), WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx), WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), WorkingDirectory::AlwaysHome => None, WorkingDirectory::Always { directory } if !is_remote => shellexpand::full(directory) .ok() .map(|dir| Path::new(&dir.to_string()).to_path_buf()) .filter(|dir| dir.is_dir()), WorkingDirectory::Always { .. } => None, }; if is_remote { directory } else { directory.or_else(dirs::home_dir) } } fn current_project_directory(workspace: &Workspace, cx: &App) -> Option { workspace .project() .read(cx) .active_project_directory(cx) .as_deref() .map(Path::to_path_buf) .or_else(|| first_project_directory(workspace, cx)) } ///Gets the first project's home directory, or the home directory fn first_project_directory(workspace: &Workspace, cx: &App) -> Option { let worktree = workspace.worktrees(cx).next()?.read(cx); let worktree_path = worktree.abs_path(); if worktree.root_entry()?.is_dir() { Some(worktree_path.to_path_buf()) } else { // If worktree is a file, return its parent directory worktree_path.parent().map(|p| p.to_path_buf()) } } #[cfg(test)] mod tests { use super::*; use gpui::TestAppContext; use project::{Entry, Project, ProjectPath, Worktree}; use remote::RemoteClient; use std::path::{Path, PathBuf}; use util::paths::PathStyle; use util::rel_path::RelPath; use workspace::item::test::{TestItem, TestProjectItem}; use workspace::{AppState, MultiWorkspace, SelectedEntry}; fn expected_drop_text(paths: &[PathBuf]) -> String { let mut text = String::new(); for path in paths { text.push(' '); text.push_str(&format!("{path:?}")); } text.push(' '); text } fn assert_drop_writes_to_terminal( pane: &Entity, terminal_view_index: usize, terminal: &Entity, dropped: &dyn Any, expected_text: &str, window: &mut Window, cx: &mut Context, ) { let _ = terminal.update(cx, |terminal, _| terminal.take_input_log()); let handled = pane.update(cx, |pane, cx| { pane.item_for_index(terminal_view_index) .unwrap() .handle_drop(pane, dropped, window, cx) }); assert!(handled, "handle_drop should return true for {:?}", dropped); let mut input_log = terminal.update(cx, |terminal, _| terminal.take_input_log()); assert_eq!(input_log.len(), 1, "expected exactly one write to terminal"); let written = String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8"); assert_eq!(written, expected_text); } // Working directory calculation tests // No Worktrees in project -> home_dir() #[gpui::test] async fn no_worktree(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; cx.read(|cx| { let workspace = workspace.read(cx); let active_entry = project.read(cx).active_entry(); //Make sure environment is as expected assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_none()); let res = default_working_directory(workspace, cx); assert_eq!(res, dirs::home_dir()); let res = first_project_directory(workspace, cx); assert_eq!(res, None); }); } #[gpui::test] async fn remote_no_worktree_uses_remote_shell_default_cwd( cx: &mut TestAppContext, server_cx: &mut TestAppContext, ) { let (_project, workspace) = init_remote_test(cx, server_cx).await; cx.read(|cx| { let workspace = workspace.read(cx); assert!(workspace.project().read(cx).is_remote()); assert!(workspace.worktrees(cx).next().is_none()); assert_eq!(default_working_directory(workspace, cx), None); }); } // No active entry, but a worktree, worktree is a file -> parent directory #[gpui::test] async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; create_file_wt(project.clone(), "/root.txt", cx).await; cx.read(|cx| { let workspace = workspace.read(cx); let active_entry = project.read(cx).active_entry(); //Make sure environment is as expected assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_some()); let res = default_working_directory(workspace, cx); assert_eq!(res, Some(Path::new("/").to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some(Path::new("/").to_path_buf())); }); } // No active entry, but a worktree, worktree is a folder -> worktree_folder #[gpui::test] async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; cx.update(|cx| { let workspace = workspace.read(cx); let active_entry = project.read(cx).active_entry(); assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_some()); let res = default_working_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root/").to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root/").to_path_buf())); }); } // Active entry with a work tree, worktree is a file -> worktree_folder() #[gpui::test] async fn active_entry_worktree_is_file(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await; let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await; insert_active_entry_for(wt2, entry2, project.clone(), cx); cx.update(|cx| { let workspace = workspace.read(cx); let active_entry = project.read(cx).active_entry(); assert!(active_entry.is_some()); let res = default_working_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); }); } // Active entry, with a worktree, worktree is a folder -> worktree_folder #[gpui::test] async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) { let (project, workspace) = init_test(cx).await; let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await; let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await; insert_active_entry_for(wt2, entry2, project.clone(), cx); cx.update(|cx| { let workspace = workspace.read(cx); let active_entry = project.read(cx).active_entry(); assert!(active_entry.is_some()); let res = default_working_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root2/").to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some(Path::new("/root1/").to_path_buf())); }); } // active_entry_directory: No active entry -> returns None (used by CurrentFileDirectory) #[gpui::test] async fn active_entry_directory_no_active_entry(cx: &mut TestAppContext) { let (project, _workspace) = init_test(cx).await; let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; cx.update(|cx| { assert!(project.read(cx).active_entry().is_none()); let res = project.read(cx).active_entry_directory(cx); assert_eq!(res, None); }); } // active_entry_directory: Active entry is file -> returns parent directory (used by CurrentFileDirectory) #[gpui::test] async fn active_entry_directory_active_file(cx: &mut TestAppContext) { let (project, _workspace) = init_test(cx).await; let (wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await; let entry = create_file_in_worktree(wt.clone(), "src/main.rs", cx).await; insert_active_entry_for(wt, entry, project.clone(), cx); cx.update(|cx| { let res = project.read(cx).active_entry_directory(cx); assert_eq!(res, Some(Path::new("/root/src").to_path_buf())); }); } // active_entry_directory: Active entry is directory -> returns that directory (used by CurrentFileDirectory) #[gpui::test] async fn active_entry_directory_active_dir(cx: &mut TestAppContext) { let (project, _workspace) = init_test(cx).await; let (wt, entry) = create_folder_wt(project.clone(), "/root/", cx).await; insert_active_entry_for(wt, entry, project.clone(), cx); cx.update(|cx| { let res = project.read(cx).active_entry_directory(cx); assert_eq!(res, Some(Path::new("/root/").to_path_buf())); }); } /// Creates a worktree with 1 file: /root.txt pub async fn init_test(cx: &mut TestAppContext) -> (Entity, Entity) { let (project, workspace, _) = init_test_with_window(cx).await; (project, workspace) } /// Creates a worktree with 1 file /root.txt and returns the project, workspace, and window handle. async fn init_test_with_window( cx: &mut TestAppContext, ) -> ( Entity, Entity, gpui::WindowHandle, ) { let params = cx.update(AppState::test); cx.update(|cx| { theme_settings::init(theme::LoadThemes::JustBase, cx); }); let project = Project::test(params.fs.clone(), [], cx).await; let window_handle = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window_handle .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); (project, workspace, window_handle) } async fn init_remote_test( cx: &mut TestAppContext, server_cx: &mut TestAppContext, ) -> (Entity, Entity) { cx.update(|cx| { release_channel::init(semver::Version::new(0, 0, 0), cx); }); server_cx.update(|cx| { release_channel::init(semver::Version::new(0, 0, 0), cx); }); let params = cx.update(AppState::test); let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx); let ping_handler = server_cx.new(|_| ()); server_session.add_request_handler::( ping_handler.downgrade(), |_entity, _envelope, _cx| async { Ok(rpc::proto::Ack {}) }, ); drop(connect_guard); let remote_client = RemoteClient::connect_mock(opts, cx).await; let project = cx.update(|cx| { Project::remote( remote_client, params.client.clone(), params.node_runtime.clone(), params.user_store.clone(), params.languages.clone(), params.fs.clone(), false, cx, ) }); let window_handle = cx.add_window({ let params = params.clone(); let project_for_workspace = project.clone(); move |window, cx| { window.activate_window(); let workspace = cx.new(|cx| { Workspace::new( None, project_for_workspace.clone(), params.clone(), window, cx, ) }); MultiWorkspace::new(workspace, window, cx) } }); let workspace = window_handle .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); (project, workspace) } /// Creates a file in the given worktree and returns its entry. async fn create_file_in_worktree( worktree: Entity, relative_path: impl AsRef, cx: &mut TestAppContext, ) -> Entry { cx.update(|cx| { worktree.update(cx, |worktree, cx| { worktree.create_entry( RelPath::new(relative_path.as_ref(), PathStyle::local()) .unwrap() .as_ref() .into(), false, None, cx, ) }) }) .await .unwrap() .into_included() .unwrap() } /// Creates a worktree with 1 folder: /root{suffix}/ async fn create_folder_wt( project: Entity, path: impl AsRef, cx: &mut TestAppContext, ) -> (Entity, Entry) { create_wt(project, true, path, cx).await } /// Creates a worktree with 1 file: /root{suffix}.txt async fn create_file_wt( project: Entity, path: impl AsRef, cx: &mut TestAppContext, ) -> (Entity, Entry) { create_wt(project, false, path, cx).await } async fn create_wt( project: Entity, is_dir: bool, path: impl AsRef, cx: &mut TestAppContext, ) -> (Entity, Entry) { let (wt, _) = project .update(cx, |project, cx| { project.find_or_create_worktree(path, true, cx) }) .await .unwrap(); let entry = cx .update(|cx| { wt.update(cx, |wt, cx| { wt.create_entry(RelPath::empty().into(), is_dir, None, cx) }) }) .await .unwrap() .into_included() .unwrap(); (wt, entry) } pub fn insert_active_entry_for( wt: Entity, entry: Entry, project: Entity, cx: &mut TestAppContext, ) { cx.update(|cx| { let p = ProjectPath { worktree_id: wt.read(cx).id(), path: entry.path, }; project.update(cx, |project, cx| project.set_active_path(Some(p), cx)); }); } // Terminal drag/drop test #[gpui::test] async fn test_handle_drop_writes_paths_for_all_drop_types(cx: &mut TestAppContext) { let (project, _workspace, window_handle) = init_test_with_window(cx).await; let (worktree, _) = create_folder_wt(project.clone(), "/root/", cx).await; let first_entry = create_file_in_worktree(worktree.clone(), "first.txt", cx).await; let second_entry = create_file_in_worktree(worktree.clone(), "second.txt", cx).await; let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); let first_path = project .read_with(cx, |project, cx| { project.absolute_path( &ProjectPath { worktree_id, path: first_entry.path.clone(), }, cx, ) }) .unwrap(); let second_path = project .read_with(cx, |project, cx| { project.absolute_path( &ProjectPath { worktree_id, path: second_entry.path.clone(), }, cx, ) }) .unwrap(); let (active_pane, terminal, terminal_view, tab_item) = window_handle .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); let active_pane = workspace.read(cx).active_pane().clone(); let terminal = cx.new(|cx| { terminal::TerminalBuilder::new_display_only( CursorShape::default(), terminal::terminal_settings::AlternateScroll::On, None, 0, cx.background_executor(), PathStyle::local(), ) .unwrap() .subscribe(cx) }); let terminal_view = cx.new(|cx| { TerminalView::new( terminal.clone(), workspace.downgrade(), None, project.downgrade(), window, cx, ) }); active_pane.update(cx, |pane, cx| { pane.add_item( Box::new(terminal_view.clone()), true, false, None, window, cx, ); }); let tab_project_item = cx.new(|_| TestProjectItem { entry_id: Some(second_entry.id), project_path: Some(ProjectPath { worktree_id, path: second_entry.path.clone(), }), is_dirty: false, }); let tab_item = cx.new(|cx| TestItem::new(cx).with_project_items(&[tab_project_item])); active_pane.update(cx, |pane, cx| { pane.add_item(Box::new(tab_item.clone()), true, false, None, window, cx); }); (active_pane, terminal, terminal_view, tab_item) }) .unwrap(); cx.run_until_parked(); window_handle .update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); let terminal_view_index = active_pane.read(cx).index_for_item(&terminal_view).unwrap(); let dragged_tab_index = active_pane.read(cx).index_for_item(&tab_item).unwrap(); assert!( workspace.read(cx).pane_for(&terminal_view).is_some(), "terminal view not registered with workspace after run_until_parked" ); // Dragging an external file should write its path to the terminal let external_paths = ExternalPaths(vec![first_path.clone()].into()); assert_drop_writes_to_terminal( &active_pane, terminal_view_index, &terminal, &external_paths, &expected_drop_text(std::slice::from_ref(&first_path)), window, cx, ); // Dragging a tab should write the path of the tab's item to the terminal let dragged_tab = DraggedTab { pane: active_pane.clone(), item: Box::new(tab_item.clone()), ix: dragged_tab_index, detail: 0, is_active: false, }; assert_drop_writes_to_terminal( &active_pane, terminal_view_index, &terminal, &dragged_tab, &expected_drop_text(std::slice::from_ref(&second_path)), window, cx, ); // Dragging multiple selections should write both paths to the terminal let dragged_selection = DraggedSelection { active_selection: SelectedEntry { worktree_id, entry_id: first_entry.id, }, marked_selections: Arc::from([ SelectedEntry { worktree_id, entry_id: first_entry.id, }, SelectedEntry { worktree_id, entry_id: second_entry.id, }, ]), }; assert_drop_writes_to_terminal( &active_pane, terminal_view_index, &terminal, &dragged_selection, &expected_drop_text(&[first_path.clone(), second_path.clone()]), window, cx, ); // Dropping a project entry should write the entry's path to the terminal let dropped_entry_id = first_entry.id; assert_drop_writes_to_terminal( &active_pane, terminal_view_index, &terminal, &dropped_entry_id, &expected_drop_text(&[first_path]), window, cx, ); }) .unwrap(); } // Terminal rename tests #[gpui::test] async fn test_custom_title_initially_none(cx: &mut TestAppContext) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, _cx| { assert!(view.custom_title().is_none()); }); } #[gpui::test] async fn test_set_custom_title(cx: &mut TestAppContext) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, cx| { view.set_custom_title(Some("frontend".to_string()), cx); assert_eq!(view.custom_title(), Some("frontend")); }); } #[gpui::test] async fn test_set_custom_title_empty_becomes_none(cx: &mut TestAppContext) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, cx| { view.set_custom_title(Some("test".to_string()), cx); assert_eq!(view.custom_title(), Some("test")); view.set_custom_title(Some("".to_string()), cx); assert!(view.custom_title().is_none()); view.set_custom_title(Some(" ".to_string()), cx); assert!(view.custom_title().is_none()); }); } #[gpui::test] async fn test_custom_title_marks_needs_serialize(cx: &mut TestAppContext) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, cx| { view.needs_serialize = false; view.set_custom_title(Some("new_label".to_string()), cx); assert!(view.needs_serialize); }); } #[gpui::test] async fn test_tab_content_uses_custom_title(cx: &mut TestAppContext) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, cx| { view.set_custom_title(Some("my-server".to_string()), cx); let text = view.tab_content_text(0, cx); assert_eq!(text.as_ref(), "my-server"); }); terminal_view.update(cx, |view, cx| { view.set_custom_title(None, cx); let text = view.tab_content_text(0, cx); assert_ne!(text.as_ref(), "my-server"); }); } #[gpui::test] async fn test_tab_content_shows_terminal_title_when_custom_title_directly_set_empty( cx: &mut TestAppContext, ) { cx.executor().allow_parking(); let (project, workspace) = init_test(cx).await; let terminal = project .update(cx, |project, cx| project.create_terminal_shell(None, cx)) .await .unwrap(); let terminal_view = cx .add_window(|window, cx| { TerminalView::new( terminal, workspace.downgrade(), None, project.downgrade(), window, cx, ) }) .root(cx) .unwrap(); terminal_view.update(cx, |view, cx| { view.custom_title = Some("".to_string()); let text = view.tab_content_text(0, cx); assert!( !text.is_empty(), "Tab should show terminal title, not empty string; got: '{}'", text ); }); terminal_view.update(cx, |view, cx| { view.custom_title = Some(" ".to_string()); let text = view.tab_content_text(0, cx); assert!( !text.is_empty() && text.as_ref() != " ", "Tab should show terminal title, not whitespace; got: '{}'", text ); }); } }