Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod view;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub mod view;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user