Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+562
View File
@@ -0,0 +1,562 @@
use crate::code::editor::comments::{EditorCommentsModel, PendingCommentEvent};
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::{CommentId, CommentOrigin};
use crate::editor::InteractionState;
use crate::notebooks::editor::{
model::NotebooksEditorModel,
rich_text_styles,
view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
};
use crate::notebooks::link::{NotebookLinks, SessionSource};
use crate::settings::FontSettings;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{
ActionButton, ButtonSize, DangerNakedTheme, KeystrokeSource, NakedTheme, PrimaryTheme,
};
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use std::cell::RefCell;
use warp_core::ui::{appearance::Appearance, theme::Fill};
use warp_editor::render::element::VerticalExpansionBehavior;
use warpui::{
elements::{
Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Flex, MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable, Text,
},
keymap::Keystroke,
text_layout::ClipConfig,
units::Pixels,
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
/// Default width of the comment editor, in pixels.
pub(crate) const DEFAULT_COMMENT_MAX_WIDTH: f32 = 750.0;
#[derive(Debug)]
pub enum CommentEditorEvent {
ContentChanged,
CommentSaved {
id: Option<CommentId>,
comment_text: String,
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
line: Option<EditorLineLocation>,
},
CloseEditor,
DeleteComment {
id: CommentId,
},
}
#[derive(Debug)]
pub enum CommentEditorAction {
SaveComment,
CloseEditor,
RemoveComment,
}
pub struct CommentEditor {
/// Comment ID if editing an existing comment, None for new comments.
comment_id: Option<CommentId>,
editor: ViewHandle<RichTextEditorView>,
save_button: ViewHandle<ActionButton>,
close_button: ViewHandle<ActionButton>,
remove_button: ViewHandle<ActionButton>,
line: Option<EditorLineLocation>,
show_remove_button: bool,
save_button_disabled: bool,
laid_out_size: RefCell<Option<Vector2F>>,
is_imported_comment: bool,
}
impl CommentEditor {
pub fn new(
ctx: &mut ViewContext<Self>,
comment_model: ModelHandle<EditorCommentsModel>,
) -> Self {
let editor = create_editable_comment_markdown_editor(None, ctx);
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
ctx.subscribe_to_model(&comment_model, |me, _, event, ctx| {
me.handle_comment_model_event(event, ctx);
});
let (save_button, close_button, remove_button) = Self::create_buttons(ctx);
let mut me = Self {
comment_id: None,
editor,
save_button,
close_button,
remove_button,
line: None,
show_remove_button: false,
save_button_disabled: true,
laid_out_size: RefCell::new(None),
is_imported_comment: false,
};
me.update_save_button_state(ctx);
me
}
#[allow(unused)] // TODO(CODE-1464): use this
pub fn new_embedded(
ctx: &mut ViewContext<Self>,
comment_model: ModelHandle<EditorCommentsModel>,
comment_id: Option<CommentId>,
line: EditorLineLocation,
) -> Self {
let editor = create_editable_comment_markdown_editor(None, ctx);
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
ctx.subscribe_to_model(&comment_model, |me, _, event, ctx| {
me.handle_comment_model_event(event, ctx);
});
let (save_button, close_button, remove_button) = Self::create_buttons(ctx);
let show_remove_button = comment_id.is_some();
let mut me = Self {
comment_id,
editor,
save_button,
close_button,
remove_button,
line: Some(line),
show_remove_button,
save_button_disabled: true,
laid_out_size: RefCell::new(None),
is_imported_comment: false,
};
me.update_save_button_state(ctx);
me
}
#[cfg_attr(not(feature = "local_fs"), allow(unused))]
pub fn comment_text(&self, app: &AppContext) -> String {
self.editor.as_ref(app).model().as_ref(app).markdown(app)
}
#[cfg_attr(not(feature = "local_fs"), allow(unused))]
pub fn get_laid_out_size(&self) -> Option<Vector2F> {
self.laid_out_size.borrow().as_ref().cloned()
}
#[allow(unused)] // TODO(CODE-1464): use this
pub fn set_laid_out_size(&self, value: Vector2F) {
self.laid_out_size.replace(Some(value));
}
fn create_buttons(
ctx: &mut ViewContext<Self>,
) -> (
ViewHandle<ActionButton>,
ViewHandle<ActionButton>,
ViewHandle<ActionButton>,
) {
let save_button = ctx.add_typed_action_view(|ctx| {
ActionButton::new("Comment", PrimaryTheme)
.with_keybinding(
KeystrokeSource::Fixed(Keystroke::parse("cmdorctrl-enter").unwrap_or_default()),
ctx,
)
.on_click(|ctx| {
ctx.dispatch_typed_action(CommentEditorAction::SaveComment);
})
.with_size(ButtonSize::Small)
});
save_button.update(ctx, |button, ctx| {
button.set_disabled(true, ctx);
});
let close_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Cancel", NakedTheme)
.on_click(|ctx| {
ctx.dispatch_typed_action(CommentEditorAction::CloseEditor);
})
.with_size(ButtonSize::Small)
});
let remove_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Remove", DangerNakedTheme)
.on_click(|ctx| {
ctx.dispatch_typed_action(CommentEditorAction::RemoveComment);
})
.with_size(ButtonSize::Small)
});
(save_button, close_button, remove_button)
}
fn update_save_button_state(&mut self, ctx: &mut ViewContext<Self>) {
let is_empty = self.editor.as_ref(ctx).model().as_ref(ctx).is_empty(ctx);
if is_empty != self.save_button_disabled {
self.save_button_disabled = is_empty;
self.save_button.update(ctx, |button, ctx| {
button.set_disabled(is_empty, ctx);
});
}
}
fn handle_comment_model_event(
&mut self,
event: &PendingCommentEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
PendingCommentEvent::NewPendingComment(line) => self.attach_to_line(line, ctx),
PendingCommentEvent::ReopenPendingComment {
id,
line,
comment_text,
origin,
} => {
self.reopen_saved_comment(id, Some(line.clone()), comment_text, origin, ctx);
}
}
}
fn handle_editor_event(&mut self, event: &EditorViewEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorViewEvent::Edited => {
self.update_save_button_state(ctx);
ctx.emit(CommentEditorEvent::ContentChanged);
}
EditorViewEvent::CmdEnter => {
self.save_comment(ctx);
}
EditorViewEvent::EscapePressed => {
// Dismiss the comment composer when pressing Escape on an empty draft.
if self.editor.as_ref(ctx).model().as_ref(ctx).is_empty(ctx) {
self.reset(ctx);
ctx.emit(CommentEditorEvent::CloseEditor);
}
}
_ => {}
}
}
fn attach_to_line(&mut self, line: &EditorLineLocation, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
// TODO: clear_buffer doesn't properly clear code blocks.
// The `reset_with_markdown` call below is a band-aid fix.
editor.reset_with_markdown("", ctx);
});
self.line = Some(line.clone());
self.update_save_button_state(ctx);
}
pub fn reopen_saved_comment(
&mut self,
id: &CommentId,
line: Option<EditorLineLocation>,
comment_text: &str,
origin: &CommentOrigin,
ctx: &mut ViewContext<Self>,
) {
self.editor.update(ctx, |editor, ctx| {
editor.model().update(ctx, |model, ctx| {
model.reset_with_markdown(comment_text, ctx);
});
});
self.comment_id = Some(*id);
self.line = line;
self.show_remove_button = true;
self.is_imported_comment = origin.is_imported_from_github();
self.save_button.update(ctx, |button, ctx| {
button.set_label("Update", ctx);
});
ctx.notify();
self.update_save_button_state(ctx);
}
fn reset(&mut self, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor, ctx| {
// TODO: system_clear_buffer doesn't properly clear code blocks.
// The `reset_with_markdown` call below is a band-aid fix.
editor.reset_with_markdown("", ctx);
});
self.comment_id = None;
self.line = None;
self.show_remove_button = false;
self.is_imported_comment = false;
self.save_button.update(ctx, |button, ctx| {
button.set_label("Comment", ctx);
});
ctx.notify();
self.update_save_button_state(ctx);
}
pub fn save_comment(&mut self, ctx: &mut ViewContext<Self>) {
let comment_text = self.editor.as_ref(ctx).model().as_ref(ctx).markdown(ctx);
if comment_text.trim().is_empty() {
log::debug!("CommentEditor attempted to save empty comment, ignoring");
return;
}
ctx.emit(CommentEditorEvent::CommentSaved {
id: self.comment_id,
comment_text: comment_text.clone(),
line: self.line.clone(),
});
self.reset(ctx);
ctx.emit(CommentEditorEvent::CloseEditor);
}
fn render_github_import_indicator(
&self,
appearance: &Appearance,
background: ColorU,
) -> Box<dyn Element> {
let theme = appearance.theme();
let sub_text_color = theme.sub_text_color(Fill::Solid(background)).into_solid();
let icon = Icon::Github
.to_warpui_icon(Fill::Solid(sub_text_color))
.finish();
let label = Text::new(
"Comment imported from GitHub".to_string(),
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.soft_wrap(false)
.with_clip(ClipConfig::end())
.with_color(sub_text_color)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(
ConstrainedBox::new(icon)
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_child(Shrinkable::new(1., label).finish())
.finish()
}
fn render_action_buttons(&self) -> Box<dyn Element> {
let mut action_buttons = vec![ChildView::new(&self.close_button).finish()];
if self.show_remove_button {
action_buttons.push(ChildView::new(&self.remove_button).finish());
}
action_buttons.push(ChildView::new(&self.save_button).finish());
Flex::row()
.with_spacing(4.)
.with_children(action_buttons)
.with_main_axis_alignment(MainAxisAlignment::End)
.finish()
}
fn render_footer_row(&self, appearance: &Appearance, background: ColorU) -> Box<dyn Element> {
let action_buttons = self.render_action_buttons();
let footer_row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if self.is_imported_comment {
footer_row
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_child(
Shrinkable::new(
1.,
self.render_github_import_indicator(appearance, background),
)
.finish(),
)
.with_child(action_buttons)
.finish()
} else {
footer_row
.with_main_axis_alignment(MainAxisAlignment::End)
.with_child(action_buttons)
.finish()
}
}
}
impl View for CommentEditor {
fn ui_name() -> &'static str {
"CommentEditor"
}
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::handle(ctx).as_ref(ctx);
let theme = appearance.theme();
let background = blended_colors::neutral_2(theme);
let border_color = blended_colors::neutral_4(theme);
let footer_row = self.render_footer_row(appearance, background);
Container::new(
ConstrainedBox::new(
Flex::column()
.with_child(
Shrinkable::new(
1.,
Container::new(
Clipped::new(ChildView::new(&self.editor).finish()).finish(),
)
.with_padding_bottom(4.)
.with_padding_top(8.)
.with_horizontal_padding(12.)
.finish(),
)
.finish(),
)
.with_child(
Container::new(footer_row)
.with_vertical_padding(8.)
.with_horizontal_padding(8.)
.with_border(Border::top(1.).with_border_fill(border_color))
.finish(),
)
.finish(),
)
.with_max_height(200.)
.with_max_width(DEFAULT_COMMENT_MAX_WIDTH)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.with_background_color(background)
.with_border(Border::all(1.).with_border_fill(border_color))
.finish()
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.editor);
}
}
}
impl TypedActionView for CommentEditor {
type Action = CommentEditorAction;
fn handle_action(&mut self, action: &CommentEditorAction, ctx: &mut ViewContext<Self>) {
match action {
CommentEditorAction::SaveComment => self.save_comment(ctx),
CommentEditorAction::CloseEditor => {
self.reset(ctx);
ctx.emit(CommentEditorEvent::CloseEditor);
}
CommentEditorAction::RemoveComment => {
if let Some(comment_id) = self.comment_id {
self.reset(ctx);
ctx.emit(CommentEditorEvent::DeleteComment { id: comment_id });
ctx.emit(CommentEditorEvent::CloseEditor);
}
}
}
}
}
impl Entity for CommentEditor {
type Event = CommentEditorEvent;
}
pub(crate) fn create_editable_comment_markdown_editor<V>(
markdown_content: Option<&str>,
ctx: &mut ViewContext<V>,
) -> ViewHandle<RichTextEditorView>
where
V: View,
{
create_comment_markdown_editor_inner(
markdown_content,
false,
Some(Pixels::new(DEFAULT_COMMENT_MAX_WIDTH)),
ctx,
)
}
pub(crate) fn create_readonly_comment_markdown_editor<V>(
markdown_content: &str,
disable_scrolling: bool,
max_width: Option<Pixels>,
ctx: &mut ViewContext<V>,
) -> ViewHandle<RichTextEditorView>
where
V: View,
{
let editor = create_comment_markdown_editor_inner(
Some(markdown_content),
disable_scrolling,
max_width,
ctx,
);
editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
editor
}
fn create_comment_markdown_editor_inner<V>(
markdown_content: Option<&str>,
disable_scrolling: bool,
max_width: Option<Pixels>,
ctx: &mut ViewContext<V>,
) -> ViewHandle<RichTextEditorView>
where
V: View,
{
let rich_text_styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
let window_id = ctx.window_id();
let parent_view_id = ctx.view_id();
let model = ctx.add_model(|ctx| NotebooksEditorModel::new(rich_text_styles, window_id, ctx));
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
let parent_view_name = ctx.view_name(window_id, parent_view_id).unwrap_or_default();
let parent_position_id = format!("{}_{}", parent_view_name, parent_view_id);
// Embedded objects (notebooks, workflows) are disabled since comments don't support them.
// Shell command execution is disabled so Cmd/Ctrl+Enter submits the comment instead.
// Block insertion menu (slash menu) is disabled since the comment editor is small.
let editor = ctx.add_typed_action_view(|ctx| {
RichTextEditorView::new(
parent_position_id,
model.clone(),
links,
RichTextEditorConfig {
gutter_width: Some(0.0),
embedded_objects_enabled: Some(false),
vertical_expansion_behavior: Some(VerticalExpansionBehavior::GrowToMaxHeight),
max_width,
can_execute_shell_commands: Some(false),
disable_block_insertion_menu: true,
disable_scrolling,
},
ctx,
)
});
if let Some(comment_content) = markdown_content {
model.update(ctx, |m, ctx| {
m.reset_with_markdown(comment_content, ctx);
});
}
editor
}
#[cfg(test)]
#[path = "comment_editor_tests.rs"]
mod tests;
+158
View File
@@ -0,0 +1,158 @@
use std::sync::Arc;
use warpui::{
platform::WindowStyle, presenter::ChildView, App, Element, Entity, TypedActionView, View,
ViewHandle, WindowId,
};
use super::{create_editable_comment_markdown_editor, create_readonly_comment_markdown_editor};
use crate::notebooks::editor::view::RichTextEditorView;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::RepoMetadataModel;
use crate::{
appearance::Appearance,
auth::AuthStateProvider,
cloud_object::model::persistence::CloudModel,
notebooks::{
editor::keys::NotebookKeybindings,
link::{NotebookLinks, SessionSource},
},
search::files::model::FileSearchModel,
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::keys::TerminalKeybindings,
test_util::settings::initialize_settings_for_tests,
workspace::ActiveSession,
GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces,
};
struct TestView {
editor: ViewHandle<RichTextEditorView>,
}
enum CommentEditorMode {
Editable,
Readonly,
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn ui_name() -> &'static str {
"CommentEditorTestView"
}
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
ChildView::new(&self.editor).finish()
}
}
impl TypedActionView for TestView {
type Action = ();
}
fn initialize_editor(
app: &mut App,
mode: CommentEditorMode,
) -> (
WindowId,
ViewHandle<RichTextEditorView>,
ViewHandle<TestView>,
) {
initialize_settings_for_tests(app);
let global_resources = GlobalResourceHandles::mock(app);
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| ActiveSession::default());
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(FileSearchModel::new);
app.add_singleton_model(NotebookKeybindings::new);
app.add_singleton_model(TerminalKeybindings::new);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
let team_client_mock = Arc::new(MockTeamClient::new());
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
team_client_mock.clone(),
workspace_client_mock.clone(),
vec![],
ctx,
)
});
let (window, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
let window_id = ctx.window_id();
let _links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
let editor = match mode {
CommentEditorMode::Editable => create_editable_comment_markdown_editor(None, ctx),
CommentEditorMode::Readonly => create_readonly_comment_markdown_editor(
"```rust\nfn main() {}\n```",
false,
None,
ctx,
),
};
TestView { editor }
});
let editor_view = app.read(|ctx| test_view.as_ref(ctx).editor.clone());
(window, editor_view, test_view)
}
#[test]
fn test_editable_comment_editor_keeps_final_trailing_newline_for_non_empty_code_blocks() {
App::test((), |mut app| async move {
let (_window, editor_view, _test_view) =
initialize_editor(&mut app, CommentEditorMode::Editable);
let render_model = editor_view.read(&app, |editor, ctx| {
editor.model().as_ref(ctx).render_state().clone()
});
let pending_layout =
render_model.read(&app, |render_state, _| render_state.layout_complete());
pending_layout.await;
assert_eq!(
render_model.read(&app, |render_state, _| render_state.blocks()),
1
);
editor_view.update(&mut app, |editor, ctx| {
editor.reset_with_markdown("```rust\nfn main() {}\n```", ctx);
});
let pending_layout =
render_model.read(&app, |render_state, _| render_state.layout_complete());
pending_layout.await;
assert_eq!(
render_model.read(&app, |render_state, _| render_state.blocks()),
2
);
});
}
#[test]
fn test_readonly_comment_editor_hides_final_trailing_newline_for_non_empty_code_blocks() {
App::test((), |mut app| async move {
let (_window, editor_view, _test_view) =
initialize_editor(&mut app, CommentEditorMode::Readonly);
let render_model = editor_view.read(&app, |editor, ctx| {
editor.model().as_ref(ctx).render_state().clone()
});
let pending_layout =
render_model.read(&app, |render_state, _| render_state.layout_complete());
pending_layout.await;
assert_eq!(
render_model.read(&app, |render_state, _| render_state.blocks()),
1
);
});
}
+98
View File
@@ -0,0 +1,98 @@
use chrono::{DateTime, Local};
use warpui::{Entity, ModelContext};
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::{
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, LineDiffContent,
};
#[derive(Debug, Clone)]
pub enum PendingCommentEvent {
NewPendingComment(EditorLineLocation),
ReopenPendingComment {
id: CommentId,
line: EditorLineLocation,
comment_text: String,
origin: CommentOrigin,
},
}
pub enum PendingComment {
Closed,
Open { line: EditorLineLocation },
}
pub struct EditorCommentsModel {
pub pending_comment: PendingComment,
}
impl EditorCommentsModel {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
Self {
pending_comment: PendingComment::Closed,
}
}
}
impl Entity for EditorCommentsModel {
type Event = PendingCommentEvent;
}
/// Used solely at the CodeEditorView level, when we don't know
/// the file path, and later converted to a full `AttachedReviewComment`.
#[derive(Clone, Debug)]
pub struct EditorReviewComment {
pub id: CommentId,
pub line: EditorLineLocation,
pub diff_content: LineDiffContent,
pub comment_content: String,
pub last_update_time: DateTime<Local>,
}
impl EditorReviewComment {
pub(crate) fn new(
line: EditorLineLocation,
diff_content: LineDiffContent,
comment_content: String,
) -> Self {
Self {
id: CommentId::new(),
line,
diff_content,
comment_content,
last_update_time: Local::now(),
}
}
pub(crate) fn new_with_id(
id: CommentId,
line: EditorLineLocation,
diff_content: LineDiffContent,
comment_content: String,
) -> Self {
Self {
id,
line,
diff_content,
comment_content,
last_update_time: Local::now(),
}
}
}
impl TryFrom<AttachedReviewComment> for EditorReviewComment {
type Error = ();
fn try_from(comment: AttachedReviewComment) -> Result<Self, Self::Error> {
match comment.target {
AttachedReviewCommentTarget::Line { content, line, .. } => Ok(EditorReviewComment {
id: comment.id,
line,
diff_content: content,
comment_content: comment.content,
last_update_time: comment.last_update_time,
}),
_ => Err(()),
}
}
}
+821
View File
@@ -0,0 +1,821 @@
#![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 std::{collections::HashMap, ops::Range, rc::Rc, sync::Arc};
use futures::stream::AbortHandle;
use itertools::Itertools;
use pathfinder_color::ColorU;
use rangemap::RangeMap;
use similar::{ChangeTag, DiffOp, TextDiff};
use string_offset::CharOffset;
use warp_core::ui::theme::Fill;
use warp_editor::{
content::{edit::TemporaryBlock, version::BufferVersion},
multiline::{AnyMultilineString, MultilineStr, MultilineString, LF},
render::model::{Decoration, LineCount, LineDecoration},
};
use warpui::{Entity, ModelContext};
use super::super::DiffResult;
use crate::{
appearance::Appearance,
code::editor::{line::EditorLineLocation, line_iterator::LineIterator},
};
use warp_core::ui::theme::AnsiColorIdentifier;
const OVERLAY_ALPHA: u8 = 56;
const INLINE_OVERLAY_ALPHA: u8 = 71;
/// Get the theme-appropriate add color
pub(crate) fn add_color(appearance: &Appearance) -> ColorU {
AnsiColorIdentifier::Green
.to_ansi_color(&appearance.theme().terminal_colors().normal)
.into()
}
/// Get the theme-appropriate remove color
pub(crate) fn remove_color(appearance: &Appearance) -> ColorU {
AnsiColorIdentifier::Red
.to_ansi_color(&appearance.theme().terminal_colors().normal)
.into()
}
/// Get the theme-appropriate replace color
pub(crate) fn replace_color(appearance: &Appearance) -> ColorU {
AnsiColorIdentifier::Yellow
.to_ansi_color(&appearance.theme().terminal_colors().normal)
.into()
}
/// Get the theme-appropriate remove overlay color
pub(crate) fn remove_overlay_color(appearance: &Appearance) -> ColorU {
let ansi_color =
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal);
let mut color: ColorU = ansi_color.into();
color.a = OVERLAY_ALPHA;
color
}
/// Get the theme-appropriate add overlay color
pub(crate) fn add_overlay_color(appearance: &Appearance) -> ColorU {
let ansi_color =
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal);
let mut color: ColorU = ansi_color.into();
color.a = OVERLAY_ALPHA;
color
}
/// Get the theme-appropriate add inline overlay color
pub(crate) fn add_inline_overlay_color(appearance: &Appearance) -> ColorU {
let ansi_color =
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal);
let mut color: ColorU = ansi_color.into();
color.a = INLINE_OVERLAY_ALPHA;
color
}
/// Get the theme-appropriate remove inline overlay color
pub(crate) fn remove_inline_overlay_color(appearance: &Appearance) -> ColorU {
let ansi_color =
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal);
let mut color: ColorU = ansi_color.into();
color.a = INLINE_OVERLAY_ALPHA;
color
}
pub enum DiffModelEvent {
DiffUpdated {
version: BufferVersion,
should_recalculate_hidden_lines: bool,
},
UnifiedDiffComputed(Rc<DiffResult>),
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ChangeType {
Replacement {
replaced_range: Range<usize>,
insertion: Vec<Range<usize>>,
deletion: Vec<Range<usize>>,
},
Addition,
}
#[derive(Debug, Default, Clone)]
pub struct DiffStatus {
/// A non-deletion change that maps the current range of content to an old range in base.
change_mapping: RangeMap<usize, ChangeType>,
/// A deletion that maps a line index in current content to an old range in base.
deletion_mapping: HashMap<usize, Range<usize>>,
}
impl DiffStatus {
/// Returns the number of lines added and removed in the current diff.
pub fn get_diff_lines(&self) -> (usize, usize) {
let mut lines_added = 0;
let mut lines_removed = 0;
// Count changes/additions
for (new_range, change_type) in self.change_mapping.iter() {
let new_range_lines =
(LineCount::from(new_range.end) - LineCount::from(new_range.start)).as_usize();
match change_type {
ChangeType::Addition => lines_added += new_range_lines,
ChangeType::Replacement { replaced_range, .. } => {
let old_range_lines = (LineCount::from(replaced_range.end)
- LineCount::from(replaced_range.start))
.as_usize();
lines_added += new_range_lines;
lines_removed += old_range_lines;
}
}
}
// Count deletions
for range in self.deletion_mapping.values() {
lines_removed += (LineCount::from(range.end) - LineCount::from(range.start)).as_usize();
}
(lines_added, lines_removed)
}
/// Retrieve the hunk to render for a given line number.
pub fn diff_hunk(
&self,
line_num: LineCount,
appearance: &Appearance,
) -> Option<DiffHunkDisplay> {
let line_num = line_num.as_usize();
if self.deletion_mapping.contains_key(&line_num) {
return Some(DiffHunkDisplay::Remove(remove_color(appearance)));
}
match self.change_mapping.get(&line_num) {
Some(ChangeType::Replacement { .. }) => Some(DiffHunkDisplay::Replacement {
collapsed_color: replace_color(appearance),
add_color: add_color(appearance),
remove_color: remove_color(appearance),
}),
Some(ChangeType::Addition) => Some(DiffHunkDisplay::Add(add_color(appearance))),
None => None,
}
}
/// Return the range of diff hunk lines (if any) containing the given line.
/// line_count is the line count assigned in the EditorWrapper element.
/// This is 0-indexed.
/// For deleted diff hunks, all lines will have the line_count of the line directly after
/// the deleted section in the new current state of the file.
/// For changed diff hunks, all lines in the old version (removed section of the hunk)
/// will have the line number of the first line in the new version of the hunk.
pub fn removed_diff_range(&self, line_count: LineCount) -> Option<Range<LineCount>> {
let line_num = line_count.as_usize();
if self.deletion_mapping.contains_key(&(line_num)) {
return Some(LineCount::from(line_num)..LineCount::from(line_num));
}
// Check if this is a replacement (change with removed lines)
self.added_diff_range(line_count)
}
/// Return the range of diff hunk lines (if any) containing the given line.
/// line_count is the line count assigned in the EditorWrapper element.
/// This is 0-indexed.
pub fn added_diff_range(&self, line_num: LineCount) -> Option<Range<LineCount>> {
let line_num = line_num.as_usize();
self.change_mapping
.get_key_value(&line_num)
.map(|(key, _)| LineCount::from(key.start)..LineCount::from(key.end))
}
}
/// The colors used to represent the diff hunks in the editor.
#[derive(Debug, Clone, Copy)]
pub enum DiffHunkDisplay {
Add(ColorU),
Replacement {
collapsed_color: ColorU,
add_color: ColorU,
remove_color: ColorU,
},
Remove(ColorU),
}
/// A single renderable diff hunk. It contains the information needed to decorate the render model.
#[derive(Debug, Clone)]
pub enum RenderableDiffHunk {
Add {
line_decoration: LineDecoration,
},
Replace {
line_decoration: LineDecoration,
inline_highlights: Vec<(usize, Range<usize>)>,
removed_lines: Vec<TemporaryBlock>,
},
Deletion {
removed_lines: Vec<TemporaryBlock>,
},
}
/// Model that tracks the line-by-line diff status of the editor content.
pub struct DiffModel {
/// Store base in an Arc to avoid cloning the underlying data on every content change.
base: Option<Arc<MultilineString<LF>>>,
status: DiffStatus,
abort_handle: Option<(AbortHandle, BufferVersion)>,
}
impl DiffModel {
pub fn new() -> Self {
Self {
base: None,
status: DiffStatus::default(),
abort_handle: None,
}
}
/// Total number of diff hunks in the current diff model.
pub fn diff_hunk_count(&self) -> usize {
self.status.change_mapping.len() + self.status.deletion_mapping.len()
}
/// Given an index of a diff hunk, expand it to the range of lines the hunk describes
/// in the current buffer.
pub fn line_range_by_diff_hunk_index(&self, index: usize) -> Option<Range<usize>> {
self.added_or_changed_lines()
.chain(
self.status
.deletion_mapping
.keys()
.map(|index| *index..*index),
)
.sorted_by(|a, b| Ord::cmp(&a.start, &b.start))
.nth(index)
}
/// Get a single renderable diff hunk by its index.
pub fn renderable_diff_hunk_by_index<'a>(
&self,
index: usize,
lines: &mut LineIterator<'a, impl Iterator<Item = &'a str>>,
appearance: &Appearance,
) -> Option<RenderableDiffHunk> {
let (range, is_addition) = self.diff_by_index(index)?;
if !is_addition {
let deleted_range = self.status.deletion_mapping.get(&range.start)?;
let mut removed_lines = Vec::with_capacity(deleted_range.len());
if let Ok(lines_in_range) = lines.lines_in_range(deleted_range) {
for line in lines_in_range {
let mut content = line.to_string();
if !content.ends_with('\n') {
content.push('\n');
}
removed_lines.push(TemporaryBlock {
content,
insert_before: LineCount::from(range.start),
line_decoration: Some(remove_overlay_color(appearance).into()),
inline_text_decorations: Vec::new(),
});
}
}
Some(RenderableDiffHunk::Deletion { removed_lines })
} else {
match self.status.change_mapping.get(&range.start)? {
ChangeType::Replacement {
replaced_range,
insertion,
deletion,
} => {
let mut removed_lines = Vec::new();
// Inline highlight indices are given over the entire multiline range.
// We need to split them into per-line decorations.
let mut start_char = 0;
if let Ok(lines_in_range) = lines.lines_in_range(replaced_range) {
for line in lines_in_range {
let mut content = line.to_string();
if !content.ends_with('\n') {
content.push('\n');
}
let inline_text_decorations = deletion
.iter()
.filter_map(|inline| {
let line_start = start_char;
let line_end = start_char + line.chars().count();
let overlap_start = inline.start.max(line_start);
let overlap_end = inline.end.min(line_end);
if overlap_start < overlap_end {
Some(
Decoration::new(
CharOffset::from(overlap_start - line_start),
CharOffset::from(overlap_end - line_start),
)
.with_background(Fill::Solid(
remove_inline_overlay_color(appearance),
)),
)
} else {
None
}
})
.collect_vec();
removed_lines.push(TemporaryBlock {
content,
insert_before: LineCount::from(range.start),
line_decoration: Some(remove_overlay_color(appearance).into()),
inline_text_decorations,
});
start_char += line.chars().count() + 1;
}
}
Some(RenderableDiffHunk::Replace {
line_decoration: LineDecoration {
start: LineCount::from(range.start),
end: LineCount::from(range.end),
overlay: add_overlay_color(appearance).into(),
},
inline_highlights: insertion
.iter()
.map(|inline_change| (range.start, inline_change.clone()))
.collect(),
removed_lines,
})
}
ChangeType::Addition => Some(RenderableDiffHunk::Add {
line_decoration: LineDecoration {
start: LineCount::from(range.start),
end: LineCount::from(range.end),
overlay: add_overlay_color(appearance).into(),
},
}),
}
}
}
/// Returns the number of diff hunks before the given line number.
pub fn diff_hunk_count_before_line(&self, line: usize) -> usize {
self.added_or_changed_lines()
.chain(
self.status
.deletion_mapping
.keys()
.map(|index| *index..*index + 1),
)
.filter(|range| range.start < line)
.count()
}
/// Given a diff hunk index, calculate what is the reverse action that undo this diff.
pub fn reverse_action_by_diff_hunk_index(
&self,
index: usize,
) -> Option<(Range<usize>, String)> {
let line_range = self.line_range_by_diff_hunk_index(index)?;
if let Some(replaced_range) = self.status.deletion_mapping.get(&line_range.start) {
let text = self.base_text_by_line_range(replaced_range)?;
return Some((line_range, text));
}
if let Some(change) = self.status.change_mapping.get(&line_range.start) {
return match change {
ChangeType::Addition => Some((line_range, "".to_string())),
ChangeType::Replacement { replaced_range, .. } => {
let text = self.base_text_by_line_range(replaced_range)?;
return Some((line_range, text));
}
};
}
None
}
/// Given a range of lines, return the corresponding substring in the base text.
/// Note that all lines will end with a trailing newline.
fn base_text_by_line_range(&self, replaced_range: &Range<usize>) -> Option<String> {
let base_text = self.base.as_ref()?;
let mut text = base_text
.lines()
.skip(replaced_range.start)
.take(replaced_range.len())
.join("\n");
text.push('\n');
Some(text)
}
/// Returns the content of a deleted line given the location of a removed line.
/// Returns None if the info is not for a removed line or if any lookup fails.
pub fn deleted_line_content(&self, info: &EditorLineLocation) -> Option<String> {
let index = self.deleted_line_to_base_line_index(info)?;
self.base_line(index)
}
/// Convert a deleted line location to a line index in the base version of the text.
pub fn deleted_line_to_base_line_index(&self, info: &EditorLineLocation) -> Option<usize> {
// Extract line_number and index from Removed variant
let (line_number, index) = match info {
EditorLineLocation::Removed {
line_number, index, ..
} => (line_number.as_usize(), *index),
_ => return None,
};
// First check if this is a pure deletion. Note that deletion maps to line AFTER the removed line.
// CODE-1638: Use line_number + 1 to align with deletion_mapping's off-by-one convention.
if let Some(removed_range) = self.status.deletion_mapping.get(&line_number) {
return Some(removed_range.start + index);
}
// Check if this is a replacement (change with removed lines)
if let Some(ChangeType::Replacement { replaced_range, .. }) =
self.status.change_mapping.get(&line_number)
{
return Some(replaced_range.start + index);
}
None
}
/// Convert a line index in the base version of the text to an editor line location.
pub fn base_line_index_to_line_location(&self, index: usize) -> Option<EditorLineLocation> {
for (line_range, change) in self.status.change_mapping.iter() {
if let ChangeType::Replacement { replaced_range, .. } = change {
if replaced_range.contains(&index) {
return Some(EditorLineLocation::Removed {
// Subtracting 1 as the diff is currently represented as attaching to the _previous line_.
line_number: LineCount::from(line_range.start),
line_range: LineCount::from(line_range.start)
..LineCount::from(line_range.end),
index: index - replaced_range.start,
});
}
}
}
for (line_range, replaced_range) in self.status.deletion_mapping.iter() {
if replaced_range.contains(&index) {
return Some(EditorLineLocation::Removed {
line_number: LineCount::from(*line_range),
line_range: LineCount::from(*line_range)..LineCount::from(*line_range),
index: index - replaced_range.start,
});
}
}
None
}
pub fn is_line_added_or_changed(&self, line_num: &LineCount) -> bool {
let line_num = line_num.as_usize();
self.status.change_mapping.get(&line_num).is_some()
}
pub fn base_line(&self, line_index: usize) -> Option<String> {
// Extract the line content
let line = self.base.as_ref()?.lines().nth(line_index)?;
Some(line.to_string())
}
pub fn base_line_count(&self) -> usize {
self.base
.as_ref()
.map(|base| base.lines().count())
.unwrap_or_default()
}
/// Returns an iterator over all the line numbers in the current buffer that are
/// added or changed from the base buffer.
pub fn added_or_changed_lines(&self) -> impl Iterator<Item = Range<usize>> + '_ {
self.status
.change_mapping
.iter()
.map(|(row_range, _)| row_range.clone())
}
pub fn modified_lines(&self) -> impl Iterator<Item = Range<usize>> + '_ {
self.added_or_changed_lines().chain(
self.status
.deletion_mapping
.keys()
.map(|index| *index..*index + 1),
)
}
fn diff_by_index(&self, index: usize) -> Option<(Range<usize>, bool)> {
self.added_or_changed_lines()
.map(|range| (range, true))
.chain(
self.status
.deletion_mapping
.keys()
.map(|index| (*index..*index + 1, false)),
)
.sorted_by(|a, b| Ord::cmp(&a.0.start, &b.0.start))
.nth(index)
}
pub fn diff_status(&self) -> &DiffStatus {
&self.status
}
pub fn set_base(&mut self, base: MultilineString<LF>) {
self.base = Some(Arc::new(base));
if let Some((abort_handle, _)) = self.abort_handle.take() {
abort_handle.abort();
}
}
pub fn base(&self) -> Option<Arc<MultilineString<LF>>> {
self.base.clone()
}
/// Given the new content, compute the new set of diff contents.
pub fn compute_diff(
&mut self,
new: MultilineString<LF>,
should_recalculate_hidden_lines: bool,
version: BufferVersion,
ctx: &mut ModelContext<Self>,
) {
if let Some((abort_handle, current_version)) = self.abort_handle.take() {
// Do not abort a diff computation for the same buffer version. Early return instead.
if current_version != version {
abort_handle.abort();
} else {
self.abort_handle = Some((abort_handle, version));
return;
}
}
let Some(base_text) = self.base.clone() else {
return;
};
let handle = ctx
.spawn(
async move { Self::compute_diff_internal(&base_text, &new).await },
move |model, (change_mapping, deletion_mapping), ctx| {
model.status.change_mapping = change_mapping;
model.status.deletion_mapping = deletion_mapping;
log::debug!("diff status updated: {:#?}", &model.status);
ctx.emit(DiffModelEvent::DiffUpdated {
should_recalculate_hidden_lines,
version,
});
},
)
.abort_handle();
self.abort_handle = Some((handle, version));
}
#[cfg(test)]
async fn compute_diff_for_test(&mut self, new: String) {
let Some(base_text) = self.base.clone() else {
return;
};
let new = AnyMultilineString::infer(new);
let (change_mapping, deletion_mapping) =
Self::compute_diff_internal(&base_text, new.to_format().as_ref()).await;
self.status.change_mapping = change_mapping;
self.status.deletion_mapping = deletion_mapping;
}
pub fn retrieve_unified_diff(
&mut self,
new: AnyMultilineString,
file_name: String,
ctx: &mut ModelContext<Self>,
) {
let Some(base_text) = self.base.clone() else {
return;
};
ctx.spawn(
async move {
let new = new.to_format();
Self::retrieve_unified_diff_internal(&base_text, new.as_ref(), file_name.as_str())
.await
},
|_, unified_diff, ctx| {
ctx.emit(DiffModelEvent::UnifiedDiffComputed(Rc::new(unified_diff)));
},
);
}
async fn retrieve_unified_diff_internal(
base: &MultilineStr<LF>,
new: &MultilineStr<LF>,
file_name: &str,
) -> DiffResult {
if base == new {
return DiffResult {
unified_diff: String::new(),
lines_added: 0,
lines_removed: 0,
};
}
// Show 3 context lines (standard of git diff).
let text_diff = TextDiff::from_lines(base.as_str(), new.as_str());
// Calculate diff statistics.
let mut lines_added = 0;
let mut lines_removed = 0;
for op in text_diff.ops() {
match op {
DiffOp::Equal { .. } => (),
DiffOp::Delete { old_len, .. } => lines_removed += old_len,
DiffOp::Insert { new_len, .. } => lines_added += new_len,
DiffOp::Replace {
old_len, new_len, ..
} => {
lines_added += new_len;
lines_removed += old_len;
}
}
}
DiffResult {
unified_diff: text_diff
.unified_diff()
.context_radius(3)
.header(file_name, file_name)
.missing_newline_hint(false)
.to_string(),
lines_added,
lines_removed,
}
}
async fn compute_diff_internal(
base: &MultilineStr<LF>,
new: &MultilineStr<LF>,
) -> (RangeMap<usize, ChangeType>, HashMap<usize, Range<usize>>) {
let diffs = TextDiff::configure()
.algorithm(similar::Algorithm::Patience)
.diff_lines(base.as_str(), new.as_str());
let mut deletion_mapping = HashMap::new();
let mut change_mapping = RangeMap::new();
for change in diffs.ops() {
futures_lite::future::yield_now().await;
match change {
DiffOp::Equal { .. } => continue,
DiffOp::Delete {
old_index,
old_len,
new_index,
} => {
deletion_mapping.insert(*new_index, *old_index..*old_index + *old_len);
}
DiffOp::Insert {
new_index, new_len, ..
} => change_mapping.insert(*new_index..*new_index + *new_len, ChangeType::Addition),
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => {
let (inline_deletion, inline_insertion) = record_replacement(change, &diffs);
change_mapping.insert(
*new_index..*new_index + *new_len,
ChangeType::Replacement {
replaced_range: *old_index..*old_index + *old_len,
insertion: inline_insertion,
deletion: inline_deletion,
},
)
}
}
}
coalesce_replacements(&diffs, &mut deletion_mapping, &mut change_mapping);
(change_mapping, deletion_mapping)
}
}
/// `similar` can represent a logical replacement as separate Delete + Insert ops at the same
/// `new_index`. When that happens, we end up with a deletion hunk and an addition hunk for
/// the same logical change. Coalesce those into a single Replacement entry.
fn coalesce_replacements<'a>(
diffs: &'a TextDiff<'a, 'a, 'a, str>,
deletion_mapping: &mut HashMap<usize, Range<usize>>,
change_mapping: &mut RangeMap<usize, ChangeType>,
) {
let mut deletions_to_remove = Vec::new();
let mut additions_to_remove = Vec::new();
let mut replacements_to_insert = Vec::new();
for (&new_index, old_range) in deletion_mapping.iter() {
let Some((new_range, change)) = change_mapping.get_key_value(&new_index) else {
continue;
};
if new_range.start != new_index {
continue;
}
if !matches!(change, ChangeType::Addition) {
continue;
}
let old_len = old_range.len();
let new_len = new_range.len();
let replace_op = DiffOp::Replace {
old_index: old_range.start,
old_len,
new_index,
new_len,
};
let (inline_deletion, inline_insertion) = record_replacement(&replace_op, diffs);
deletions_to_remove.push(new_index);
additions_to_remove.push(new_range.clone());
replacements_to_insert.push((
new_range.clone(),
ChangeType::Replacement {
replaced_range: old_range.clone(),
insertion: inline_insertion,
deletion: inline_deletion,
},
));
}
for new_range in additions_to_remove {
change_mapping.remove(new_range);
}
deletion_mapping.retain(|new_index, _| !deletions_to_remove.contains(new_index));
change_mapping.extend(replacements_to_insert);
}
/// Given a replace operation, iterate through its associated diff hunks and collect
/// the deletions and insertions from the generated diff.
/// Return order: (deletions, insertions)
fn record_replacement<'a>(
replace_op: &DiffOp,
diffs: &'a TextDiff<'a, 'a, 'a, str>,
) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
let mut old_offset = 0;
let mut new_offset = 0;
let mut inline_deletion = Vec::new();
let mut inline_insertion = Vec::new();
for inline_diff in diffs.iter_inline_changes(replace_op) {
match inline_diff.tag() {
ChangeTag::Equal => {
log::warn!("Unexpected equal change tag in a replace op");
continue;
}
ChangeTag::Delete => {
record_inline_diff_as_changes(&mut inline_deletion, &mut old_offset, inline_diff);
}
ChangeTag::Insert => {
record_inline_diff_as_changes(&mut inline_insertion, &mut new_offset, inline_diff);
}
}
}
(inline_deletion, inline_insertion)
}
fn record_inline_diff_as_changes(
changes: &mut Vec<Range<usize>>,
offset: &mut usize,
inline_diff: similar::InlineChange<str>,
) {
for (highlight, val) in inline_diff.values() {
let char_len = val.chars().count();
if *highlight {
changes.push(*offset..*offset + char_len);
}
*offset += char_len;
}
}
impl Entity for DiffModel {
type Event = DiffModelEvent;
}
#[cfg(test)]
#[path = "diff_tests.rs"]
mod tests;
+456
View File
@@ -0,0 +1,456 @@
use std::collections::HashMap;
use rangemap::RangeMap;
use unindent::Unindent as _;
use warp_editor::multiline::{MultilineStr, MultilineString};
use crate::code::editor::diff::ChangeType;
use super::DiffModel;
#[test]
fn test_diff_generation() {
use warpui::App;
App::test((), |_| async move {
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
.unwrap(),
MultilineStr::try_new(
"Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more",
)
.unwrap(),
)
.await;
assert_eq!(
change_mapping,
RangeMap::from_iter([
(
0..1,
ChangeType::Replacement {
replaced_range: 0..1,
insertion: vec![0..5, 6..10],
deletion: vec![0..5, 6..11]
}
),
(
2..4,
ChangeType::Replacement {
replaced_range: 2..3,
insertion: vec![8..13, 14..22, 23..27,],
deletion: vec![8..11, 12..18]
}
)
])
);
assert!(deletion_mapping.is_empty());
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
.unwrap(),
MultilineStr::try_new("Hello World\nThis is the third.").unwrap(),
)
.await;
assert!(change_mapping.is_empty());
assert_eq!(deletion_mapping, HashMap::from([(1, 1..2)]));
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
MultilineStr::try_new("Hello\nWorld\n").unwrap(),
MultilineStr::try_new("Hallo\nWorlds\n").unwrap(),
)
.await;
assert_eq!(
change_mapping,
RangeMap::from_iter([(
0..2,
ChangeType::Replacement {
replaced_range: 0..2,
insertion: vec![0..5, 6..12],
deletion: vec![0..5, 6..11]
}
),])
);
assert!(deletion_mapping.is_empty());
});
}
#[test]
fn test_reverse_action() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
diff_model.set_base(MultilineString::apply(
"Hello World\nThis is the second line\n",
));
diff_model
.compute_diff_for_test("Hallo World\nThis is the second line\nNew".to_string())
.await;
assert_eq!(diff_model.diff_hunk_count(), 2);
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(0),
Some((0..1, "Hello World\n".to_string()))
);
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(1),
Some((2..3, "".to_string()))
);
diff_model
.compute_diff_for_test("Hello World\n".to_string())
.await;
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(0),
Some((1..1, "This is the second line\n".to_string()))
);
});
}
#[test]
fn test_reverse_action_replaced_newlines() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
let base_text = r"
abc
def
ghi
jkl
mno
pqr
stu
"
.unindent();
diff_model.set_base(MultilineString::apply(&base_text));
// Replace with text:
// * Leading newline before "abc"
// * "def" and the following line
// * Newline between "def" and "ghi"
// * "pqr" and the preceding line
// * Trailing newline at end of file
let modified_text = r"
replaced first empty line
abc
changed def
changed line after def
ghi
changed line between ghi and jkl
jkl
mno
changed line before pqr
changed pqr
stu
replaced last empty line
"
.unindent();
diff_model.compute_diff_for_test(modified_text).await;
assert_eq!(diff_model.diff_hunk_count(), 5);
// Reversing the leading newline change
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(0),
Some((0..1, "\n".to_string()))
);
// Reversing "def" and following newline
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(1),
Some((2..4, "def\n\n".to_string()))
);
// Reversing the line changed between "ghi" and "jkl"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(2),
Some((5..6, "\n".to_string()))
);
// Reversing changing "pqr" and the line before it
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(3),
Some((9..11, "\npqr\n".to_string()))
);
// Reversing changing the last line
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(4),
Some((13..14, "\n".to_string()))
);
});
}
#[test]
fn test_reverse_action_replaced_text() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
let base_text = r"
abc
def
ghi
jkl
mno
pqr
stu
vwx
yz
"
.unindent();
diff_model.set_base(MultilineString::apply(&base_text));
// Replace with a newline:
// * First line "abc"
// * "ghi", which is followed by a newline
// * "mno"
// * "stu", which is preceded by a newline
// * Last line "yz"
let modified_text = r"
def
jkl
pqr
vwx
"
.unindent();
diff_model.compute_diff_for_test(modified_text).await;
assert_eq!(diff_model.diff_hunk_count(), 5);
// Reversing the leading newline change
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(0),
Some((0..1, "abc\n".to_string()))
);
// Reversing "ghi"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(1),
Some((3..4, "ghi\n".to_string()))
);
// Reversing "mno"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(2),
Some((5..6, "mno\n".to_string()))
);
// Reversing "stu"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(3),
Some((8..9, "stu\n".to_string()))
);
// Reversing changing the last line
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(4),
Some((10..11, "yz\n".to_string()))
);
});
}
#[test]
fn test_reverse_action_deleted_lines() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
let base_text = r"
abc
def
ghi
jkl
mno
pqr
stu
vwx
yz
"
.unindent();
diff_model.set_base(MultilineString::apply(&base_text));
// Delete:
// * Leading newline before "abc"
// * Newline between "def" and "ghi"
// * "mno" followed by a newline
// * newline followed by "stu"
// * Trailing newline after "yz"
let modified_text = r"
abc
def
ghi
jkl
pqr
vwx
yz
"
.unindent();
diff_model.compute_diff_for_test(modified_text).await;
assert_eq!(diff_model.diff_hunk_count(), 5);
// Reversing the leading newline deletion
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(0),
Some((0..0, "\n".to_string()))
);
// Reversing the newline deletion between "def" and "ghi"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(1),
Some((2..2, "\n".to_string()))
);
// Reversing the deletion of "mno" followed by a newline
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(2),
Some((5..5, "mno\n\n".to_string()))
);
// Reversing the deletion of a newline followed by "stu"
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(3),
Some((8..8, "\nstu\n".to_string()))
);
// Reversing the trailing newline deletion
assert_eq!(
diff_model.reverse_action_by_diff_hunk_index(4),
Some((11..11, "\n".to_string()))
);
});
}
#[test]
fn test_diff_count_before_line() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
diff_model.set_base(
"Hello World\nThis is the second line\n"
.to_owned()
.try_into()
.unwrap(),
);
diff_model
.compute_diff_for_test("Hallo World\nThis is the second line\nNew".to_string())
.await;
assert_eq!(diff_model.diff_hunk_count_before_line(0), 0);
assert_eq!(diff_model.diff_hunk_count_before_line(1), 1);
assert_eq!(diff_model.diff_hunk_count_before_line(2), 1);
});
}
#[test]
fn test_unified_diff() {
use warpui::App;
App::test((), |_| async move {
let diff = DiffModel::retrieve_unified_diff_internal(
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
.unwrap(),
MultilineStr::try_new(
"Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more",
)
.unwrap(),
"test.rs",
)
.await;
assert_eq!(diff.unified_diff, "--- test.rs\n+++ test.rs\n@@ -1,3 +1,4 @@\n-Hello World\n+Hallo Welt\n This is the second line.\n-This is the third.\n+This is life.\n+Moar and more\n");
assert_eq!(diff.lines_added, 3);
assert_eq!(diff.lines_removed, 2);
});
}
/// Test coalesce_replacements with a case where the `similar` library is known
/// to produce duplicate deletion and insertion hunks for what is logically a replacement.
#[test]
fn test_coalesce_replacements() {
use warpui::App;
App::test((), |_| async move {
let mut diff_model = DiffModel::new();
let base_text = r"
abc
def
ghi
jkl
mno
pqr
stu
vwx
yz
"
.unindent();
diff_model.set_base(MultilineString::apply(&base_text));
// Replace with a newline:
// * First line "abc"
// * "ghi", which is followed by a newline
// * "mno"
// * "stu", which is preceded by a newline
// * Last line "yz"
let modified_text = r"
def
jkl
pqr
vwx
"
.unindent();
diff_model.compute_diff_for_test(modified_text).await;
assert_eq!(diff_model.diff_hunk_count(), 5);
// Replacing "abc"
assert_eq!(diff_model.diff_by_index(0), Some((0..1, true)));
// Replacing "ghi"
assert_eq!(diff_model.diff_by_index(1), Some((3..4, true)));
// Replacing "mno"
assert_eq!(diff_model.diff_by_index(2), Some((5..6, true)));
// Replacing "stu"
assert_eq!(diff_model.diff_by_index(3), Some((8..9, true)));
// Replacing "yz"
assert_eq!(diff_model.diff_by_index(4), Some((10..11, true)));
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
use crate::view_components::action_button::{
ActionButtonTheme, DisabledSecondaryTheme, SecondaryTheme,
};
use warp_core::ui::appearance::Appearance;
use warp_core::ui::color::contrast::MinimumAllowedContrast;
use warp_core::ui::color::ContrastingColor;
use warp_core::ui::theme::color::internal_colors;
use warp_core::ui::theme::Fill;
use warp_core::ui::Icon;
use warpui::elements::MouseState;
/// A button rendered within the gutter of the editor.
pub(super) trait GutterButton {
/// The icon color for the gutter.
fn icon_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
let button_background = self.background_color(mouse_state, appearance);
let is_hovered = mouse_state.is_hovered();
let color = if self.is_enabled() {
SecondaryTheme.text_color(is_hovered, Some(button_background), appearance)
} else {
DisabledSecondaryTheme.text_color(is_hovered, Some(button_background), appearance)
};
let contrast_shifted_color = color.on_background(
button_background.into_solid(),
MinimumAllowedContrast::NonText,
);
contrast_shifted_color.into()
}
/// The background color of the button.
fn background_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
if self.is_enabled() {
if mouse_state.is_hovered() {
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
} else {
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
}
} else {
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
}
}
/// Whether the button is currently enabled. If false, the button is rendered in a disabled
/// state.
fn is_enabled(&self) -> bool;
/// The tooltip text displayed when the button is hovered.
fn tooltip_text(&self) -> Option<&'static str>;
/// The icon of the button.
fn icon(&self) -> Icon;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct AddAsContextButton {
is_enabled: bool,
}
impl AddAsContextButton {
pub fn new(is_enabled: bool) -> Self {
Self { is_enabled }
}
}
impl GutterButton for AddAsContextButton {
fn is_enabled(&self) -> bool {
self.is_enabled
}
fn tooltip_text(&self) -> Option<&'static str> {
if self.is_enabled {
Some("Add diff hunk as context")
} else {
Some("Save changes to attach as context.")
}
}
fn icon(&self) -> Icon {
Icon::Paperclip
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RevertHunkButton {
is_enabled: bool,
}
impl RevertHunkButton {
pub fn new(is_enabled: bool) -> Self {
Self { is_enabled }
}
}
impl GutterButton for RevertHunkButton {
fn is_enabled(&self) -> bool {
self.is_enabled
}
fn tooltip_text(&self) -> Option<&'static str> {
if self.is_enabled {
Some("Revert diff hunk")
} else {
Some("Save changes to revert")
}
}
fn icon(&self) -> Icon {
Icon::ReverseLeft
}
}
#[derive(Debug, Default, Clone, Copy)]
#[allow(dead_code)]
pub enum CommentButton {
#[default]
CreateNewComment,
Disabled,
AddedComment,
EditorOpenedToCreateNewComment,
EditorOpenedToUpdateComment,
}
impl GutterButton for CommentButton {
fn background_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
match self {
CommentButton::CreateNewComment => {
if mouse_state.is_hovered() {
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
} else {
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
}
}
CommentButton::EditorOpenedToCreateNewComment => {
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
}
CommentButton::Disabled => Fill::Solid(internal_colors::neutral_1(appearance.theme())),
CommentButton::AddedComment | CommentButton::EditorOpenedToUpdateComment => {
internal_colors::accent(appearance.theme())
}
}
}
fn is_enabled(&self) -> bool {
matches!(
self,
CommentButton::AddedComment
| CommentButton::CreateNewComment
| CommentButton::EditorOpenedToCreateNewComment
)
}
fn tooltip_text(&self) -> Option<&'static str> {
match self {
CommentButton::CreateNewComment => Some("Add comment on line"),
CommentButton::Disabled => Some("Save changes to add comment"),
CommentButton::AddedComment => Some("Show saved comment"),
CommentButton::EditorOpenedToCreateNewComment
| CommentButton::EditorOpenedToUpdateComment => None,
}
}
fn icon(&self) -> Icon {
match self {
CommentButton::CreateNewComment
| CommentButton::Disabled
| CommentButton::EditorOpenedToCreateNewComment => Icon::MessagePlusSquare,
CommentButton::AddedComment | CommentButton::EditorOpenedToUpdateComment => {
Icon::MessageText
}
}
}
}
+241
View File
@@ -0,0 +1,241 @@
use std::any::Any;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use serde_yaml::Mapping;
use uuid::Uuid;
use warp_editor::content::markdown::MarkdownStyle;
use warp_editor::editor::EmbeddedItemModel;
use warp_editor::render::element::{RenderContext, RenderableBlock};
use warp_editor::render::layout::TextLayout;
use warp_editor::render::model::{
viewport::ViewportItem, BlockSpacing, EmbeddedItem, EmbeddedItemHTMLRepresentation,
EmbeddedItemRichFormat, LaidOutEmbeddedItem, RenderState,
};
use warpui::event::DispatchedEvent;
use warpui::units::Pixels;
use warpui::{AppContext, EntityId, EventContext, LayoutContext, ViewHandle, WindowId};
use crate::code::editor::comment_editor::CommentEditor;
use crate::code_review::comments::CommentId;
const COMMENT_ID_MAPPING_KEY: &str = "comment_id";
const ENTITY_ID_MAPPING_KEY: &str = "entity_id";
const WINDOW_ID_MAPPING_KEY: &str = "window_id";
#[derive(Debug)]
pub struct EmbeddedCommentSpace {
// We unfortunately need to store a string version of the ID
// in order to return it in EmbeddedItem::hashed_id()
id_string: String,
editor_entity_id: EntityId,
window_id: WindowId,
}
impl EmbeddedCommentSpace {
fn new(id: CommentId, editor_entity_id: EntityId, window_id: WindowId) -> Self {
Self {
id_string: id.to_string(),
editor_entity_id,
window_id,
}
}
// Fetch the underlying comment editor view
fn get_comment_editor(&self, app: &AppContext) -> Option<ViewHandle<CommentEditor>> {
app.view_with_id::<CommentEditor>(self.window_id, self.editor_entity_id)
}
}
impl EmbeddedItem for EmbeddedCommentSpace {
fn layout(&self, _text_layout: &TextLayout, app: &AppContext) -> Box<dyn LaidOutEmbeddedItem> {
let comment_editor = self.get_comment_editor(app);
if comment_editor.is_none() {
log::error!(
"EmbeddedComment can't layout missing comment editor for comment ID {:?}",
self.id_string
);
};
let size = comment_editor
.and_then(|editor| editor.read(app, |editor, _ctx| editor.get_laid_out_size()))
.unwrap_or_else(|| {
log::error!(
"Didn't find laid out size for editor ID {:?}",
self.id_string
);
Vector2F::new(100.0, 24.0)
});
Box::new(LaidOutEmbeddedCommentSpace { size })
}
fn hashed_id(&self) -> &str {
self.id_string.as_str()
}
fn to_mapping(&self, _style: MarkdownStyle) -> Mapping {
let mut map = Mapping::new();
let comment_id = self.id_string.clone();
let editor_entity_id = self.editor_entity_id.to_string();
let window_id = self.window_id.to_string();
map.insert(COMMENT_ID_MAPPING_KEY.into(), comment_id.into());
map.insert(ENTITY_ID_MAPPING_KEY.into(), editor_entity_id.into());
map.insert(WINDOW_ID_MAPPING_KEY.into(), window_id.into());
map
}
fn to_rich_format(&self, app: &AppContext) -> EmbeddedItemRichFormat<'_> {
let text = if let Some(editor) = self.get_comment_editor(app) {
editor.read(app, |editor, app| editor.comment_text(app))
} else {
String::new()
};
EmbeddedItemRichFormat {
plain_text: text.to_string(),
html: EmbeddedItemHTMLRepresentation {
element_name: "div",
content: text.to_string(),
attributes: HashMap::new(),
},
}
}
}
#[derive(Debug)]
pub struct LaidOutEmbeddedCommentSpace {
pub size: Vector2F,
}
impl LaidOutEmbeddedItem for LaidOutEmbeddedCommentSpace {
fn height(&self) -> Pixels {
Pixels::new(self.size.y())
}
fn size(&self) -> Vector2F {
self.size
}
fn first_line_bound(&self) -> Vector2F {
vec2f(self.size.x(), 24.0)
}
fn element(
&self,
_state: &RenderState,
viewport_item: ViewportItem,
_model: Option<&dyn EmbeddedItemModel>,
_ctx: &AppContext,
) -> Box<dyn RenderableBlock> {
// Just create a spacer - no child view rendering here
Box::new(RenderableEmbeddedCommentSpace::new(viewport_item))
}
fn spacing(&self) -> BlockSpacing {
BlockSpacing::default()
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub struct RenderableEmbeddedCommentSpace {
viewport_item: ViewportItem,
}
impl RenderableEmbeddedCommentSpace {
pub(crate) fn new(viewport_item: ViewportItem) -> Self {
Self { viewport_item }
}
}
impl RenderableBlock for RenderableEmbeddedCommentSpace {
fn viewport_item(&self) -> &ViewportItem {
&self.viewport_item
}
fn layout(&mut self, _model: &RenderState, _ctx: &mut LayoutContext, _app: &AppContext) {
// No-op: this is just a spacer, the actual editor is laid out by EditorWrapper
}
fn paint(&mut self, _model: &RenderState, _ctx: &mut RenderContext, _app: &AppContext) {
// No-op: this is just empty space, the actual editor is painted by EditorWrapper
}
fn dispatch_event(
&mut self,
_model: &RenderState,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
// No interactivity: events are handled by the editor rendered by EditorWrapper
false
}
fn is_embedded_comment(&self) -> bool {
true
}
}
/// The embedded item transformation for comments.
#[cfg_attr(not(test), allow(unused))] // TODO(CODE-1464): use this
pub(super) fn comment_embedded_item_conversion(
mut mapping: serde_yaml::Mapping,
) -> Option<Arc<dyn EmbeddedItem>> {
use serde_yaml::Value;
let Some(Value::String(comment_uuid)) =
mapping.remove(&Value::String(COMMENT_ID_MAPPING_KEY.to_string()))
else {
log::error!("Unable to deserialize embedded comment ID");
return None;
};
let Some(Value::String(entity_id)) =
mapping.remove(&Value::String(ENTITY_ID_MAPPING_KEY.to_string()))
else {
log::error!("Unable to deserialize embedded comment entity ID");
return None;
};
let Some(Value::String(window_id)) =
mapping.remove(&Value::String(WINDOW_ID_MAPPING_KEY.to_string()))
else {
log::error!("Unable to deserialize embedded comment window ID");
return None;
};
let comment_id = CommentId::from_uuid(
Uuid::from_str(&comment_uuid)
.inspect_err(|e| {
log::error!("Unable to parse comment ID {comment_uuid}: {e:?}");
})
.ok()?,
);
let entity_id = EntityId::from_usize(
entity_id
.parse::<usize>()
.inspect_err(|e| {
log::error!("Unable to parse entity ID {entity_id}: {e:?}");
})
.ok()?,
);
let window_id = WindowId::from_usize(
window_id
.parse::<usize>()
.inspect_err(|e| {
log::error!("Unable to parse entity ID {window_id}: {e:?}");
})
.ok()?,
);
Some(Arc::new(EmbeddedCommentSpace::new(
comment_id, entity_id, window_id,
)))
}
#[cfg(test)]
#[path = "embedded_comment_tests.rs"]
mod tests;
@@ -0,0 +1,157 @@
use super::{
comment_embedded_item_conversion, EmbeddedCommentSpace, EmbeddedItem as _,
COMMENT_ID_MAPPING_KEY, ENTITY_ID_MAPPING_KEY, WINDOW_ID_MAPPING_KEY,
};
use crate::code_review::comments::CommentId;
use serde_yaml::{Mapping, Value};
use warp_editor::content::markdown::MarkdownStyle;
use warpui::{EntityId, WindowId};
#[test]
fn test_comment_embedded_item_conversion_valid_input() {
let comment_id = CommentId::new();
let entity_id = EntityId::from_usize(123);
let window_id = WindowId::from_usize(456);
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String(comment_id.to_string()),
);
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String(entity_id.to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String(window_id.to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_some());
}
#[test]
fn test_comment_embedded_item_conversion_roundtrip() {
let comment_id = CommentId::new();
let entity_id = EntityId::from_usize(789);
let window_id = WindowId::from_usize(101);
let space = EmbeddedCommentSpace::new(comment_id, entity_id, window_id);
let mapping = space.to_mapping(MarkdownStyle::Internal);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_some());
}
#[test]
fn test_comment_embedded_item_conversion_missing_comment_id() {
let mut mapping = Mapping::new();
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String("123".to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String("456".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
#[test]
fn test_comment_embedded_item_conversion_missing_entity_id() {
let comment_id = CommentId::new();
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String(comment_id.to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String("456".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
#[test]
fn test_comment_embedded_item_conversion_missing_window_id() {
let comment_id = CommentId::new();
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String(comment_id.to_string()),
);
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String("123".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
#[test]
fn test_comment_embedded_item_conversion_invalid_uuid() {
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String("not-a-valid-uuid".to_string()),
);
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String("123".to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String("456".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
#[test]
fn test_comment_embedded_item_conversion_invalid_entity_id() {
let comment_id = CommentId::new();
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String(comment_id.to_string()),
);
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String("not-a-number".to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String("456".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
#[test]
fn test_comment_embedded_item_conversion_invalid_window_id() {
let comment_id = CommentId::new();
let mut mapping = Mapping::new();
mapping.insert(
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
Value::String(comment_id.to_string()),
);
mapping.insert(
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
Value::String("123".to_string()),
);
mapping.insert(
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
Value::String("not-a-number".to_string()),
);
let result = comment_embedded_item_conversion(mapping);
assert!(result.is_none());
}
+1
View File
@@ -0,0 +1 @@
pub mod view;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
pub mod view;
+200
View File
@@ -0,0 +1,200 @@
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
use crate::appearance::Appearance;
use crate::code::editor::find::view::{FIND_BAR_PADDING, FIND_EDITOR_BORDER_RADIUS};
use crate::editor::{
EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
SingleLineEditorOptions, TextOptions,
};
use warpui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
ParentElement, Radius, Text,
},
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
const GOTO_LINE_WIDTH: f32 = 300.;
const GOTO_LINE_LABEL_FONT_SIZE: f32 = 12.;
const GOTO_LINE_EDITOR_FONT_SIZE: f32 = 12.;
const GOTO_LINE_ERROR_FONT_SIZE: f32 = 11.;
const GOTO_LINE_EDITOR_PADDING: f32 = 6.;
const GOTO_LINE_EDITOR_BORDER_WIDTH: f32 = 1.;
const GOTO_LINE_ROW_SPACING: f32 = 6.;
#[derive(Debug)]
pub enum Event {
Close,
Confirm { input: String },
}
pub struct GoToLineView {
line_editor: ViewHandle<EditorView>,
is_open: bool,
error_message: Option<String>,
}
impl GoToLineView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let line_editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(GOTO_LINE_EDITOR_FONT_SIZE), appearance),
select_all_on_focus: true,
clear_selections_on_blur: false,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text("Line number:Column", ctx);
editor
});
ctx.subscribe_to_view(&line_editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let appearance_handle = Appearance::handle(ctx);
ctx.observe(&appearance_handle, |_, _, ctx| {
ctx.notify();
});
Self {
line_editor,
is_open: false,
error_message: None,
}
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn open(&mut self, ctx: &mut ViewContext<Self>) {
self.is_open = true;
self.error_message = None;
self.line_editor.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer(ctx);
});
}
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.is_open = false;
self.error_message = None;
ctx.notify();
}
pub fn set_error(&mut self, message: String, ctx: &mut ViewContext<Self>) {
self.error_message = Some(message);
ctx.notify();
}
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
let input = self.line_editor.as_ref(ctx).buffer_text(ctx);
ctx.emit(Event::Confirm { input });
}
EditorEvent::Escape => {
ctx.emit(Event::Close);
}
_ => {}
}
}
}
impl Entity for GoToLineView {
type Event = Event;
}
impl TypedActionView for GoToLineView {
type Action = ();
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
}
impl View for GoToLineView {
fn ui_name() -> &'static str {
"GoToLineView"
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
ctx.focus(&self.line_editor);
ctx.notify();
}
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let label = Text::new_inline(
"Go to line",
appearance.ui_font_family(),
GOTO_LINE_LABEL_FONT_SIZE,
)
.with_color(theme.active_ui_text_color().into())
.finish();
let input_field = Container::new(ChildView::new(&self.line_editor).finish())
.with_padding_left(8.)
.with_padding_right(4.)
.with_padding_top(GOTO_LINE_EDITOR_PADDING)
.with_padding_bottom(GOTO_LINE_EDITOR_PADDING)
.with_background(theme.surface_1())
.with_border(
Border::all(GOTO_LINE_EDITOR_BORDER_WIDTH).with_border_fill(theme.surface_3()),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
FIND_EDITOR_BORDER_RADIUS,
)))
.finish();
let mut content = Flex::column().with_child(
Container::new(label)
.with_margin_bottom(GOTO_LINE_ROW_SPACING)
.finish(),
);
content.add_child(input_field);
if let Some(error) = &self.error_message {
let error_text = Text::new_inline(
error.clone(),
appearance.ui_font_family(),
GOTO_LINE_ERROR_FONT_SIZE,
)
.with_color(theme.ui_error_color())
.finish();
content.add_child(
Container::new(error_text)
.with_margin_top(GOTO_LINE_ROW_SPACING)
.finish(),
);
}
let panel = Container::new(
ConstrainedBox::new(
Container::new(content.finish())
.with_background(theme.surface_2())
.finish(),
)
.with_width(GOTO_LINE_WIDTH)
.finish(),
)
.with_uniform_padding(FIND_BAR_PADDING)
.with_background(theme.surface_2())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
FIND_EDITOR_BORDER_RADIUS,
)))
.with_drop_shadow(DropShadow::default())
.finish();
Align::new(panel).top_center().finish()
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::ops::Range;
use warp_editor::render::model::{LineCount, RenderLineLocation};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorLineLocation {
Collapsed {
line_range: Range<LineCount>,
},
Current {
line_number: LineCount,
line_range: Range<LineCount>,
},
Removed {
line_number: LineCount,
line_range: Range<LineCount>,
// There can be many deleted lines in a removal hunk, so we track the index of this line within the hunk.
index: usize,
},
}
impl EditorLineLocation {
pub fn line_range(&self) -> &Range<LineCount> {
match self {
EditorLineLocation::Current { line_range, .. } => line_range,
EditorLineLocation::Removed { line_range, .. } => line_range,
EditorLineLocation::Collapsed { line_range } => line_range,
}
}
pub fn line_number(&self) -> Option<LineCount> {
match self {
EditorLineLocation::Current { line_number, .. } => Some(*line_number),
EditorLineLocation::Removed { line_number, .. } => Some(*line_number),
EditorLineLocation::Collapsed { .. } => None,
}
}
/// Check if this line location represents the same line as another.
/// This is not the same as equality, as the line range of the diff hunk may differ.
pub fn is_same_line(&self, other: &Self) -> bool {
match (self, other) {
(
EditorLineLocation::Current { line_number: a, .. },
EditorLineLocation::Current { line_number: b, .. },
) if a == b => true,
(
EditorLineLocation::Removed {
line_number: al,
index: ai,
..
},
EditorLineLocation::Removed {
line_number: bl,
index: bi,
..
},
) if al == bl && ai == bi => true,
_ => false,
}
}
pub fn into_render_line_location(self) -> RenderLineLocation {
match self {
EditorLineLocation::Current { line_number, .. } => {
RenderLineLocation::Current(line_number)
}
EditorLineLocation::Removed {
line_number, index, ..
} => RenderLineLocation::Temporary {
at_line: line_number,
index_from_at_line: index,
},
EditorLineLocation::Collapsed { line_range } => {
debug_assert!(false, "We don't support converting from collapsed line location to render line location yet");
RenderLineLocation::Current(line_range.start)
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/// An iterator over lines that can efficiently return lines in a specified range.
/// This should be shared when multiple in-order ranges need to be accessed.
/// Note that you cannot request a range before the previous range that was requested.
use std::ops::Range;
pub struct LineIterator<'a, I: Iterator<Item = &'a str>> {
lines: I,
index: usize,
}
impl<'a, I: Iterator<Item = &'a str>> LineIterator<'a, I> {
pub fn new(iter: I) -> Self {
Self {
lines: iter,
index: 0,
}
}
pub fn lines_in_range(
&mut self,
range: &Range<usize>,
) -> anyhow::Result<impl Iterator<Item = &'a str> + use<'a, '_, I>> {
if self.index > range.start {
return Err(anyhow::anyhow!(
"Requested range start {} is before current index {}",
range.start,
self.index
));
}
if self.index < range.start {
self.lines.nth(range.start - self.index - 1);
self.index = range.start;
}
self.index = range.end;
Ok(self.lines.by_ref().take(range.end - range.start))
}
}
+22
View File
@@ -0,0 +1,22 @@
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
pub(crate) mod comment_editor;
mod comments;
pub(super) mod diff;
mod element;
pub mod embedded_comment;
pub mod find;
pub mod goto_line;
pub mod line;
mod line_iterator;
pub mod model;
mod nav_bar;
pub mod scroll;
pub mod view;
pub use comment_editor::{CommentEditor, CommentEditorEvent};
pub use comments::EditorCommentsModel;
pub use comments::EditorReviewComment;
pub(crate) use diff::{add_color, remove_color};
pub use element::GutterHoverTarget;
pub use nav_bar::NavBarBehavior;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
#![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 warp_core::ui::{appearance::Appearance, theme::Fill};
use warp_editor::model::CoreEditorModel;
use warpui::{
elements::{
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, MouseStateHandle,
ParentElement, Shrinkable,
},
presenter::ChildView,
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
units::IntoPixels,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::{
editor::InteractionState,
ui_components::icons::Icon,
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
view_components::find::FIND_BAR_PADDING,
};
use super::model::{CodeEditorModel, CodeEditorModelEvent};
const NAV_BAR_HEIGHT: f32 = 40.;
const NAV_BAR_ICON_SIZE: f32 = 16.;
const NAV_BAR_ICON_PADDING: f32 = 4.;
const NAV_BAR_SEPARATOR_PADDING: f32 = 12.;
// The ratio of rows to offset of when jumping to a diff nav (base is the total number of lines in viewport)
const DIFF_NAV_OFFSET_PIXEL_RATIO: usize = 10;
#[derive(Debug, Clone, Copy)]
pub enum NavBarEvent {
Close,
}
#[derive(Debug, Clone, Copy)]
pub enum NavBarAction {
NavigateUp,
NavigateDown,
Revert,
Close,
}
#[derive(Default)]
struct MouseStateHandles {
close_mouse_state: MouseStateHandle,
revert_mouse_state: MouseStateHandle,
}
pub enum NavBarBehavior {
Closable,
NotClosable,
}
pub struct NavBar {
model: ModelHandle<CodeEditorModel>,
behavior: NavBarBehavior,
mouse_state_handles: MouseStateHandles,
up_label_button: ViewHandle<ActionButton>,
down_label_button: ViewHandle<ActionButton>,
}
impl NavBar {
pub fn new(model: ModelHandle<CodeEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
ctx.subscribe_to_model(&model, |_, _, event, ctx| {
if matches!(event, CodeEditorModelEvent::InteractionStateChanged) {
ctx.notify();
}
});
let up_label_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Previous", NakedTheme)
.with_size(ButtonSize::InlineActionHeader)
.with_icon(Icon::ArrowUp)
.on_click(|ctx| ctx.dispatch_typed_action(NavBarAction::NavigateUp))
});
let down_label_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Next", NakedTheme)
.with_size(ButtonSize::InlineActionHeader)
.with_icon(Icon::ArrowDown)
.on_click(|ctx| ctx.dispatch_typed_action(NavBarAction::NavigateDown))
});
Self {
model,
behavior: NavBarBehavior::Closable,
mouse_state_handles: Default::default(),
up_label_button,
down_label_button,
}
}
pub fn set_behavior(&mut self, behavior: NavBarBehavior) {
self.behavior = behavior;
}
fn diff_hunk_count(&self, app: &AppContext) -> usize {
self.model.as_ref(app).diff().as_ref(app).diff_hunk_count()
}
pub fn selected_index(&self, app: &AppContext) -> Option<usize> {
self.model.as_ref(app).focused_diff_index()
}
/// Autoscroll until the start of the selected hunk is in the center of the viewport.
pub fn autoscroll(&self, ctx: &mut ViewContext<Self>) {
let model = self.model.as_ref(ctx);
let Some(index) = self.selected_index(ctx) else {
return;
};
let Some(range) = model
.diff()
.as_ref(ctx)
.line_range_by_diff_hunk_index(index)
else {
return;
};
let character_offset = model.start_of_line_offset(range.start, ctx);
// Number of lines to offset when autoscrolling to a diff. Keep a minimum of 1 line as context.
let delta = (model.lines_in_viewport(ctx) / DIFF_NAV_OFFSET_PIXEL_RATIO).max(1);
let pixel_offset = -(delta as f32 * model.line_height(ctx));
model
.render_state()
.clone()
.update(ctx, |render_state, _ctx| {
render_state.request_autoscroll_to_exact_vertical(
character_offset,
pixel_offset.into_pixels(),
);
})
}
fn render_match_index(
&self,
appearance: &Appearance,
background: Fill,
total: usize,
app: &AppContext,
) -> Box<dyn Element> {
let diff_text = appearance
.ui_builder()
.span("Hunk:")
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().sub_text_color(background).into()),
..Default::default()
})
.with_selectable(false)
.build()
.finish();
let index = (self.selected_index(app).unwrap_or(0) + 1).min(total);
let text = format!("{index}/{total}");
let index = Container::new(
appearance
.ui_builder()
.span(text)
.with_style(UiComponentStyles {
font_color: Some(appearance.theme().foreground().into()),
..Default::default()
})
.with_selectable(false)
.build()
.finish(),
)
.with_vertical_padding(4.)
.with_horizontal_padding(8.)
.with_border(Border::new(1.).with_border_fill(appearance.theme().surface_2()))
.finish();
Align::new(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(diff_text)
.with_child(index)
.finish(),
)
.with_padding_right(16.)
.finish(),
)
.right()
.finish()
}
fn render_revert_button(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
appearance
.ui_builder()
.button(
ButtonVariant::Outlined,
self.mouse_state_handles.revert_mouse_state.clone(),
)
.with_text_label("Reject".to_string())
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NavBarAction::Revert))
.finish(),
)
.with_padding_left(NAV_BAR_SEPARATOR_PADDING)
.with_padding_right(NAV_BAR_SEPARATOR_PADDING)
.finish()
}
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
Container::new(
appearance
.ui_builder()
.close_button(
NAV_BAR_ICON_SIZE,
self.mouse_state_handles.close_mouse_state.clone(),
)
.build()
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NavBarAction::Close))
.finish(),
)
.with_uniform_padding(NAV_BAR_ICON_PADDING)
.finish()
}
fn render_nav_label(&self, up: bool) -> Box<dyn Element> {
let button_handle = if up {
&self.up_label_button
} else {
&self.down_label_button
};
Container::new(Align::new(ChildView::new(button_handle).finish()).finish())
.with_padding_right(16.)
.finish()
}
pub fn navigate_up(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.nav_diff_up(ctx);
});
self.autoscroll(ctx);
ctx.notify();
}
pub fn navigate_down(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.nav_diff_down(ctx);
});
self.autoscroll(ctx);
ctx.notify();
}
}
impl Entity for NavBar {
type Event = NavBarEvent;
}
impl View for NavBar {
fn ui_name() -> &'static str {
"NavBar"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let total = self.diff_hunk_count(app);
let editable = matches!(
self.model.as_ref(app).interaction_state(),
InteractionState::Editable
);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Shrinkable::new(
1.,
self.render_match_index(
appearance,
appearance.theme().background(),
total,
app,
),
)
.finish(),
)
.with_child(self.render_nav_label(true))
.with_child(self.render_nav_label(false));
// Do not render the revert button if there is nothing to revert or the editor is
// not in an editable interaction state.
if editable && total > 0 {
row.add_child(self.render_revert_button(appearance));
}
if matches!(self.behavior, NavBarBehavior::Closable) {
row.add_child(self.render_close_button(appearance));
}
Container::new(
ConstrainedBox::new(row.finish())
.with_height(NAV_BAR_HEIGHT)
.finish(),
)
.with_uniform_padding(FIND_BAR_PADDING)
.finish()
}
}
impl TypedActionView for NavBar {
type Action = NavBarAction;
fn handle_action(&mut self, action: &NavBarAction, ctx: &mut ViewContext<Self>) {
match action {
NavBarAction::Close => ctx.emit(NavBarEvent::Close),
NavBarAction::NavigateUp => self.navigate_up(ctx),
NavBarAction::NavigateDown => self.navigate_down(ctx),
NavBarAction::Revert => {
self.autoscroll(ctx);
self.model.update(ctx, |model, ctx| {
model.revert_diff_index(ctx);
});
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
use warp_editor::content::version::BufferVersion;
use warp_util::path::LineAndColumnArg;
#[derive(Debug, Clone, Copy)]
pub enum ScrollWheelBehavior {
#[allow(dead_code)]
OnlyHandleOnFocus,
#[allow(dead_code)]
AlwaysHandle,
#[allow(dead_code)]
NeverHandle,
}
impl ScrollWheelBehavior {
#[allow(dead_code)]
pub fn should_handle(&self, focused: bool) -> bool {
match self {
Self::OnlyHandleOnFocus => focused,
Self::AlwaysHandle => true,
Self::NeverHandle => false,
}
}
}
#[cfg_attr(target_family = "wasm", allow(dead_code))]
#[derive(Clone)]
pub enum ScrollPosition {
LineAndColumn(LineAndColumnArg),
FocusedDiffHunk,
}
/// We don't want to scroll to the provided line number until the content has
/// been loaded from the file and layout has occurred to update the viewport size.
/// This struct is used to track the state of the scroll trigger.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub struct ScrollTrigger {
pub minimum_applicable_version: BufferVersion,
pub position: ScrollPosition,
}
impl ScrollTrigger {
/// Create a new scroll trigger that will jump to the provided line number
/// after the provided version has been loaded and a layout update has occurred.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn new(position: ScrollPosition, version: BufferVersion) -> Self {
Self {
position,
minimum_applicable_version: version,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
use std::sync::Arc;
use warp_core::ui::appearance::Appearance;
use warp_editor::render::element::VerticalExpansionBehavior;
use warpui::{
elements::{new_scrollable::ScrollableAppearance, ScrollbarWidth},
platform::WindowStyle,
App, TypedActionView, ViewHandle, WindowId,
};
use crate::{
cloud_object::model::persistence::CloudModel,
editor::InteractionState,
notebooks::editor::keys::NotebookKeybindings,
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
settings_view::keybindings::KeybindingChangedNotifier,
test_util::settings::initialize_settings_for_tests,
vim_registers::VimRegisters,
workspace::{sync_inputs::SyncedInputState, ActiveSession},
workspaces::user_workspaces::UserWorkspaces,
AuthStateProvider,
};
use super::{CodeEditorRenderOptions, CodeEditorView, CodeEditorViewAction};
use warp_util::user_input::UserInput;
fn initialize_editor(app: &mut App) -> (WindowId, ViewHandle<CodeEditorView>) {
initialize_settings_for_tests(app);
// Add all required singleton models for EditorView dependencies
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| SyncedInputState::mock());
app.add_singleton_model(|_| VimRegisters::new());
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
// Add mocks required by rich text editor (used in CommentEditor)
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|_| ActiveSession::default());
app.add_singleton_model(NotebookKeybindings::new);
// Add UserWorkspaces mock (required by EditorView)
let team_client_mock = Arc::new(MockTeamClient::new());
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
team_client_mock.clone(),
workspace_client_mock.clone(),
vec![],
ctx,
)
});
let (window, editor_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
CodeEditorView::new(
None,
None,
CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight),
ctx,
)
.with_horizontal_scrollbar_appearance(ScrollableAppearance::new(ScrollbarWidth::Auto, true))
});
(window, editor_view)
}
#[test]
fn test_interaction_state_prevents_editing() {
App::test((), |mut app| async move {
let (_window, editor_view) = initialize_editor(&mut app);
let text = editor_view.update(&mut app, |view, ctx| {
view.handle_action(&CodeEditorViewAction::UserTyped(UserInput::new("abc")), ctx);
view.text(ctx)
});
assert_eq!(text.as_str(), "abc");
// Set to be only selectable
editor_view.update(&mut app, |view, ctx| {
view.set_interaction_state(InteractionState::Selectable, ctx);
});
let text = editor_view.update(&mut app, |view, ctx| {
view.handle_action(&CodeEditorViewAction::UserTyped(UserInput::new("def")), ctx);
view.text(ctx)
});
assert_eq!(text.as_str(), "abc");
});
}
+937
View File
@@ -0,0 +1,937 @@
use super::{CodeEditorEvent, CodeEditorView};
use crate::code::editor::{
find::view::Event as FindViewEvent,
model::{CaseTransform, CodeEditorModel, LineBound},
};
use crate::{
view_components::find::FindDirection,
vim_registers::{RegisterContent, VimRegisters},
};
use vim::vim::{
BracketChar, CharacterMotion, Direction, FindCharMotion, FirstNonWhitespaceMotion,
InsertPosition, LineMotion, ModeTransition, MotionType, TextObjectType, VimHandler, VimMode,
VimMotion, VimOperand, VimOperator, VimTextObject, WordMotion,
};
use warp_editor::{
content::buffer::{
AutoScrollBehavior, BufferEditAction, EditOrigin, SelectionOffsets,
ToBufferCharOffset as _, VimInsertPoint,
},
model::{CoreEditorModel, PlainTextEditorModel},
selection::{TextDirection, TextUnit},
};
use warpui::{text::point::Point, SingletonEntity, ViewContext};
impl VimHandler for CodeEditorView {
fn insert_char(&mut self, c: char, ctx: &mut ViewContext<Self>) {
self.user_insert(&c.to_string(), ctx);
}
fn keyword_prg(&mut self, _ctx: &mut ViewContext<Self>) {
// no-op
}
fn navigate_char(
&mut self,
count: u32,
character_motion: &CharacterMotion,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| match character_motion {
CharacterMotion::Right => {
model.vim_move_horizontal_by_offset(count, &Direction::Forward, false, true, ctx);
}
CharacterMotion::Up => {
model.vim_move_vertical_by_offset(count, TextDirection::Backwards, false, ctx);
}
CharacterMotion::Down => {
model.vim_move_vertical_by_offset(count, TextDirection::Forwards, false, ctx);
}
CharacterMotion::Left => {
model.vim_move_horizontal_by_offset(count, &Direction::Backward, false, true, ctx);
}
CharacterMotion::WrappingLeft => {
model.vim_move_horizontal_by_offset(count, &Direction::Backward, false, false, ctx);
}
CharacterMotion::WrappingRight => {
model.vim_move_horizontal_by_offset(count, &Direction::Forward, false, false, ctx);
}
});
}
fn navigate_word(&mut self, count: u32, word_motion: &WordMotion, ctx: &mut ViewContext<Self>) {
let WordMotion {
direction,
bound,
word_type,
} = word_motion;
self.model.update(ctx, |model, ctx| {
model.vim_navigate_word(*direction, *bound, *word_type, count, ctx);
});
}
fn navigate_line(&mut self, line_count: u32, motion: &LineMotion, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
match motion {
LineMotion::Start => model.vim_move_to_line_bound(LineBound::Start, false, ctx),
LineMotion::FirstNonWhitespace => model.vim_move_to_first_nonwhitespace(false, ctx),
LineMotion::End => {
// Only moving to the end of the line ($) uses number-repeat (the line-count var)
model.vim_move_vertical_by_offset(
line_count.saturating_sub(1),
TextDirection::Forwards,
false,
ctx,
);
model.vim_move_to_line_bound(LineBound::End, false, ctx);
}
}
})
}
fn first_nonwhitespace_motion(
&mut self,
count: u32,
motion: &FirstNonWhitespaceMotion,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| {
match motion {
FirstNonWhitespaceMotion::Up => {
model.vim_move_vertical_by_offset(count, TextDirection::Backwards, false, ctx);
}
FirstNonWhitespaceMotion::Down => {
model.vim_move_vertical_by_offset(count, TextDirection::Forwards, false, ctx)
}
FirstNonWhitespaceMotion::DownMinusOne => model.vim_move_vertical_by_offset(
count - 1,
TextDirection::Forwards,
false,
ctx,
),
}
model.vim_move_to_first_nonwhitespace(false, ctx);
})
}
fn find_char(
&mut self,
occurrence_count: u32,
find_char_motion: &FindCharMotion,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| {
model.vim_find_char(
false, /* keep_selection */
occurrence_count,
find_char_motion,
ctx,
);
});
}
fn navigate_paragraph(
&mut self,
count: u32,
direction: &Direction,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| {
model.vim_move_by_paragraph(count, direction, false, ctx);
});
}
fn operation(
&mut self,
operator: &VimOperator,
operand_count: u32,
operand: &VimOperand,
register_name: char,
replacement_text: &str,
ctx: &mut ViewContext<Self>,
) {
// Selection logic is almost the same for all operators, so capture that in a closure first.
let selection_change =
|model: &mut CodeEditorModel, ctx: &mut warpui::ModelContext<CodeEditorModel>| {
match operand {
VimOperand::Motion {
motion,
motion_type,
} => {
match motion {
VimMotion::Character(char_motion) => {
model.vim_select_for_char_motion(
char_motion,
motion_type,
operator,
operand_count,
ctx,
);
}
VimMotion::Word(word_motion) => {
model.vim_select_for_word_motion(
word_motion,
operand_count,
motion_type,
operator,
ctx,
);
}
VimMotion::Line(line_motion) => {
model.vim_select_for_line_motion(
line_motion,
operand_count,
motion_type,
operator,
ctx,
);
}
VimMotion::FirstNonWhitespace(nonws_motion) => {
model.vim_select_for_first_nonwhitespace_motion(
nonws_motion,
motion_type,
operator,
operand_count,
ctx,
);
}
VimMotion::Paragraph(direction) => {
model.vim_move_by_paragraph(operand_count, direction, true, ctx);
if *motion_type == MotionType::Linewise {
let include_newline = *operator != VimOperator::Change;
model.vim_extend_selection_linewise(include_newline, ctx);
}
}
VimMotion::JumpToLastLine => {
model.vim_select_to_buffer_end(ctx);
if *motion_type == MotionType::Linewise {
model.vim_extend_selection_linewise(
*operator != VimOperator::Change,
ctx,
);
}
}
VimMotion::JumpToFirstLine => {
model.vim_select_to_buffer_start(ctx);
if *motion_type == MotionType::Linewise {
model.vim_extend_selection_linewise(
*operator != VimOperator::Change,
ctx,
);
}
}
VimMotion::FindChar(m) => {
// Extend selection to the found character according to the motion
model.vim_find_char(
true, /* keep_selection */
operand_count,
m,
ctx,
);
}
VimMotion::JumpToLine(line_number) => {
let buffer = model.content().as_ref(ctx);
let selection_model = model.buffer_selection_model().as_ref(ctx);
let current_selections = selection_model.selection_offsets();
let new_selections = current_selections.mapped(|selection| {
let cursor_pos = selection.head;
let target_pos =
Point::new(*line_number, 0).to_buffer_char_offset(buffer);
SelectionOffsets {
head: target_pos,
tail: cursor_pos,
}
});
model.vim_set_selections(
new_selections,
AutoScrollBehavior::Selection,
ctx,
);
if *motion_type == MotionType::Linewise {
let include_newline = *operator != VimOperator::Change;
model.vim_extend_selection_linewise(include_newline, ctx);
}
}
_ => {
// TODO: Implement other motions (find char, brackets, etc.)
}
}
}
VimOperand::Line => {
// Extend selection down by count-1 lines
if operand_count > 1 {
model.vim_move_vertical_by_offset(
operand_count - 1,
TextDirection::Forwards,
true,
ctx,
);
}
let include_newline = operator != &VimOperator::Change
&& operator != &VimOperator::ToggleComment;
model.vim_extend_selection_linewise(include_newline, ctx);
}
VimOperand::TextObject(text_object) => {
model.vim_select_text_object(text_object, Some(operator), ctx);
}
}
};
let motion_type = match operand {
VimOperand::Motion { motion_type, .. } => *motion_type,
VimOperand::TextObject(text_object) => match text_object {
VimTextObject {
object_type: TextObjectType::Paragraph,
..
} => MotionType::Linewise,
_ => MotionType::Charwise,
},
VimOperand::Line => MotionType::Linewise,
};
match operator {
VimOperator::Delete | VimOperator::Change => {
self.model.update(ctx, |model, ctx| {
selection_change(model, ctx);
// Copy selection to vim register before modifying
let buffer = model.content().as_ref(ctx);
let selection_model = model.buffer_selection_model().clone();
let selected_text = buffer
.selected_text_as_plain_text(selection_model, ctx)
.into_string();
if !selected_text.is_empty() {
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
registers.write_to_register(
register_name,
selected_text,
motion_type,
ctx,
);
});
if *operator == VimOperator::Change && motion_type == MotionType::Linewise {
// Use smart indent to position the cursor when changing the entire
// line.
model.vim_change_line_with_smart_indent(ctx);
} else {
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
// Insert replacement text if provided
if *operator == VimOperator::Change && !replacement_text.is_empty() {
model.insert(replacement_text, EditOrigin::UserInitiated, ctx);
}
if motion_type == MotionType::Linewise {
model.vim_move_to_line_bound(LineBound::Start, false, ctx);
}
}
}
});
}
VimOperator::Yank => {
self.model.update(ctx, |model, ctx| {
// Store existing selections to restore after yank
let existing_selections = model.selections(ctx).clone();
selection_change(model, ctx);
// Copy selection to vim register
let buffer = model.content().as_ref(ctx);
let selection_model = model.buffer_selection_model().clone();
let selected_text = buffer
.selected_text_as_plain_text(selection_model, ctx)
.into_string();
if !selected_text.is_empty() {
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
registers.write_to_register(
register_name,
selected_text,
motion_type,
ctx,
);
});
}
match operand {
VimOperand::TextObject(_) => {
// For text objects, move to the start (min) of the selected range
let starts = model
.buffer_selection_model()
.as_ref(ctx)
.selection_offsets()
.mapped(|selection| {
let start = selection.head.min(selection.tail);
SelectionOffsets {
head: start,
tail: start,
}
});
model.vim_set_selections(starts, AutoScrollBehavior::None, ctx);
}
_ => {
model.vim_set_selections(
existing_selections,
AutoScrollBehavior::None,
ctx,
);
}
}
});
}
VimOperator::ToggleCase => {
self.model.update(ctx, |model, ctx| {
model.apply_case_transformation_with_selection_change(
selection_change,
CaseTransform::Toggle,
ctx,
);
});
}
VimOperator::Uppercase => {
self.model.update(ctx, |model, ctx| {
model.apply_case_transformation_with_selection_change(
selection_change,
CaseTransform::Uppercase,
ctx,
);
});
}
VimOperator::Lowercase => {
self.model.update(ctx, |model, ctx| {
model.apply_case_transformation_with_selection_change(
selection_change,
CaseTransform::Lowercase,
ctx,
);
});
}
VimOperator::ToggleComment => {
self.model.update(ctx, |model, ctx| {
let existing_selections = model.selections(ctx).clone();
selection_change(model, ctx);
model.toggle_comments(ctx);
if motion_type == MotionType::Linewise {
model.vim_move_to_first_nonwhitespace(false, ctx);
} else {
model.vim_set_selections(
existing_selections,
AutoScrollBehavior::None,
ctx,
);
}
});
}
}
}
fn replace_char(&mut self, c: char, char_count: u32, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.replace_char(c, char_count, ctx);
});
// Explicit call to ctx.notify() in the case that we don't make any updates to the model
ctx.notify();
}
fn search(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
self.last_search_direction = *direction;
self.show_find_bar(ctx);
}
fn cycle_search(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
let Some(find_bar) = &self.find_bar else {
return;
};
if !self.searcher.as_ref(ctx).has_query() {
return;
}
if !find_bar.as_ref(ctx).is_open() {
find_bar.update(ctx, |find_bar, _| find_bar.set_open(true));
}
// Vim-like behavior:
// 'n' (Forward) repeats in the same direction
// 'N' (Backward) reverses the last direction
let effective_dir = match (direction, self.last_search_direction) {
(Direction::Forward, dir) => dir,
(Direction::Backward, Direction::Backward) => Direction::Forward,
(Direction::Backward, Direction::Forward) => Direction::Backward,
};
// Map vim::Direction to a FindDirection
let find_dir = match effective_dir {
Direction::Forward => FindDirection::Down,
Direction::Backward => FindDirection::Up,
};
find_bar.update(ctx, |_find_bar, ctx| {
ctx.emit(FindViewEvent::NextMatch {
direction: find_dir,
})
});
}
fn search_word_at_cursor(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
self.last_search_direction = *direction;
let Some(find_bar) = &self.find_bar else {
return;
};
let word_under_cursor = self.model.as_ref(ctx).word_under_cursor_for_search(ctx);
if let Some(word) = word_under_cursor {
if !word.trim().is_empty() {
find_bar.update(ctx, |find_bar, ctx| {
find_bar.set_find_query(ctx, &word);
find_bar.set_open(true);
// Disable the find input; the search is already defined.
find_bar.set_find_input_editable(ctx, false);
});
self.searcher
.update(ctx, |searcher, _| searcher.set_auto_select(true));
self.run_find(&word, ctx);
ctx.notify();
}
}
}
fn ex_command(&mut self, _ctx: &mut ViewContext<Self>) {}
fn visual_operator(
&mut self,
operator: &VimOperator,
motion_type: MotionType,
register_name: char,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| {
// Compute the visual selection
let include_newline = *operator != VimOperator::Change;
model.vim_visual_selection_range(motion_type, include_newline, ctx);
if matches!(
operator,
VimOperator::Delete | VimOperator::Change | VimOperator::Yank
) {
let buffer = model.content().as_ref(ctx);
let selection_model = model.buffer_selection_model().clone();
let selected_text = buffer
.selected_text_as_plain_text(selection_model, ctx)
.into_string();
if !selected_text.is_empty() {
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
registers.write_to_register(register_name, selected_text, motion_type, ctx);
});
}
}
match operator {
VimOperator::Delete | VimOperator::Change => {
let selection_model = model.buffer_selection_model().clone();
model.update_content(
|mut content, ctx| {
content.apply_edit(
BufferEditAction::Backspace,
EditOrigin::UserInitiated,
selection_model,
ctx,
);
},
ctx,
);
if *operator == VimOperator::Change && motion_type == MotionType::Linewise {
model.vim_change_line_with_smart_indent(ctx);
}
}
VimOperator::ToggleCase | VimOperator::Lowercase | VimOperator::Uppercase => {
let transform = match operator {
VimOperator::ToggleCase => CaseTransform::Toggle,
VimOperator::Uppercase => CaseTransform::Uppercase,
VimOperator::Lowercase => CaseTransform::Lowercase,
_ => CaseTransform::Toggle,
};
model.transform_current_selections_case(transform, ctx);
}
VimOperator::Yank => {
model.vim_clear_selections(ctx);
}
VimOperator::ToggleComment => {
model.toggle_comments(ctx);
if motion_type == MotionType::Linewise {
model.vim_move_to_first_nonwhitespace(false, ctx);
} else {
model.vim_clear_selections(ctx);
}
}
}
});
// Force a re-render so that residual Visual mode highlight is cleared.
ctx.notify();
}
fn visual_paste(
&mut self,
motion_type: MotionType,
read_register_name: char,
write_register_name: char,
ctx: &mut ViewContext<Self>,
) {
// Read content from the specified vim register
let Some(RegisterContent {
text,
motion_type: yanked_motion_type,
}) = VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
registers.read_from_register(read_register_name, ctx)
})
else {
return;
};
self.model.update(ctx, |model, ctx| {
// Compute the visual selection
let include_newline =
motion_type == MotionType::Linewise && yanked_motion_type == MotionType::Linewise;
model.vim_visual_selection_range(motion_type, include_newline, ctx);
// Copy current selection to the write register before replacing it
let buffer = model.content().as_ref(ctx);
let selection_model = model.buffer_selection_model().clone();
let selected_text = buffer
.selected_text_as_plain_text(selection_model.clone(), ctx)
.into_string();
if !selected_text.is_empty() {
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
registers.write_to_register(
write_register_name,
selected_text,
motion_type,
ctx,
);
});
}
// Replace selection with yanked text
model.update_content(
|mut content, ctx| {
content.apply_edit(
BufferEditAction::Insert {
text: &text,
style: model.active_text_style(),
override_text_style: None,
},
EditOrigin::UserInitiated,
selection_model,
ctx,
);
},
ctx,
);
if motion_type == MotionType::Linewise {
model.vim_move_to_line_bound(LineBound::Start, false, ctx);
}
});
}
fn visual_text_object(&mut self, text_object: &VimTextObject, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.vim_select_text_object(text_object, None, ctx);
});
}
fn jump_to_first_line(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.jump_to_line_column(0, None, ctx);
});
}
fn jump_to_last_line(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
let buffer = model.content().as_ref(ctx);
let max_point = buffer.max_point();
model.jump_to_line_column(max_point.row as usize, None, ctx);
});
}
fn jump_to_line(&mut self, line_number: u32, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.jump_to_line_column(line_number as usize, None, ctx);
});
}
fn jump_to_matching_bracket(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.vim_jump_to_matching_bracket(false, ctx);
})
}
fn jump_to_unmatched_bracket(&mut self, bracket: &BracketChar, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.vim_jump_to_unmatched_bracket(bracket, false, ctx);
})
}
fn paste(
&mut self,
count: u32,
direction: &Direction,
register_name: char,
ctx: &mut ViewContext<Self>,
) {
let Some(RegisterContent { text, motion_type }) = VimRegisters::handle(ctx)
.update(ctx, |registers, ctx| {
registers.read_from_register(register_name, ctx)
})
else {
return;
};
// For linewise cursor positioning, compute how many leading whitespace characters are at
// the start of the first inserted line.
let leading_ws = if motion_type == MotionType::Linewise {
text.chars()
.take_while(|c| c.is_whitespace() && *c != '\n')
.count()
} else {
0
};
let text = match motion_type {
MotionType::Charwise => text,
MotionType::Linewise => match direction {
Direction::Backward => {
// 'P' - paste above current line
// Insert the text followed by a newline to push current line down
trim_one_end_match(&text, '\n').to_owned() + "\n"
}
Direction::Forward => {
// 'p' - paste below current line
"\n".to_owned() + trim_one_end_match(&text, '\n')
}
},
};
let insert_text = text.repeat(count as usize);
let (insert_point, cursor_offset_len) = match motion_type {
MotionType::Charwise => match direction {
Direction::Backward => (VimInsertPoint::BeforeCursor, insert_text.len() - 1),
Direction::Forward => (VimInsertPoint::AtCursor, insert_text.len() - 1),
},
MotionType::Linewise => match direction {
Direction::Backward => (VimInsertPoint::LineStart, leading_ws),
// For linewise "p", offset the cursor by 1 to get onto the new line, then by the line's leading whitespace.
Direction::Forward => (VimInsertPoint::LineEnd, 1 + leading_ws),
},
};
self.model.update(ctx, |model, ctx| {
let selection_model = model.buffer_selection_model().clone();
model.update_content(
|mut content, ctx| {
content.apply_edit(
BufferEditAction::VimEvent {
text: insert_text,
insert_point,
cursor_offset_len,
},
EditOrigin::UserInitiated,
selection_model,
ctx,
);
},
ctx,
);
});
}
fn insert_text(
&mut self,
text: &str,
position: &InsertPosition,
count: u32,
ctx: &mut ViewContext<Self>,
) {
self.model.update(ctx, |model, ctx| {
model.vim_insert_text(text, position, count, ctx);
});
}
fn toggle_case(&mut self, char_count: u32, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.vim_toggle_case_chars(char_count, ctx);
});
}
fn join_line(&mut self, mut count: u32, ctx: &mut ViewContext<Self>) {
// 1J joins two lines, which is the same as 2J.
if count == 1 {
count = 2;
}
self.model.update(ctx, |model, ctx| {
let buffer = model.content().as_ref(ctx);
let current_selections = model.selections(ctx);
let mut replacement_ranges = Vec::new();
// For each selection, find `count` newlines to replace with spaces
for selection in current_selections.iter() {
let start_offset = selection.head;
let mut current_offset = start_offset;
let mut newlines_found = 0;
while newlines_found < count.saturating_sub(1) {
let Some(ch) = buffer.char_at(current_offset) else {
break;
};
if ch == '\n' {
newlines_found += 1;
let mut range_end = current_offset + 1;
// Trim whitespace from the start of the next line
while range_end < buffer.max_charoffset() {
match buffer.char_at(range_end) {
Some(ch) if ch.is_whitespace() && ch != '\n' => range_end += 1,
_ => break,
}
}
replacement_ranges.push((current_offset, range_end));
current_offset = range_end;
} else {
current_offset += 1;
}
}
}
// If we have edits, update the model
if let Ok(edits) = vec1::Vec1::try_from_vec(
replacement_ranges
.into_iter()
.map(|(start, end)| (" ".to_string(), start..end))
.collect(),
) {
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,
);
}
});
}
fn undo(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.undo(ctx);
// Clear selections after undo, for things like delete/change operations which
// modify the editor state by changing selections and then making an insert/delete.
//
// TODO(liliwilson): this only works for the vim undo: cmd+Z and cmd+shift+z will undo
// the operation but not the selection. Need a deeper change to the buffer model
// undostack to support this.
model.vim_clear_selections(ctx);
});
}
fn change_mode(&mut self, old: &VimMode, new: &ModeTransition, ctx: &mut ViewContext<Self>) {
match new.mode {
VimMode::Normal => {
if *old == VimMode::Insert {
// When exiting insert mode, move cursor back to cover
// the character that was last inserted. In vim, the cursor should
// be ON the last inserted character, not after it.
self.model.update(ctx, |model, ctx| {
model.vim_move_horizontal_by_offset(
1,
&Direction::Backward,
false,
true,
ctx,
);
});
}
// Implement line capping for normal mode
self.vim_maybe_enforce_cursor_line_cap(ctx);
}
VimMode::Insert => {
// Apply insert position for different insert commands (i, a, o, etc.)
self.vim_apply_insert_position(&new.position, ctx);
}
VimMode::Visual(_) => {
self.model.update(ctx, |model, ctx| {
model.vim_set_visual_tail_to_selection_heads(ctx);
});
}
_ => {}
}
ctx.notify();
}
fn backspace(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.backspace(ctx);
});
}
fn delete_forward(&mut self, ctx: &mut ViewContext<Self>) {
self.model.update(ctx, |model, ctx| {
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
});
}
fn escape(&mut self, ctx: &mut ViewContext<Self>) {
match self.vim_mode(ctx) {
Some(VimMode::Normal) => {
ctx.emit(CodeEditorEvent::VimEscapeInNormalMode);
}
_ => {
self.vim_escape(ctx);
}
}
}
fn goto_definition(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(CodeEditorEvent::VimGotoDefinition);
}
fn find_references(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(CodeEditorEvent::VimFindReferences);
}
fn show_hover(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(CodeEditorEvent::VimShowHover);
}
}
/// Like [`str::trim_end_matches`] except that it only trims up to a single instance.
fn trim_one_end_match(s: &str, ch: char) -> &str {
if s.ends_with(ch) {
&s[..s.len() - 1]
} else {
s
}
}
#[cfg(test)]
#[path = "vim_handler_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff