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;
|
||||
Reference in New Issue
Block a user