#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))] // Adding this file level gate as some of the code around editability is not used in WASM yet. use crate::code::editor::{ comment_editor::{CommentEditor, CommentEditorEvent}, comments::PendingComment, diff::DiffStatus, element::{ AddAsContextButton, CommentButton, EditorWrapper, EditorWrapperStateHandle, GutterHoverTarget, GutterRange, InnerEditor, LineNumberConfig, RevertHunkButton, }, find::view::{CodeEditorFind as Find, Event as FindViewEvent}, goto_line::view::{Event as GoToLineEvent, GoToLineView}, line::EditorLineLocation, model::{CodeEditorModel, CodeEditorModelEvent, HoverableLink, LineBound, StableEditorLine}, nav_bar::{NavBar, NavBarBehavior, NavBarEvent}, scroll::{ScrollPosition, ScrollTrigger, ScrollWheelBehavior}, }; use crate::code::{ editor::EditorReviewComment, DiffResult, NoopCommentEditorProvider, NoopFindReferencesCardProvider, ShowCommentEditorProvider, ShowFindReferencesCardProvider, }; use crate::{ appearance::Appearance, code_review::comments::{CommentId, CommentOrigin}, editor::InteractionState, features::FeatureFlag, notebooks::editor::rich_text_styles, settings::{AppEditorSettings, FontSettings}, view_components::find::FindDirection, }; use ai::diff_validation::DiffDelta; use lazy_static::lazy_static; use num_traits::SaturatingSub; use pathfinder_geometry::vector::vec2f; use std::fmt::Debug; use std::rc::Rc; use std::{collections::HashMap, ops::Range}; use std::{collections::HashSet, path::Path}; use string_offset::CharOffset; use vec1::{vec1, Vec1}; use vim::vim::{Direction, InsertPosition, VimMode, VimModel, VimState, VimSubscriber}; use warp_core::platform::SessionPlatform; use warp_editor::{ content::{ buffer::{ Buffer, BufferEditAction, EditOrigin, InitialBufferState, ToBufferCharOffset as _, ToBufferPoint, }, text::IndentUnit, version::BufferVersion, }, model::{CoreEditorModel, PlainTextEditorModel}, multiline::AnyMultilineString, render::{ element::{ lens_element::RichTextElementLens, DisplayOptions, DisplayStateHandle, RichTextElement, VerticalExpansionBehavior, }, model::{ AutoScrollMode, BlockSpacing, Decoration, ExpansionType, LineCount, ParagraphStyles, RichTextStyles, CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES, }, }, search::{SearchEvent, Searcher, MATCH_FILL, SELECTED_MATCH_FILL}, }; use warp_util::content_version::ContentVersion; use warpui::{ elements::{ new_scrollable::{ AxisConfiguration, DualAxisConfig, NewScrollableElement, ScrollableAppearance, }, ChildAnchor, ChildView, Dismiss, Fill, Flex, Margin, MouseStateHandle, NewScrollable, OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, ScrollStateHandle, Shrinkable, Stack, }, event::ModifiersState, keymap::Keystroke, platform::Cursor, prelude::RectF, text::point::Point, units::Pixels, AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle, WeakViewHandle, WindowId, }; mod actions; pub use actions::init; pub(super) use actions::CodeEditorViewAction; mod vim_handler; /// Limit the keybindings that conflict with the Agent Mode embedded editor. const NON_EDITABLE_KEYMAP_CONTEXT: &str = "NonEditableKeymapContext"; lazy_static! { static ref AUTOCOMPLETE_SYMBOLS: HashMap = HashMap::from([('(', ')'), ('[', ']'), ('{', '}'), ('\'', '\''), ('"', '"'),]); static ref CLOSING_SYMBOLS: HashSet = AUTOCOMPLETE_SYMBOLS.values().cloned().collect(); } pub enum CodeEditorEvent { Focused, ContentChanged { origin: EditOrigin, }, UnifiedDiffComputed(Rc), SelectionChanged, SelectionStart, SelectionEnd, CopiedEmptyText, /// Emitted when the editor is in Vim mode and a user Escape while already in Normal mode. VimEscapeInNormalMode, /// Emitted when escape key is pressed (regardless of Vim state). /// This allows parent views to handle escape for closing overlays. EscapePressed, /// Emitted when diff decorations are updated (line highlights, removed lines, etc.) DiffUpdated, /// Emitted when the plus icon is clicked to add diff hunk context DiffHunkContextAdded { #[allow(dead_code)] line_range: Range, }, /// Emitted when a diff hunk is reverted DiffReverted, HiddenSectionExpanded, /// Emitted when a comment is saved. This gets propagated up so that it /// can be augmented with the file and repo paths and saved to the comment model. CommentSaved { comment: EditorReviewComment, }, RequestOpenComment(CommentId), /// Emitted when the viewport is updated after layout ViewportUpdated, DelayedRenderingFlushed, /// Emitted when the render state layout has been updated. LayoutInvalidated, #[cfg(windows)] WindowsCtrlC { /// True if the `ctrl-c` action was used to copy an active selection. copied_selection: bool, }, MouseHovered { offset: CharOffset, cmd: bool, clamped: bool, /// Whether the mouse move event was covered by an element above the editor. is_covered: bool, }, DeleteComment { id: CommentId, }, VimGotoDefinition, VimFindReferences, VimShowHover, } /// Store all states related to displaying the editor content. #[derive(Default)] struct DisplayHandles { vertical_scroll_state: ScrollStateHandle, horizontal_scroll_state: ScrollStateHandle, display_state: DisplayStateHandle, wrapper_state_handle: EditorWrapperStateHandle, } #[derive(Debug, Clone, Copy)] struct CodeEditorViewDisplayOptions { vertical_expansion_behavior: VerticalExpansionBehavior, /// If `false`, we will never show the diff UI in this code editor. can_show_diff_ui: bool, collapsible_diffs: bool, show_line_numbers: bool, scroll_wheel_behavior: ScrollWheelBehavior, horizontal_scrollbar_appearance: ScrollableAppearance, vertical_scrollbar_appearance: ScrollableAppearance, show_nav_bar: bool, /// The add as context button, or `None` if it is not currently visible. diff_hunk_as_context: Option, /// The revert diff button, or `None` if it is not currently visible. revert_diff_hunk: Option, /// The add comment button, or `None` if it is not currently visible. comment_button: Option, /// Whether to expand the width of the diff indicator in the gutter on hover. expand_diff_indicator_width_on_hover: bool, // Provides a starting line number for markdown code blocks. starting_line_number: Option, gutter_hover_target: GutterHoverTarget, line_height_override: Option, } #[derive(Clone, Debug)] pub(super) struct SavedComment { uuid: CommentId, location: EditorLineLocation, mouse_state: MouseStateHandle, } impl SavedComment { pub fn location(&self) -> &EditorLineLocation { &self.location } pub fn mouse_state(&self) -> &MouseStateHandle { &self.mouse_state } pub fn uuid(&self) -> CommentId { self.uuid } } #[derive(Debug)] pub struct CodeEditorRenderOptions { vertical_expansion_behavior: VerticalExpansionBehavior, line_height_override: Option, lazy_layout: bool, show_comment_editor_provider: Box, show_find_references_provider: Box, } impl CodeEditorRenderOptions { pub fn new(vertical_expansion_behavior: VerticalExpansionBehavior) -> Self { Self { vertical_expansion_behavior, line_height_override: None, lazy_layout: false, show_comment_editor_provider: Box::new(NoopCommentEditorProvider), show_find_references_provider: Box::new(NoopFindReferencesCardProvider), } } pub fn lazy_layout(mut self) -> Self { self.lazy_layout = true; self } pub fn line_height_override(mut self, line_height: f32) -> Self { self.line_height_override = Some(line_height); self } pub fn with_show_comment_editor_provider( mut self, comment_editor_provider: impl ShowCommentEditorProvider, ) -> Self { self.show_comment_editor_provider = Box::new(comment_editor_provider); self } pub fn with_show_find_references_provider( mut self, find_references_provider: impl ShowFindReferencesCardProvider, ) -> Self { self.show_find_references_provider = Box::new(find_references_provider); self } } pub struct CodeEditorView { pub model: ModelHandle, pub searcher: ModelHandle, find_bar: Option>, goto_line_dialog: ViewHandle, nav_bar: ViewHandle, display_states: DisplayHandles, is_selecting: bool, self_handle: WeakViewHandle, display_options: CodeEditorViewDisplayOptions, pending_scroll: Option, supports_vim_mode: bool, vim_model: ModelHandle, // Track the most recent Vim search direction to determine how to cycle (n/N) thereafter. last_search_direction: Direction, active_comment_editor: ViewHandle, /// TODO: maybe turn into a map for fast UUID or range lookup comment_locations: Vec, /// Save position of the comment button rendered within this code editor view. comment_save_position_id: String, show_comment_editor_provider: Box, /// Save position of the anchor point for find references card. find_references_save_position_id: String, show_find_references_provider: Box, /// The offset where find references card is anchored (if showing). find_references_anchor_offset: Option, window_id: WindowId, } impl CodeEditorView { /// A [`SessionPlatform`] is used to determine the default line ending for /// the code editor. It should be provided if the code editor can modify /// files on the user's system. pub fn new( session_platform: Option, buffer: Option>, render_options: CodeEditorRenderOptions, ctx: &mut ViewContext, ) -> Self { let appearance_handle = Appearance::handle(ctx); let font_settings_handle = FontSettings::handle(ctx); let initial_styles = code_text_styles( appearance_handle.as_ref(ctx), font_settings_handle.as_ref(ctx), render_options.line_height_override, ); ctx.subscribe_to_model(&appearance_handle, |me, _, _, ctx| { me.handle_appearance_or_font_change(ctx); }); ctx.subscribe_to_model(&font_settings_handle, |me, _, _, ctx| { me.handle_appearance_or_font_change(ctx); }); let model = ctx.add_model(|ctx| { CodeEditorModel::new( initial_styles, session_platform, render_options.lazy_layout, buffer, ctx, ) }); ctx.subscribe_to_model(&model, |me, _, event, ctx| { me.handle_editor_model_event(event, ctx); }); // Creates a new model for searching the editor let buffer = model.as_ref(ctx).buffer().clone(); let selection_model = model.as_ref(ctx).buffer_selection_model().clone(); let searcher = ctx.add_model(|ctx| Searcher::new(buffer, selection_model, ctx)); // Subscribes to events from Searcher model so that we can handle ctx.subscribe_to_model(&searcher, |me, _, event, ctx| { me.handle_searcher_event(event, ctx); }); let find_bar = ctx.add_typed_action_view(|ctx| Find::new(searcher.clone(), ctx)); ctx.subscribe_to_view(&find_bar, move |me, _, event, ctx| { me.handle_find_event(event, ctx); }); let goto_line_dialog = ctx.add_typed_action_view(GoToLineView::new); ctx.subscribe_to_view(&goto_line_dialog, |me, _, event, ctx| { me.handle_goto_line_event(event, ctx); }); let nav_bar = ctx.add_typed_action_view(|ctx| NavBar::new(model.clone(), ctx)); ctx.subscribe_to_view(&nav_bar, |me, _, event, ctx| match event { NavBarEvent::Close => { me.toggle_diff_nav(None, ctx); ctx.notify(); } }); // If feature flag is enabled, enable vim mode. let supports_vim_mode = FeatureFlag::VimCodeEditor.is_enabled(); let vim_model = ctx.add_model(|_| VimModel::new()); ctx.subscribe_to_model(&vim_model, Self::handle_vim_event); // Ensure CodeEditorView starts in Normal mode when Vim keybindings are enabled. if supports_vim_mode && AppEditorSettings::as_ref(ctx).vim_mode_enabled() { vim_model.update(ctx, |vim_model, ctx| { if let Ok(escape) = Keystroke::parse("escape") { vim_model.keypress(&escape, ctx); } }); } // Ensure that we re-render when the rendering model changes. ctx.observe(&model.as_ref(ctx).render_state().clone(), |_, _, ctx| { ctx.notify(); }); let comment_model = model.as_ref(ctx).comments().clone(); let comment_editor = ctx.add_typed_action_view(|ctx| CommentEditor::new(ctx, comment_model)); ctx.subscribe_to_view(&comment_editor, |me, _, event, ctx| { me.handle_comment_editor_event(event, ctx); }); Self { searcher, find_bar: Some(find_bar), goto_line_dialog, model, display_states: Default::default(), is_selecting: false, self_handle: ctx.handle(), nav_bar, comment_locations: Vec::new(), display_options: CodeEditorViewDisplayOptions { vertical_expansion_behavior: render_options.vertical_expansion_behavior, can_show_diff_ui: true, collapsible_diffs: true, show_line_numbers: true, starting_line_number: None, show_nav_bar: true, diff_hunk_as_context: Default::default(), revert_diff_hunk: Default::default(), comment_button: Default::default(), // By default expand diff indicators on hover. expand_diff_indicator_width_on_hover: true, scroll_wheel_behavior: ScrollWheelBehavior::AlwaysHandle, // By default, we should render the horizontal scrollbar as overlay to prevent it // from truncating space for the code editor. We should not render it as an overlay // for small code editors. horizontal_scrollbar_appearance: ScrollableAppearance::new( warpui::elements::ScrollbarWidth::Auto, false, ), vertical_scrollbar_appearance: ScrollableAppearance::new( warpui::elements::ScrollbarWidth::Auto, false, ), gutter_hover_target: GutterHoverTarget::GutterElement, line_height_override: render_options.line_height_override, }, pending_scroll: None, supports_vim_mode, vim_model, last_search_direction: Direction::Forward, active_comment_editor: comment_editor, comment_save_position_id: format!("code_editor_comment_{}", ctx.view_id()), show_comment_editor_provider: render_options.show_comment_editor_provider, find_references_save_position_id: format!( "code_editor_find_references_{}", ctx.view_id() ), show_find_references_provider: render_options.show_find_references_provider, find_references_anchor_offset: None, window_id: ctx.window_id(), } } pub fn set_find_references_anchor_offset(&mut self, offset: Option) { self.find_references_anchor_offset = offset; } pub fn find_references_save_position_id(&self) -> &str { &self.find_references_save_position_id } pub fn show_find_references_provider(&self) -> &dyn ShowFindReferencesCardProvider { self.show_find_references_provider.as_ref() } pub fn window_id(&self) -> WindowId { self.window_id } pub fn set_add_diff_hunk_as_context_button( &mut self, enabled: bool, ctx: &mut ViewContext, ) { self.display_options.diff_hunk_as_context = Some(AddAsContextButton::new(enabled)); ctx.notify(); } /// Enables the add context button (plus icon) in diff hunks. Only enable this for code review views. pub fn with_add_context_button(mut self) -> Self { self.display_options.diff_hunk_as_context = Some(AddAsContextButton::new(true /* enabled */)); self } /// Enables the "revert" button on diff hunks. Only enable this for code review views. pub fn with_revert_diff_hunk_button(mut self) -> Self { self.display_options.revert_diff_hunk = Some(RevertHunkButton::new(true /* is_enabled */)); self } /// Enables the "comment" button on diff hunks. Only enable this for code review views. pub fn with_comment_button(mut self) -> Self { self.display_options.comment_button = Some(CommentButton::default()); self } /// Disables the diff indicator expanding on hover. pub fn disable_diff_indicator_expansion_on_hover(mut self) -> Self { self.display_options.expand_diff_indicator_width_on_hover = false; self } pub fn with_gutter_hover_target(mut self, target: GutterHoverTarget) -> Self { self.display_options.gutter_hover_target = target; self } /// Enables clicking on diff hunk gutter elements to collapse changed sections. pub fn with_collapsible_diffs(mut self, enabled: bool) -> Self { self.display_options.collapsible_diffs = enabled; self } pub(crate) fn disable_find_and_replace(mut self) -> Self { self.find_bar = None; self } fn find_bar_open(&self, ctx: &mut ViewContext) -> bool { if let Some(find) = &self.find_bar { find.as_ref(ctx).is_open() } else { false } } fn show_find_bar(&mut self, ctx: &mut ViewContext) { let Some(find_bar) = &self.find_bar else { return; }; // Pre-populate the search field with the currently selected text if it's a single line let should_populate_query = self.model.as_ref(ctx).selection_is_single_range(ctx); if should_populate_query { let selection_offsets = *self.model.as_ref(ctx).selections(ctx).first(); let selected_text = self .model .as_ref(ctx) .buffer() .as_ref(ctx) .text_in_range(selection_offsets.head..selection_offsets.tail) .into_string(); // Only use the selected text if it's a single line (no newlines) if !selected_text.contains('\n') && !selected_text.trim().is_empty() { find_bar.update(ctx, |find_bar, ctx| { find_bar.set_find_query(ctx, &selected_text); }); } } find_bar.update(ctx, |find_bar, _ctx| { find_bar.set_open(true); }); self.update_decorations_and_position(ctx); ctx.focus(find_bar); ctx.notify(); } pub fn changed_lines(&self, app: &AppContext) -> Vec> { self.model .as_ref(app) .diff() .as_ref(app) .modified_lines() .collect() } pub fn close_find_bar(&mut self, should_focus_editor: bool, ctx: &mut ViewContext) { if let Some(find_bar) = &self.find_bar { let should_update = find_bar.update(ctx, |find_bar, _ctx| { if find_bar.is_open() { find_bar.set_open(false); true } else { false } }); if should_update { self.update_decorations_and_position(ctx); } } if should_focus_editor { self.focus(ctx); } ctx.notify(); } pub fn hide_lines_outside_of_active_diff( &self, context_lines: usize, ctx: &mut ViewContext, ) { self.model.update(ctx, |model, ctx| { model.hide_lines_outside_of_active_diff(context_lines, ctx) }); } pub fn set_base(&self, base: &str, recompute_diff: bool, ctx: &mut ViewContext) { self.model .update(ctx, |model, ctx| model.set_base(base, recompute_diff, ctx)); } pub fn lens_for_line_range( &self, line_range: Range, ctx: &AppContext, ) -> Box { let render_state = self.model.as_ref(ctx).render_state(); let lens = RichTextElementLens::::new( line_range.start.into_render_line_location() ..line_range.end.into_render_line_location(), render_state.clone(), self.self_handle.clone(), Default::default(), ); let line_number_config = self.line_number_config(ctx); let diff_status = if self.display_options.can_show_diff_ui { self.model.as_ref(ctx).diff_status(ctx) } else { DiffStatus::default() }; EditorWrapper::new( InnerEditor::Lens(lens), self.display_options.vertical_expansion_behavior, line_number_config, diff_status, Default::default(), /* Do not reuse the same hover state handle as the editor view */ Box::new(|_, _| {}), false, self.model.as_ref(ctx).diff_navigation_state().clone(), None, Default::default(), Default::default(), Default::default(), vec![], false, self.display_options.gutter_hover_target, self.comment_save_position_id.clone(), self.find_references_save_position_id.clone(), ) .finish() } fn show_goto_line(&mut self, ctx: &mut ViewContext) { if self.find_bar_open(ctx) { self.close_find_bar(false, ctx); } self.goto_line_dialog.update(ctx, |dialog, ctx| { dialog.open(ctx); }); ctx.focus(&self.goto_line_dialog); ctx.notify(); } fn close_goto_line_dialog(&mut self, ctx: &mut ViewContext) { self.goto_line_dialog.update(ctx, |dialog, ctx| { dialog.close(ctx); }); self.focus(ctx); ctx.notify(); } fn handle_goto_line_event(&mut self, event: &GoToLineEvent, ctx: &mut ViewContext) { match event { GoToLineEvent::Close => { self.close_goto_line_dialog(ctx); } GoToLineEvent::Confirm { input } => { let trimmed = input.trim().to_string(); if trimmed.is_empty() { self.goto_line_dialog.update(ctx, |dialog, ctx| { dialog.set_error("Please enter a line number".to_string(), ctx); }); return; } let (line_str, col_str) = match trimmed.split_once(':') { Some((l, c)) => (l, Some(c)), None => (trimmed.as_str(), None), }; let line_number = match line_str.parse::() { Ok(n) if n >= 1 => n, _ => { self.goto_line_dialog.update(ctx, |dialog, ctx| { dialog.set_error("Please enter a valid line number".to_string(), ctx); }); return; } }; let column = match col_str { Some(c) if !c.is_empty() => match c.parse::() { Ok(n) => Some(n), Err(_) => { self.goto_line_dialog.update(ctx, |dialog, ctx| { dialog.set_error( "Please enter a valid column number".to_string(), ctx, ); }); return; } }, _ => None, }; let line_count = self.model.as_ref(ctx).line_count(ctx); let clamped_line = line_number.min(line_count + 1); self.model.update(ctx, |model, ctx| { model.jump_to_line_column(clamped_line, column, ctx); }); self.close_goto_line_dialog(ctx); } } } fn escape(&mut self, ctx: &mut ViewContext) { if self.goto_line_dialog.as_ref(ctx).is_open() { self.close_goto_line_dialog(ctx); return; } // If find bar is open, close it (editor handles escape) if self.find_bar_open(ctx) { self.close_find_bar(true, ctx); return; } // If vim mode is enabled, let vim handle escape if self.vim_mode_enabled(ctx) { self.enter_vim_normal_mode(ctx); return; } // Editor didn't handle escape - emit event for parent views (e.g., close overlays) ctx.emit(CodeEditorEvent::EscapePressed); } fn update_decorations_and_position(&self, ctx: &mut ViewContext) { // Updating search decorations based on matches let search_decorations = match self.find_bar_open(ctx) { true => self.searcher.as_ref(ctx).result_decorations(), false => Vec::new(), }; self.model.update(ctx, |model, ctx| { model.render_state().update(ctx, |render, ctx| { render.set_text_decorations(search_decorations, ctx); }); }); // Updating position on code editor view based on match location if let Some(autoscroll_match) = self.searcher.as_ref(ctx).selected_match_range() { self.model .as_ref(ctx) .render_state() .clone() .update(ctx, |render_state, _ctx| { render_state.request_autoscroll_to(AutoScrollMode::ScrollOffsetsIntoViewport( autoscroll_match.clone(), )); }); } } pub fn set_pending_scroll(&mut self, trigger: ScrollTrigger) { self.pending_scroll = Some(trigger); } pub fn set_show_nav_bar(&mut self, show_nav_bar: bool) { self.display_options.show_nav_bar = show_nav_bar; } pub fn set_nav_bar_behavior(&self, behavior: NavBarBehavior, ctx: &mut ViewContext) { self.nav_bar.update(ctx, |nav_bar, _ctx| { nav_bar.set_behavior(behavior); }); } pub fn set_show_current_line_highlights( &self, show_current_line_highlights: bool, ctx: &mut ViewContext, ) { self.model.update(ctx, |model, _ctx| { model.set_show_current_line_highlights(show_current_line_highlights); }) } pub fn set_scroll_wheel_behavior(&mut self, behavior: ScrollWheelBehavior) { self.display_options.scroll_wheel_behavior = behavior; } pub fn set_vertical_scrollbar_appearance(&mut self, appearance: ScrollableAppearance) { self.display_options.vertical_scrollbar_appearance = appearance; } pub fn set_horizontal_scrollbar_appearance(&mut self, appearance: ScrollableAppearance) { self.display_options.horizontal_scrollbar_appearance = appearance; } pub fn set_show_find_references_provider( &mut self, provider: impl ShowFindReferencesCardProvider, ) { self.show_find_references_provider = Box::new(provider); } pub fn set_find_highlights( &self, ranges: Vec>, selected_range_index: Option, ctx: &mut ViewContext, ) { let decorations: Vec = ranges .into_iter() .enumerate() .map(|(index, range)| { let fill = if Some(index) == selected_range_index { *SELECTED_MATCH_FILL } else { *MATCH_FILL }; Decoration::new(range.start, range.end).with_background(fill) }) .collect(); self.model.update(ctx, |model, ctx| { model.render_state().update(ctx, |render, ctx| { render.set_text_decorations(decorations, ctx); }); }); } /// Set diagnostic decorations (e.g., error/warning underlines) on the editor. pub fn set_diagnostic_decorations( &self, decorations: Vec, ctx: &mut ViewContext, ) { self.model.update(ctx, |model, ctx| { model.render_state().update(ctx, |render, ctx| { render.set_text_decorations(decorations, ctx); }); }); } pub fn character_bounds_in_viewport( &self, offset: CharOffset, app: &AppContext, ) -> Option { let render_state = self.model.as_ref(app).render_state().as_ref(app); // Get the bounds of the character in viewport coordinates. let bounds = render_state.character_bounds_in_viewport(offset)?; // The render_state bounds are relative to the editor content area, but the // CodeEditorView also includes a gutter (line numbers). We need to offset // the bounds by the gutter width when line numbers are shown. let gutter_offset = if self.display_options.show_line_numbers { super::element::GUTTER_WIDTH } else { 0.0 }; Some(RectF::new( bounds.origin() + vec2f(gutter_offset, 0.0), bounds.size(), )) } /// Returns the current viewport height in pixels, or None if not yet laid out. pub fn viewport_height(&self, app: &AppContext) -> Option { let render_state = self.model.as_ref(app).render_state().as_ref(app); let height = render_state.viewport().height(); // Return None if the viewport hasn't been laid out yet (height is zero) if height.as_f32() > 0.0 { Some(height.as_f32()) } else { None } } #[allow(clippy::single_range_in_vec_init)] fn expand_hidden_section( &mut self, line_range: Range, expansion_type: &ExpansionType, ctx: &mut ViewContext, ) { let hidden_section_start = line_range.start.as_usize(); let hidden_section_end = line_range.end.as_usize(); let lines_to_unhide = match expansion_type { ExpansionType::Both => { warp_editor::content::text::LineCount::from(hidden_section_start) ..warp_editor::content::text::LineCount::from(hidden_section_end) } ExpansionType::ExpandDown => { let end = hidden_section_end .min(hidden_section_start + CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES); warp_editor::content::text::LineCount::from(hidden_section_start) ..warp_editor::content::text::LineCount::from(end) } ExpansionType::ExpandUp => { let start = hidden_section_start.max( hidden_section_end.saturating_sub(CODE_EDITOR_HIDDEN_SECTION_EXPANSION_LINES), ); warp_editor::content::text::LineCount::from(start) ..warp_editor::content::text::LineCount::from(hidden_section_end) } }; self.model.update(ctx, |model, ctx| { model.set_visible_line_range(lines_to_unhide, ctx); }); ctx.emit(CodeEditorEvent::HiddenSectionExpanded); } pub(crate) fn with_can_show_diff_ui(mut self, can_show_diff_ui: bool) -> Self { self.display_options.can_show_diff_ui = can_show_diff_ui; self } pub(crate) fn with_show_line_numbers(mut self, show_line_numbers: bool) -> Self { self.display_options.show_line_numbers = show_line_numbers; self } pub(crate) fn with_horizontal_scrollbar_appearance( mut self, scrollbar_appearance: ScrollableAppearance, ) -> Self { self.display_options.horizontal_scrollbar_appearance = scrollbar_appearance; self } pub(crate) fn starting_line_number(&self) -> Option { self.display_options.starting_line_number } pub(crate) fn set_starting_line_number(&mut self, starting_line_number: Option) { self.display_options.starting_line_number = starting_line_number; } fn handle_searcher_event(&mut self, _event: &SearchEvent, ctx: &mut ViewContext) { // Only update decorations if the find bar is open. if self.find_bar_open(ctx) { // TODO: Add some indicator (maybe a border?) for invalid queries. self.update_decorations_and_position(ctx); } } /// Move the editor cursor to the start of the currently-selected search match, if any. fn move_cursor_to_selected_match(&mut self, ctx: &mut ViewContext) { if let Some(match_range) = self.searcher.as_ref(ctx).selected_match_range() { self.model.update(ctx, |model, ctx| { model.selection().update(ctx, |selection_model, ctx| { selection_model.set_cursor(match_range.start, ctx); }); }); } } pub fn selection_position_anchor(&self, app: &AppContext) -> OffsetPositioning { self.model.as_ref(app).positioning(app) } fn handle_find_event(&mut self, event: &FindViewEvent, ctx: &mut ViewContext) { let Some(find_bar) = &self.find_bar else { return; }; match event { FindViewEvent::CloseFindBar => { // If vim mode is enabled and there is a selected search match, move the cursor // to the start of that match before closing the find bar. if self.vim_mode_enabled(ctx) { self.move_cursor_to_selected_match(ctx); } self.close_find_bar(true, ctx); } FindViewEvent::Update { query } => { let query_str = query.clone().unwrap_or_default(); self.run_find(&query_str, ctx); } FindViewEvent::NextMatch { direction } => { self.searcher.update(ctx, |searcher, ctx| match direction { FindDirection::Up => searcher.select_previous_result(ctx), FindDirection::Down => searcher.select_next_result(ctx), }); self.move_cursor_to_selected_match(ctx); // After navigating via the find bar, only return focus to the editor in Vim mode. if self.vim_mode_enabled(ctx) { self.focus(ctx); } } FindViewEvent::VimEnterAndFocusEditor => { // Shift focus back to the main editor when Enter is pressed in Vim mode in the find bar. // Select the nearest match from the cursor position and move to it. self.searcher .update(ctx, |searcher, ctx| match self.last_search_direction { Direction::Forward => searcher.select_next_from_cursor(ctx), Direction::Backward => searcher.select_prev_from_cursor(ctx), }); self.move_cursor_to_selected_match(ctx); self.focus(ctx); ctx.notify(); } FindViewEvent::SelectAll => { // Get all search results from the find model if let Some(results) = self.searcher.as_ref(ctx).results() { if !results.matches.is_empty() { // Convert all match ranges to selection offsets let selection_offsets: Vec = results .matches .iter() .map(|match_result| { warp_editor::content::buffer::SelectionOffsets { head: match_result.end, tail: match_result.start, } }) .collect(); // Set multiple selections on the editor to highlight all matches if let Ok(selections) = vec1::Vec1::try_from_vec(selection_offsets) { self.model.update(ctx, |model, ctx| { model.selection().update(ctx, |selection_model, ctx| { selection_model.update_selection( warp_editor::content::buffer::BufferSelectAction::SetSelectionOffsets { selections }, warp_editor::content::buffer::AutoScrollBehavior::Selection, ctx, ); }); }); } } } self.close_find_bar(true, ctx); } FindViewEvent::ReplaceSelected => { let replace_query = find_bar.update(ctx, |find_bar, ctx| find_bar.replace_query(ctx)); if let Some(match_range) = self.searcher.as_ref(ctx).selected_match_range() { let final_replace_text = if find_bar.as_ref(ctx).is_preserve_case_enabled() { self.preserve_case(match_range.clone(), &replace_query, ctx) } else { replace_query.clone() }; let edits = vec1![(final_replace_text, match_range)]; let selection_model = self.model.as_ref(ctx).buffer_selection_model().clone(); // Replace current selection in the editor with replace query self.model.update(ctx, |model, ctx| { model.update_content( |mut content_model, ctx| { content_model.apply_edit( warp_editor::content::buffer::BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ) }, ctx); }); // After selection is replaced, emit an accessibility announcement find_bar.update(ctx, |find_bar, ctx| { find_bar.emit_replace_a11y_content(ctx); }) } } FindViewEvent::ReplaceAll => { let (replace_query, preserve_case_enabled) = find_bar.update(ctx, |find_bar, ctx| { ( find_bar.replace_query(ctx), find_bar.is_preserve_case_enabled(), ) }); if !replace_query.is_empty() { // Get all search results from the find model if let Some(results) = self.searcher.as_ref(ctx).results() { if !results.matches.is_empty() { // Convert all match ranges to selection offsets with case preservation let edits: Vec<(String, Range)> = results .matches .iter() .map(|match_result| { let match_range = Range { start: match_result.start, end: match_result.end, }; let final_replace_text = if preserve_case_enabled { self.preserve_case(match_range.clone(), &replace_query, ctx) } else { replace_query.clone() }; (final_replace_text, match_range) }) .collect(); if let Ok(edits) = vec1::Vec1::try_from_vec(edits) { let selection_model = self.model.as_ref(ctx).buffer_selection_model().clone(); // Replace selections in the editor with replace query self.model.update(ctx, |model, ctx| { model.update_content(|mut content_model, ctx| { content_model.apply_edit( warp_editor::content::buffer::BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ) }, ctx); }); } } } } } } } fn handle_comment_editor_event( &mut self, event: &CommentEditorEvent, ctx: &mut ViewContext, ) { match event { CommentEditorEvent::ContentChanged => { // Handle comment content changes if needed ctx.notify(); } CommentEditorEvent::CommentSaved { id, comment_text, line, } => { let Some(line) = line else { debug_assert!(false, "Comment saved event missing line information"); return; }; self.save_comment(*id, comment_text, line, ctx); } CommentEditorEvent::CloseEditor => { // Close the comment editor by updating the pending comment state to Closed self.model.update(ctx, |model, ctx| { model.comments().update(ctx, |comments, _| { comments.pending_comment = PendingComment::Closed; }); }); ctx.notify(); } CommentEditorEvent::DeleteComment { id } => { ctx.emit(CodeEditorEvent::DeleteComment { id: *id }); } } } fn save_comment( &mut self, id: Option, comment_text: &str, line: &EditorLineLocation, ctx: &mut ViewContext, ) { let line_content = self.model.as_ref(ctx).get_diff_content_for_line(line, ctx); let review_comment = match id { Some(id) => EditorReviewComment::new_with_id( id, line.to_owned(), line_content, comment_text.to_owned(), ), None => { EditorReviewComment::new(line.to_owned(), line_content, comment_text.to_owned()) } }; self.comment_locations.push(SavedComment { uuid: review_comment.id, location: line.to_owned(), mouse_state: MouseStateHandle::default(), }); ctx.emit(CodeEditorEvent::CommentSaved { comment: review_comment, }); ctx.notify(); } /// Update all comment locations in this editor. pub fn set_comment_locations( &mut self, comments: impl Iterator, ctx: &mut ViewContext, ) { self.comment_locations.clear(); for comment in comments { self.comment_locations.push(SavedComment { uuid: comment.id, location: comment.line.clone(), mouse_state: MouseStateHandle::default(), }); } ctx.notify(); } /// Clear all comment locations in this editor. pub fn clear_comment_locations(&mut self, ctx: &mut ViewContext) { self.comment_locations.clear(); ctx.notify(); } fn line_number_config(&self, ctx: &AppContext) -> Option { let appearance = Appearance::as_ref(ctx); let theme = appearance.theme(); if self.display_options.show_line_numbers { Some(LineNumberConfig { font_family: appearance.monospace_font_family(), font_size: appearance.monospace_font_size(), text_color: theme.sub_text_color(theme.background()).into(), highlight_text_color: theme.main_text_color(theme.background()).into(), starting_line_number: self.display_options.starting_line_number, }) } else { None } } fn run_find(&mut self, query: &str, ctx: &mut ViewContext) { self.searcher.update(ctx, |searcher, ctx| { searcher.set_query(query.to_string(), ctx); }); } fn handle_editor_model_event( &mut self, event: &CodeEditorModelEvent, ctx: &mut ViewContext, ) { match event { CodeEditorModelEvent::DiffUpdated => { self.nav_bar.update(ctx, |_, ctx| ctx.notify()); ctx.emit(CodeEditorEvent::DiffUpdated); ctx.notify() } CodeEditorModelEvent::SyntaxHighlightingUpdated => { self.nav_bar.update(ctx, |_, ctx| ctx.notify()); ctx.notify() } CodeEditorModelEvent::SelectionChanged => { self.reset_for_editing_change(); self.vim_maybe_enforce_cursor_line_cap(ctx); ctx.emit(CodeEditorEvent::SelectionChanged); } CodeEditorModelEvent::ContentChanged { origin } => { if origin.from_user() { self.reset_for_editing_change(); self.vim_maybe_enforce_cursor_line_cap(ctx); } ctx.emit(CodeEditorEvent::ContentChanged { origin: *origin }); } CodeEditorModelEvent::UnifiedDiffComputed(diff) => { ctx.emit(CodeEditorEvent::UnifiedDiffComputed(diff.clone())); } CodeEditorModelEvent::ViewportUpdated(version) => { if let Some(trigger) = self .pending_scroll .take_if(|trigger| trigger.minimum_applicable_version <= *version) { match trigger.position { ScrollPosition::LineAndColumn(line_col) => { self.jump_to_line_column(line_col.line_num, line_col.column_num, ctx); } ScrollPosition::FocusedDiffHunk => { self.navigate_current_diff_hunk(ctx); } } } ctx.emit(CodeEditorEvent::ViewportUpdated); } CodeEditorModelEvent::InteractionStateChanged => (), CodeEditorModelEvent::DelayedRenderingFlushed => { ctx.emit(CodeEditorEvent::DelayedRenderingFlushed); } CodeEditorModelEvent::LayoutInvalidated => { ctx.emit(CodeEditorEvent::LayoutInvalidated); } #[cfg(windows)] CodeEditorModelEvent::WindowsCtrlC { copied_selection } => { ctx.emit(CodeEditorEvent::WindowsCtrlC { copied_selection: *copied_selection, }); } } } pub fn toggle_diff_nav( &self, line_range: Option>, ctx: &mut ViewContext, ) { if !self.display_options.can_show_diff_ui { return; } let toggle_to_on = self .model .update(ctx, |model, ctx| model.toggle_diff_nav(line_range, ctx)); if toggle_to_on { self.nav_bar.update(ctx, |nav_bar, ctx| { nav_bar.autoscroll(ctx); }); } } /// Expands all diff hunks without focusing any specific diff hunk. /// All diff hunks will be shown expanded with normal highlighting. pub fn expand_diffs(&self, ctx: &mut ViewContext) { if !self.display_options.can_show_diff_ui { return; } self.model.update(ctx, |model, ctx| { model.expand_diffs(ctx); }); } /// Handles [`Appearance`] changes by updating the render model. fn handle_appearance_or_font_change(&mut self, ctx: &mut ViewContext) { let new_styles = code_text_styles( Appearance::as_ref(ctx), FontSettings::as_ref(ctx), self.display_options.line_height_override, ); self.model.update(ctx, move |model, ctx| { model.handle_appearance_or_font_change(new_styles, ctx); }); } pub fn is_focused(&self, app: &AppContext) -> bool { let Some(handle) = self.self_handle.upgrade(app) else { return false; }; // If our window is not active, we don't have user focus, even if we're focused within the app. if app.windows().state().active_window != Some(handle.window_id(app)) { return false; } // Check if the editor is focused directly. if handle.is_focused(app) { return true; } false } pub fn focus(&self, ctx: &mut ViewContext) { ctx.focus_self(); ctx.emit(CodeEditorEvent::Focused); } pub fn indent_unit(&self, ctx: &AppContext) -> IndentUnit { self.model .as_ref(ctx) .buffer() .as_ref(ctx) .indent_unit_at_plain_text() .unwrap_or_default() } fn selection_start( &mut self, offset: CharOffset, modifiers: ModifiersState, ctx: &mut ViewContext, ) { // Clicking into the editor should restore focus. self.focus(ctx); // If there is a hovered symbol range, don't handle the cmd-click. if modifiers.cmd { if let Some(range) = self.model.as_ref(ctx).hovered_symbol_range() { if range.range().contains(&offset) { return; } } } let multiselect = modifiers.alt && FeatureFlag::RichTextMultiselect.is_enabled(); self.model.update(ctx, |model, ctx| { model.select_at(offset, multiselect, ctx); }); self.is_selecting = true; ctx.emit(CodeEditorEvent::SelectionStart); } /// Extend the selection that is currently being dragged. This should be called after /// `selection_start` and before `selection_end`. fn selection_update(&mut self, offset: CharOffset, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.update_pending_selection(offset, ctx); }); } fn selection_end(&mut self, ctx: &mut ViewContext) { self.is_selecting = false; self.model.update(ctx, |model, ctx| { model.end_selection(ctx); }); ctx.emit(CodeEditorEvent::SelectionEnd); ctx.notify(); } pub fn is_selecting(&self) -> bool { self.is_selecting } /// Extend the selection to the given offset. This is used for shift-clicking to extend the /// selection, and not for dragging the selection. fn selection_extend(&mut self, offset: CharOffset, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.set_last_selection_head(offset, ctx); }); } pub fn set_language_with_path(&mut self, path: &Path, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.set_language_with_path(path, ctx); }); } pub fn set_language_with_name(&mut self, name: &str, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.set_language_with_name(name, ctx); }); } fn jump_to_line_column(&self, line: usize, column: Option, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.jump_to_line_column(line, column, ctx) }) } /// Reset editor content using InitialBufferState. /// This is the preferred method for resetting editor content as it consolidates all parameters. pub fn reset(&self, state: InitialBufferState, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.reset_content(state, ctx); }); } pub fn apply_diffs(&self, diffs: Vec, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.apply_diffs(diffs, ctx); }); } fn reset_for_editing_change(&mut self) { self.display_states.display_state.reset_cursor_blink_timer(); } pub fn text(&self, ctx: &AppContext) -> AnyMultilineString { self.model.as_ref(ctx).content_string(ctx) } pub fn word_range_at_offset( &self, offset: CharOffset, app: &AppContext, ) -> Option> { self.model.as_ref(app).word_range_at_offset(offset, app) } /// Returns the character at the given offset in the buffer, if it exists. pub fn char_at(&self, offset: CharOffset, ctx: &AppContext) -> Option { self.model.as_ref(ctx).char_at(offset, ctx) } /// Check whether the given version matches the version of the underlying buffer. pub fn version_match(&self, version: &ContentVersion, ctx: &AppContext) -> bool { self.model .as_ref(ctx) .content() .as_ref(ctx) .version_match(version) } /// Return the ContentVersion of the underlying `Buffer`. pub fn version(&self, ctx: &AppContext) -> ContentVersion { self.model.as_ref(ctx).content().as_ref(ctx).version() } pub fn buffer_version(&self, ctx: &AppContext) -> BufferVersion { self.model.as_ref(ctx).buffer_version(ctx) } /// Append text to the end of the buffer regardless of cursor position. /// This is used for streaming content where we always want to append at the end, /// not at the current cursor position since the user may select text while it's streaming. pub fn append_at_end(&self, text: &str, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { // Use append_at_end to insert at the end of buffer regardless of cursor position. // This ensures streaming code blocks always append at the end, even when user // has clicked somewhere else in the editor. model.append_at_end(text, ctx); }); } pub fn system_append_autoscroll_vertical_only(&self, text: &str, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.system_insert_autoscroll_vertical_only(text, ctx); }); } pub fn truncate(&self, len: usize, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.truncate(len, ctx); }); } pub fn retrieve_unified_diff(&self, file_name: String, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.retrieve_unified_diff(file_name, ctx); }); } /// Identifies which line (current or removed) is at the given content-space /// vertical offset. Delegates to [`CodeEditorModel::line_at_vertical_offset`]. #[allow(dead_code)] pub fn line_at_vertical_offset( &self, offset: Pixels, ctx: &mut ViewContext, ) -> Option<(StableEditorLine, Pixels)> { self.model .update(ctx, |model, ctx| model.line_at_vertical_offset(offset, ctx)) } /// Returns the content-space vertical offset of the top of the given line. /// Delegates to [`CodeEditorModel::line_top`]. pub fn line_top(&self, line: &StableEditorLine, ctx: &AppContext) -> Option { self.model.as_ref(ctx).line_top(line, ctx) } /// Returns the total content height of the editor. pub fn content_height(&self, ctx: &AppContext) -> Pixels { self.model.as_ref(ctx).render_state().as_ref(ctx).height() } pub fn interaction_state(&self, ctx: &AppContext) -> InteractionState { self.model.as_ref(ctx).interaction_state() } pub fn set_interaction_state(&self, state: InteractionState, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.set_interaction_state(state, ctx); }); } pub fn is_editable(&self, app: &AppContext) -> bool { self.model.as_ref(app).interaction_state() == InteractionState::Editable } pub fn navigate_next_diff_hunk(&self, ctx: &mut ViewContext) { self.nav_bar.update(ctx, |nav_bar, ctx| { nav_bar.navigate_down(ctx); }); } pub fn navigate_previous_diff_hunk(&self, ctx: &mut ViewContext) { self.nav_bar.update(ctx, |nav_bar, ctx| { nav_bar.navigate_up(ctx); }); } fn navigate_current_diff_hunk(&self, ctx: &mut ViewContext) { self.nav_bar.update(ctx, |nav_bar, ctx| { nav_bar.autoscroll(ctx); }); } pub fn set_vertical_expansion_behavior( &mut self, behavior: VerticalExpansionBehavior, ctx: &mut ViewContext, ) { self.display_options.vertical_expansion_behavior = behavior; ctx.notify(); } pub fn selected_text(&self, ctx: &AppContext) -> Option { let selected_text = self .model .as_ref(ctx) .read_selected_text_as_clipboard_content(ctx); if selected_text.plain_text.is_empty() { return None; } Some(selected_text.plain_text) } pub fn clear_selection(&mut self, ctx: &mut ViewContext) { // Same as [`Self::selection_start`] but without the focus. self.model.update(ctx, |model, ctx| { model.clear_selections(ctx); }); self.selection_end(ctx); } pub fn line_location_to_offsets( &self, line: &EditorLineLocation, ctx: &AppContext, ) -> (CharOffset, CharOffset) { let render_state = self.model.as_ref(ctx).render_state(); let render_state_ref = render_state.as_ref(ctx); let line_number = match line { EditorLineLocation::Current { line_number, .. } => *line_number, EditorLineLocation::Removed { line_number, .. } => { // Scroll to where the removal hunk appears in the diff view *line_number } EditorLineLocation::Collapsed { line_range } => line_range.start, }; render_state_ref.line_number_to_offset_range(line_number) } pub fn offset_to_lsp_position( &self, offset: CharOffset, ctx: &AppContext, ) -> lsp::types::Location { let buffer = self.model.as_ref(ctx).content().as_ref(ctx); let point = offset.to_buffer_point(buffer); let line = point.row.saturating_sub(1); lsp::types::Location { line: line as usize, column: point.column as usize, } } pub fn lsp_location_to_offset( &self, location: &lsp::types::Location, ctx: &AppContext, ) -> CharOffset { let buffer = self.model.as_ref(ctx).content().as_ref(ctx); let line = location.line + 1; let column = location.column; let point = Point::new(line as u32, column as u32); point.to_buffer_char_offset(buffer) } /// Returns the current cursor position as an LSP location. pub fn cursor_lsp_position(&self, ctx: &AppContext) -> lsp::types::Location { let selection = *self.model.as_ref(ctx).selections(ctx).first(); self.offset_to_lsp_position(selection.head, ctx) } /// Returns the buffer offset at the current cursor head. pub fn cursor_head_offset(&self, ctx: &AppContext) -> CharOffset { self.model.as_ref(ctx).selections(ctx).first().head } pub fn hovered_symbol_range<'a>( &'a self, ctx: &'a AppContext, ) -> Option<&'a Range> { self.model .as_ref(ctx) .hovered_symbol_range() .map(|link| link.range()) } pub fn cursor_at(&self, point: Point, ctx: &mut ViewContext) { let offset = point.to_buffer_char_offset(self.model.as_ref(ctx).content().as_ref(ctx)); self.model.update(ctx, |model, ctx| { model.cursor_at(offset, ctx); }); } pub fn clear_hovered_symbol_range(&mut self, ctx: &mut ViewContext) -> bool { self.set_hovered_symbol_range(None, ctx) } pub fn set_hovered_symbol_range( &mut self, range: Option, ctx: &mut ViewContext, ) -> bool { let cursor_shape = if range.is_some() { Cursor::PointingHand } else { Cursor::IBeam }; let updated = self .model .update(ctx, |model, _ctx| model.set_hovered_symbol_range(range)); if updated { ctx.set_cursor_shape(cursor_shape); } updated } pub fn apply_edits( &mut self, edits: Vec1<(String, Range)>, ctx: &mut ViewContext, ) { self.model.update(ctx, |model, ctx| { let selection_model = model.buffer_selection_model().clone(); model.update_content( |mut content, ctx| { content.apply_edit( BufferEditAction::InsertAtCharOffsetRanges { edits: &edits }, EditOrigin::UserInitiated, selection_model, ctx, ); }, ctx, ); }); } /// Splits text based on pascal case, camel case, hyphens, or underscores. /// Returns a tuple of (parts, delimiter) where delimiter is Some(char) if a delimiter was used. fn split_text(text: &str) -> (Vec, Option) { if text.is_empty() { return (vec![], None); } // Check for hyphen or underscore delimiters first if text.contains('-') { let parts = text.split('-').map(|s| s.to_string()).collect(); return (parts, Some('-')); } if text.contains('_') { let parts = text.split('_').map(|s| s.to_string()).collect(); return (parts, Some('_')); } // Split on camelCase/PascalCase boundaries let mut parts = Vec::new(); let mut current_part = String::new(); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { if ch.is_uppercase() && !current_part.is_empty() { // Check if this is a sequence of uppercase letters (like "HTML" in "HTMLParser") if let Some(&next_ch) = chars.peek() { if next_ch.is_lowercase() { // This is the start of a new word (like "P" in "HTMLParser") parts.push(current_part); current_part = ch.to_string(); } else { // Continue the current part for sequences like "HTML" current_part.push(ch); } } else { // Last character - check if it should be part of the previous uppercase sequence if current_part .chars() .last() .is_some_and(|c| c.is_uppercase()) { // Previous char was uppercase, append to current part current_part.push(ch); } else { // Previous char was lowercase, start new part parts.push(current_part); current_part = ch.to_string(); } } } else { current_part.push(ch); } } if !current_part.is_empty() { parts.push(current_part); } // No delimiter used for camelCase/PascalCase (parts, None) } /// Preserves the case of a single part based on the original part's casing. fn preserve_case_for_part(original_part: &str, replace_part: &str) -> String { if original_part.chars().all(|c| c.is_lowercase()) { replace_part.to_lowercase() } else if original_part.chars().all(|c| c.is_uppercase()) { replace_part.to_uppercase() } else if let Some(first_original) = original_part.chars().next() { if first_original.is_uppercase() { // Preserve uppercase first character let mut result = String::new(); let mut replace_chars = replace_part.chars(); if let Some(mut first_replace) = replace_chars.next() { first_replace = if first_replace.is_uppercase() { first_replace } else { { first_replace.to_uppercase().next().unwrap_or(first_replace) } }; result.push(first_replace); result.push_str(&replace_chars.collect::().to_lowercase()); } result } else { // Mixed case but starts with lowercase - keep replace_part as-is replace_part.to_string() } } else { // Empty original part - keep replace_part as-is replace_part.to_string() } } /// Whether the editor needs a vertical constraint when rendering. This is when the editor /// has a vertical expansion behavior that is not infinite height. pub fn needs_vertical_constraint(&self) -> bool { !matches!( self.display_options.vertical_expansion_behavior, VerticalExpansionBehavior::InfiniteHeight ) } /// Preserves the case pattern of the original text when replacing it with new text. /// The case pattern is preserved in several ways: /// - For simple text: "begin" -> "end" will preserve uppercase ("Begin" -> "End") /// and all-caps ("BEGIN" -> "END") /// - For camelCase/PascalCase: The replacement text's casing pattern is used /// ("oneTwoThree" -> "fourFiveSix", "OneTwoThree" -> "FourFiveSix") /// - Delimiters like hyphens and underscores are preserved fn preserve_case( &self, original_range: Range, replace_text: &str, ctx: &AppContext, ) -> String { // Get the original text from the buffer let original_text = self .model .as_ref(ctx) .buffer() .as_ref(ctx) .text_in_range(original_range.start..original_range.end) .into_string(); // Split both original and replacement text let (mut original_parts, mut original_delimiter) = Self::split_text(&original_text); let (mut replace_parts, replace_delimiter) = Self::split_text(replace_text); // If the replaced text is delimited differently than incoming text, we shouldn't split either of them. if original_delimiter != replace_delimiter { (original_parts, original_delimiter) = (vec![original_text.to_string()], None); replace_parts = vec![replace_text.to_string()]; } // Apply case preservation to each part let mut result_parts = Vec::new(); for (i, replace_part) in replace_parts.iter().enumerate() { if let Some(original_part) = original_parts.get(i) { result_parts.push(Self::preserve_case_for_part(original_part, replace_part)); } else { // If we have more replacement parts than original parts, keep them as-is result_parts.push(replace_part.clone()); } } // Join the parts back together using the original delimiter if it exists let delimiter = original_delimiter .map(|d| d.to_string()) .unwrap_or_default(); result_parts.join(&delimiter) } pub fn diff_hunks_changed_lines(&self, app: &AppContext) -> (usize, usize) { let model = self.model.as_ref(app); let diff = model.diff().as_ref(app); diff.diff_status().get_diff_lines() } /// If there's a single selection, returns its starting and ending line numbers. pub fn selected_lines(&self, app: &AppContext) -> Option<(u32, u32)> { // Query the buffer model directly to determine if we have a selection let selection_model = self.model.as_ref(app).buffer_selection_model().as_ref(app); if !selection_model.is_single_selection() { return None; } let offsets = selection_model.selection_offsets(); let selection_offsets = offsets.first(); if selection_offsets.head == selection_offsets.tail { return None; } let buffer = self.model.as_ref(app).buffer().as_ref(app); let (start_offset, end_offset) = ( selection_offsets.head.min(selection_offsets.tail), selection_offsets.head.max(selection_offsets.tail), ); let start_line = start_offset.to_buffer_point(buffer).row; let end_line = end_offset.to_buffer_point(buffer).row; Some((start_line, end_line)) } /// If vim keybindings are enabled, return the [`VimMode`]. Otherwise, return None. pub fn vim_mode(&self, ctx: &AppContext) -> Option { self.vim_state(ctx).map(|state| state.mode) } /// If vim keybindings are enabled, return the [`VimState`]. Otherwise, return None. pub fn vim_state<'a>(&self, ctx: &'a AppContext) -> Option> { self.vim_mode_enabled(ctx) .then(|| self.vim_model.as_ref(ctx).state()) } pub fn vim_mode_enabled(&self, ctx: &AppContext) -> bool { self.supports_vim_mode && AppEditorSettings::as_ref(ctx).vim_mode_enabled() } pub fn enter_vim_normal_mode(&mut self, ctx: &mut ViewContext) { if self.vim_mode_enabled(ctx) { self.vim_escape(ctx); } } /// Send the 'escape' keystroke to the VimFSA. fn vim_escape(&mut self, ctx: &mut ViewContext) { self.vim_keystroke(&Keystroke::parse("escape").expect("escape parses"), ctx) } fn vim_apply_insert_position( &mut self, position: &InsertPosition, ctx: &mut ViewContext, ) { match position { InsertPosition::AtCursor => {} InsertPosition::AfterCursor => { self.model.update(ctx, |model, ctx| { model.vim_move_horizontal_by_offset( 1, &Direction::Forward, false, // keep_selection true, // stop_at_line_boundary ctx, ); }); } InsertPosition::LineFirstNonWhitespace => self.model.update(ctx, |model, ctx| { model.vim_move_to_first_nonwhitespace(false, ctx); }), InsertPosition::LineEnd => self.model.update(ctx, |model, ctx| { model.vim_move_to_line_bound(LineBound::End, false, ctx); }), InsertPosition::LineAbove => { self.model.update(ctx, |model, ctx| { model.vim_newline(true, ctx); }); } InsertPosition::LineBelow => { self.model.update(ctx, |model, ctx| { model.vim_newline(false, ctx); }); } } } /// When in Vim mode, specifically normal mode, the block cursor cannot go past the last /// character on the line as the beam cursor can. We call this "line capping." This helper /// method determines if line capping needs to be enforced, and if so, enforces it. fn vim_maybe_enforce_cursor_line_cap(&mut self, ctx: &mut ViewContext) { if let Some(VimMode::Normal) = self.vim_mode(ctx) { if self.model.as_ref(ctx).vim_needs_line_capping(ctx) { self.model.update(ctx, |model, ctx| { model.vim_enforce_cursor_line_cap(ctx); }); } } } fn user_insert(&mut self, typed: &str, ctx: &mut ViewContext) { if ctx.is_self_focused() { if let Some(first_char) = typed.chars().next() { if typed.chars().count() == 1 { let all_cursors_next_character_matches_char = self.model.update(ctx, |model, ctx| { model.all_cursors_next_character_matches_char(first_char, ctx) }); // If the character is a closing symbol, we want to potentially step over it incase it's already a closing symbol. if CLOSING_SYMBOLS.contains(&first_char) && all_cursors_next_character_matches_char { let buffer = self.model.as_ref(ctx).buffer().as_ref(ctx); let selection_model = self.model.as_ref(ctx).buffer_selection_model().as_ref(ctx); if selection_model.all_single_cursors() { let selections = selection_model.selection_offsets(); let should_step_over = selections.iter().all(|sel| { buffer.char_at(sel.head).is_some_and(|c| c == first_char) }); if should_step_over { self.model.update(ctx, |model, ctx| { model.selection_model().update(ctx, |selection, ctx| { selection.update_selection( warp_editor::content::buffer::BufferSelectAction::MoveRight, warp_editor::content::buffer::AutoScrollBehavior::Selection, ctx, ); }); }); return; } } } // If the character is opening autcomplete symbol, we want to autocomplete it with a closing symbol. if let Some(close) = AUTOCOMPLETE_SYMBOLS.get(&first_char) { self.model.update(ctx, |model, ctx| { model.autocomplete_symbol(first_char, *close, ctx); }); return; } } } self.model.update(ctx, |model, ctx| { model.user_insert(typed, ctx); }); } } pub fn undo(&mut self, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.undo(ctx); }); } fn delete_line_left(&self, ctx: &mut ViewContext) { self.model.update(ctx, |model, ctx| { model.delete_all_left(ctx); }) } fn vim_user_insert(&mut self, text: &str, ctx: &mut ViewContext) { // Only handle Vim input when this editor is focused. if !ctx.is_self_focused() { return; } self.vim_model.update(ctx, |vim_model, ctx| { for c in text.chars() { vim_model.typed_character(c, ctx); } }); } /// Similar to Self::vim_user_insert, but for keystrokes which aren't represented by a char. pub fn vim_keystroke(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext) { // Only handle Vim keystrokes when this editor is focused. if !ctx.is_self_focused() { return; } self.vim_model.update(ctx, |vim_model, ctx| { vim_model.keypress(keystroke, ctx); }); } pub fn open_existing_comment( &mut self, id: &CommentId, location: &EditorLineLocation, comment_text: &str, origin: &CommentOrigin, ctx: &mut ViewContext, ) { let comment_exists = self .comment_locations .iter() .any(|saved_comment| saved_comment.uuid == *id); if !comment_exists { log::warn!( "open_existing_comment: no saved comment found for id {:?}", id ); return; } self.active_comment_editor .update(ctx, |comment_editor, ctx| { comment_editor.reopen_saved_comment( id, Some(location.clone()), comment_text, origin, ctx, ); }); self.model.update(ctx, |editor_model, ctx| { editor_model.reopen_comment_line(id, location, comment_text, origin, ctx); }); ctx.notify(); } } impl Entity for CodeEditorView { type Event = CodeEditorEvent; } impl View for CodeEditorView { fn ui_name() -> &'static str { "CodeEditorView" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let editable = self.is_editable(app); let focused = self.is_focused(app); let blink_cursors = AppEditorSettings::as_ref(app).cursor_blink_enabled(); let display_options = DisplayOptions { debug_bounds: false, focused, blink_cursors, vertical_expansion_behavior: self.display_options.vertical_expansion_behavior, editable, ..Default::default() }; let line_number_config = self.line_number_config(app); let diff_status = if self.display_options.can_show_diff_ui { self.model.as_ref(app).diff_status(app) } else { DiffStatus::default() }; let render_state = self.model.as_ref(app).render_state(); // Align Vim visual tails with the render state's selection coordinate system. // The render model stores selection head/tail as (buffer_offset - 1), so apply the same // transformation to the visual tails to avoid off-by-one mismatches when highlighting. let vim_visual_tails = self .model .as_ref(app) .vim_visual_tails() .iter() .map(|t| t.saturating_sub(&CharOffset::from(1))) .collect::>(); let editor_rich_content = RichTextElement::::new( render_state.clone(), self.self_handle.clone(), display_options, self.display_states.display_state.clone(), self.vim_mode(app), vim_visual_tails, ); let collapsible_diffs = self.display_options.collapsible_diffs; let mut code_editor = EditorWrapper::new( InnerEditor::FullEditor(editor_rich_content), self.display_options.vertical_expansion_behavior, line_number_config, diff_status, self.display_states.wrapper_state_handle.clone(), Box::new(move |click, ctx| match click { GutterRange::DiffHunk { line, in_sliver: true, } => { if collapsible_diffs { ctx.dispatch_typed_action(CodeEditorViewAction::ToggleDiffNav(Some( line.line_range().clone(), ))) } } GutterRange::HiddenSection { line: line_info, expansion_type, } => ctx.dispatch_typed_action(CodeEditorViewAction::HiddenSectionExpansion { line_range: line_info.line_range().clone(), expansion_type, }), _ => {} }), self.display_options .scroll_wheel_behavior .should_handle(focused), self.model.as_ref(app).diff_navigation_state().clone(), if let Some(index) = self.model.as_ref(app).focused_diff_index() { self.model .as_ref(app) .diff() .as_ref(app) .line_range_by_diff_hunk_index(index) .map(|line_count| { LineCount::from(line_count.start)..LineCount::from(line_count.end) }) } else { None }, self.display_options.diff_hunk_as_context, self.display_options.revert_diff_hunk, self.display_options.comment_button, self.comment_locations.clone(), self.display_options.expand_diff_indicator_width_on_hover, self.display_options.gutter_hover_target, self.comment_save_position_id.clone(), self.find_references_save_position_id.clone(), ); let pending_comment = &self .model .as_ref(app) .comments() .as_ref(app) .pending_comment; // Check if there's an open comment in the model and set the comment box if let PendingComment::Open { line, .. } = pending_comment { code_editor.set_comment_box(line.clone(), app); } // Set find references anchor if there's an active request if let Some(offset) = &self.find_references_anchor_offset { let render_state_ref = render_state.as_ref(app); let softwrap_point = render_state_ref.offset_to_softwrap_point(*offset); let line_number = LineCount::from(softwrap_point.row() as usize + 1); // Convert 0-indexed to 1-indexed // Create a simple EditorLineLocation::Current with the line number // We don't have hunk range info here, so use a single-line range let anchor_line = EditorLineLocation::Current { line_number, line_range: line_number..line_number + LineCount::from(1), }; code_editor.set_find_references_anchor(Some(anchor_line)); } let config = DualAxisConfig::Manual { horizontal: AxisConfiguration::Manual( self.display_states.horizontal_scroll_state.clone(), ), vertical: AxisConfiguration::Manual(self.display_states.vertical_scroll_state.clone()), child: NewScrollableElement::finish_scrollable(code_editor), }; let scrollable = NewScrollable::horizontal_and_vertical( config, theme.disabled_text_color(theme.background()).into(), theme.main_text_color(theme.background()).into(), Fill::None, ) .with_horizontal_scrollbar(self.display_options.horizontal_scrollbar_appearance) .with_vertical_scrollbar(self.display_options.vertical_scrollbar_appearance) .with_propagate_mousewheel_if_not_handled(true) .finish(); let inner = match self.display_options.vertical_expansion_behavior { VerticalExpansionBehavior::InfiniteHeight => scrollable, VerticalExpansionBehavior::FillMaxHeight | VerticalExpansionBehavior::GrowToMaxHeight => { Shrinkable::new(1., scrollable).finish() } }; let mut col = Flex::column().with_child(inner); if self.model.as_ref(app).diff_nav_is_active() && self.display_options.show_nav_bar { col.add_child(ChildView::new(&self.nav_bar).finish()); } let mut stack = Stack::new() .with_constrain_absolute_children() .with_child(col.finish()); if let Some(find_bar) = &self.find_bar { if find_bar.as_ref(app).is_open() { stack.add_overlay_child(ChildView::new(find_bar).finish()); } } if self.goto_line_dialog.as_ref(app).is_open() { let dialog = Dismiss::new(ChildView::new(&self.goto_line_dialog).finish()) .on_dismiss(|ctx, _app| { ctx.dispatch_typed_action(CodeEditorViewAction::Escape); }) .finish(); stack.add_overlay_child(dialog); } if !FeatureFlag::EmbeddedCodeReviewComments.is_enabled() { // Render the open comment editor. if let PendingComment::Open { line, .. } = pending_comment { let render_state_ref = render_state.as_ref(app); let vertical_offset = render_state_ref .vertical_offset_at_render_location(line.clone().into_render_line_location()) .unwrap_or_default() + render_state_ref.styles().base_line_height(); let line_location = app.element_position_by_id_at_last_frame( self.window_id, &self.comment_save_position_id, ); let should_render_comment_editor = match line_location { Some(line_location) => self .show_comment_editor_provider .should_show_comment_editor(line_location, app), None => true, }; if should_render_comment_editor { stack.add_positioned_child( ChildView::new(&self.active_comment_editor).finish(), OffsetPositioning::offset_from_parent( vec2f(0., vertical_offset.as_f32()), ParentOffsetBounds::ParentByPosition, ParentAnchor::TopLeft, ChildAnchor::TopLeft, ), ); } } } stack.finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() && self.goto_line_dialog.as_ref(ctx).is_open() { ctx.focus(&self.goto_line_dialog); } } fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { if blur_ctx.is_self_blurred() { ctx.notify(); } } fn keymap_context(&self, app: &AppContext) -> warpui::keymap::Context { let mut context = Self::default_keymap_context(); if self.interaction_state(app) != InteractionState::Editable { context.set.insert(NON_EDITABLE_KEYMAP_CONTEXT); } if let Some(vim_mode) = self.vim_mode(app) { context.set.insert("Vim"); if vim_mode == VimMode::Normal { context.set.insert("VimNormalMode"); } } if self.find_bar.is_some() { context.set.insert("FindBarAvailable"); } context } } pub fn code_text_styles( appearance: &Appearance, font_settings: &FontSettings, line_height_override: Option, ) -> RichTextStyles { let mut styling = rich_text_styles(appearance, font_settings); let theme = appearance.theme(); styling.base_text = ParagraphStyles { font_size: appearance.monospace_font_size(), line_height_ratio: line_height_override.unwrap_or(appearance.line_height_ratio()), font_family: appearance.monospace_font_family(), font_weight: Default::default(), text_color: theme.main_text_color(theme.background()).into_solid(), baseline_ratio: 0.8, fixed_width_tab_size: Some(4), }; styling.block_spacings.text = BlockSpacing { margin: Margin::uniform(0.).with_left(1.), padding: Padding::uniform(0.), }; styling.show_placeholder_text_on_empty_block = false; styling.minimum_paragraph_height = None; styling.cursor_width = 2.; // URLs are not clickable in code editors, so we should not highlight them. styling.highlight_urls = false; styling } #[cfg(feature = "integration_tests")] impl CodeEditorView { pub fn open_goto_line_for_test(&mut self, ctx: &mut ViewContext) { self.show_goto_line(ctx); } pub fn goto_line_confirm_for_test(&mut self, input: &str, ctx: &mut ViewContext) { self.show_goto_line(ctx); let event = GoToLineEvent::Confirm { input: input.to_string(), }; self.handle_goto_line_event(&event, ctx); } } #[cfg(test)] mod view_tests;