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 galaxy_core::ui::{appearance::Appearance, theme::Fill}; use galaxy_editor::render::element::VerticalExpansionBehavior; use galaxyui::{ 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, comment_text: String, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] line: Option, }, 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, editor: ViewHandle, save_button: ViewHandle, close_button: ViewHandle, remove_button: ViewHandle, line: Option, show_remove_button: bool, save_button_disabled: bool, laid_out_size: RefCell>, is_imported_comment: bool, } impl CommentEditor { pub fn new( ctx: &mut ViewContext, comment_model: ModelHandle, ) -> 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, comment_model: ModelHandle, comment_id: Option, 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 { 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, ) -> ( ViewHandle, ViewHandle, ViewHandle, ) { 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) { 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, ) { 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) { 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.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, comment_text: &str, origin: &CommentOrigin, ctx: &mut ViewContext, ) { 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.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) { 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 { let theme = appearance.theme(); let sub_text_color = theme.sub_text_color(Fill::Solid(background)).into_solid(); let icon = Icon::Github .to_galaxyui_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 { 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 { 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 { 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) { 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) { 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( markdown_content: Option<&str>, ctx: &mut ViewContext, ) -> ViewHandle 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( markdown_content: &str, disable_scrolling: bool, max_width: Option, ctx: &mut ViewContext, ) -> ViewHandle 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( markdown_content: Option<&str>, disable_scrolling: bool, max_width: Option, ctx: &mut ViewContext, ) -> ViewHandle 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;