//! Shared rendering helpers for displaying code review comments. //! //! These functions are used by both the `CommentListView` (in the code review panel) //! and the blocklist's imported comments rendering. use std::rc::Rc; use chrono::{Duration, Local}; use pathfinder_color::ColorU; use galaxy_core::ui::theme::color::internal_colors::{neutral_1, neutral_2, text_sub}; use galaxy_core::ui::theme::Fill; use warp_editor::content::buffer::InitialBufferState; use warp_editor::render::element::VerticalExpansionBehavior; use warpui::elements::new_scrollable::ScrollableAppearance; use warpui::elements::{ Border, ChildView, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Shrinkable, Text, }; use warpui::platform::Cursor; use warpui::text_layout::ClipConfig; use warpui::units::Pixels; use warpui::{AppContext, Element, EventContext, SingletonEntity, View, ViewContext, ViewHandle}; use crate::appearance::Appearance; use crate::code::buffer_location::LocalOrRemotePath; use crate::code::editor::comment_editor::create_readonly_comment_markdown_editor; use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView}; use crate::code_review::comments::{ AttachedReviewComment, AttachedReviewCommentTarget, LineDiffContent, }; use crate::editor::InteractionState; use crate::notebooks::editor::view::RichTextEditorView; use crate::util::time_format::human_readable_approx_duration; /// Configuration for making the comment header clickable. pub(crate) struct HeaderClickHandler { pub mouse_state: MouseStateHandle, pub on_click: Rc, } /// Wraps the given content element in the standard comment card chrome /// (rounded corners, neutral background, outline border). fn comment_card_container( content: Box, theme: &galaxy_core::ui::theme::GalaxyTheme, ) -> Box { Container::new(content) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_background(Fill::Solid(neutral_1(theme))) .with_border(Border::all(1.).with_border_fill(theme.outline())) .finish() } /// Renders a collapsed comment card showing only the file-path header and an /// optional trailing element (e.g. action buttons). fn render_collapsed_comment_card( title: &str, is_outdated: bool, header_trailing_element: Option>, on_header_click: Option<&HeaderClickHandler>, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let header = render_comment_file_path_header( title, is_outdated, header_trailing_element, CornerRadius::with_all(Radius::Pixels(8.)), on_header_click, appearance, ); comment_card_container(header, theme) } fn render_comment_file_path_header( title: &str, is_outdated: bool, trailing_element: Option>, corner_radius: CornerRadius, on_header_click: Option<&HeaderClickHandler>, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let file_path_text = Text::new( title.to_owned(), appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .soft_wrap(false) .with_clip(ClipConfig::start()) .with_color( theme .main_text_color(Fill::Solid(neutral_2(theme))) .into_solid(), ) .finish(); let mut header_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., file_path_text).finish()); if is_outdated { let yellow_border: ColorU = theme.terminal_colors().normal.yellow.into(); let yellow_text: ColorU = theme.terminal_colors().bright.yellow.into(); let outdated_chip = Container::new( Text::new( "Outdated", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(yellow_text) .finish(), ) .with_margin_left(8.) .with_horizontal_padding(8.) .with_vertical_padding(4.) .with_border(Border::all(1.).with_border_fill(Fill::Solid(yellow_border))) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(2.))) .with_background(Fill::Solid(neutral_2(theme))) .finish(); header_row.add_child(outdated_chip); } if let Some(trailing) = trailing_element { header_row = header_row .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); header_row.add_child(Container::new(trailing).with_margin_left(8.).finish()); } let container = Container::new(header_row.finish()) .with_horizontal_padding(12.) .with_vertical_padding(8.) .with_background(Fill::Solid(neutral_2(theme))) .with_corner_radius(corner_radius) .finish(); if let Some(click_handler) = on_header_click { let callback = Rc::clone(&click_handler.on_click); Hoverable::new(click_handler.mouse_state.clone(), |_| container) .on_click(move |ctx, _, _| { callback(ctx); }) .with_cursor(Cursor::PointingHand) .with_defer_events_to_children() .finish() } else { container } } fn render_comment_text_section( comment_editor: &ViewHandle, last_updated_duration: Duration, is_imported_from_github: bool, metadata_trailing_element: Option>, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let background = Fill::Solid(neutral_1(theme)); let mut left_section = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(8.); if is_imported_from_github { left_section.add_child( Text::new( "From GitHub".to_string(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_sub(theme, background)) .finish(), ); } left_section.add_child( Text::new( human_readable_approx_duration(last_updated_duration, true /* sentence_case */), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color( appearance .theme() .disabled_text_color(background) .into_solid(), ) .finish(), ); let mut metadata_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child(left_section.finish()); if let Some(trailing) = metadata_trailing_element { metadata_row = metadata_row .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_child(trailing); } else { metadata_row = metadata_row.with_main_axis_alignment(MainAxisAlignment::Start); } let comment_content_child = ChildView::new(comment_editor).finish(); let column = Flex::column() .with_children([metadata_row.finish(), comment_content_child]) .finish(); Container::new(column) .with_background(background) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_uniform_padding(8.) .finish() } /// Creates a read-only, syntax-highlighted code editor for displaying static diff content. /// /// The editor is configured with infinite height, no diff UI, no line numbers, and selectable /// interaction state. The buffer is populated with the diff's original text and syntax /// highlighting is set based on the file path. fn create_static_diff_content_editor( content: &LineDiffContent, file_path: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) -> ViewHandle { let editor = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight), ctx, ) .with_can_show_diff_ui(false) .with_show_line_numbers(false) .with_horizontal_scrollbar_appearance(ScrollableAppearance::new( ScrollbarWidth::Auto, false, )) }); editor.update(ctx, |view, ctx| { view.set_show_current_line_highlights(false, ctx); view.set_interaction_state(InteractionState::Selectable, ctx); let original_text = content.original_text(); let state = InitialBufferState::plain_text(original_text.trim()); view.reset(state, ctx); if let Some(file_path) = file_path { let language_path = file_path.path_component(); view.set_language_with_path(&language_path, ctx); } }); editor } fn render_static_diff_content_element( editor: &ViewHandle, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); Container::new(ChildView::new(editor).finish()) .with_background(theme.background()) .with_horizontal_padding(8.) .with_vertical_padding(4.) .finish() } /// How to display the diff content section of a comment card. enum CommentDiffContent { /// Diff content rendered via a live editor lens. The element is provided at render time. EditorLens, /// Diff content rendered via a static, read-only `CodeEditorView`. StaticEditor(ViewHandle), } /// A shared UI component for a single code review comment card. /// /// Used by both `CommentListView` (code review panel) and the blocklist's /// imported comments. Owns the view handles for the comment body editor and /// (optionally) a static diff editor, plus the underlying comment data. pub(crate) struct CommentViewCard { comment_editor: ViewHandle, diff_content: Option, source: AttachedReviewComment, title: String, last_updated_duration: Duration, is_collapsed: bool, } impl CommentViewCard { pub(crate) fn new( source: AttachedReviewComment, always_use_static_diff: bool, disable_scrolling: bool, max_width: Option, repo_path: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) -> Self { let comment_editor = create_readonly_comment_markdown_editor( &source.content, disable_scrolling, max_width, ctx, ); let diff_content = Self::diff_content_for_comment(&source, always_use_static_diff, ctx); let title = Self::compute_title(&source, repo_path); let last_updated_duration = Local::now() - source.last_update_time; Self { comment_editor, diff_content, source, title, last_updated_duration, is_collapsed: false, } } fn diff_content_for_comment( comment: &AttachedReviewComment, always_use_static_diff: bool, ctx: &mut ViewContext, ) -> Option { if let AttachedReviewCommentTarget::Line { absolute_file_path, content, .. } = &comment.target { if always_use_static_diff || comment.outdated { Some(CommentDiffContent::StaticEditor( // Language detection only needs the path component (extension). create_static_diff_content_editor(content, Some(absolute_file_path), ctx), )) } else { Some(CommentDiffContent::EditorLens) } } else { None } } pub(crate) fn toggle_collapsed(&mut self) { self.is_collapsed = !self.is_collapsed; } pub(crate) fn is_collapsed(&self) -> bool { self.is_collapsed } /// Updates the comment data and resets the body editor with the new content. pub(crate) fn update_source( &mut self, new_source: AttachedReviewComment, repo_path: Option<&LocalOrRemotePath>, ctx: &mut ViewContext, ) { self.comment_editor.update(ctx, |editor, ctx| { editor.model().update(ctx, |model, ctx| { model.reset_with_markdown(&new_source.content, ctx); }); }); self.source = new_source; self.title = Self::compute_title(&self.source, repo_path); } /// Renders the comment card. When collapsed, only the header and trailing /// element are shown. When expanded, the full card with diff content and /// comment text is rendered. /// /// When `diff_content` is `EditorLens`, the caller must supply the live element via /// `editor_lens_element`. For `StaticEditor` or `None` it is ignored. /// /// When `on_header_click` is provided, the entire header area becomes clickable. pub(crate) fn render( &self, editor_lens_element: Option>, header_trailing_element: Option>, metadata_trailing_element: Option>, on_header_click: Option<&HeaderClickHandler>, app: &AppContext, ) -> Box { if self.is_collapsed { return render_collapsed_comment_card( &self.title, self.source.outdated, header_trailing_element, on_header_click, app, ); } let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let mut card = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); card.add_child(render_comment_file_path_header( &self.title, self.source.outdated, header_trailing_element, CornerRadius::with_top(Radius::Pixels(8.)), on_header_click, appearance, )); match &self.diff_content { Some(CommentDiffContent::EditorLens) => { if let Some(lens) = editor_lens_element { card.add_child(lens); } } Some(CommentDiffContent::StaticEditor(editor)) => { card.add_child(render_static_diff_content_element(editor, app)); } None => {} } card.add_child(render_comment_text_section( &self.comment_editor, self.last_updated_duration, self.source.origin.is_imported_from_github(), metadata_trailing_element, appearance, )); comment_card_container(card.finish(), theme) } pub(crate) fn source(&self) -> &AttachedReviewComment { &self.source } pub(crate) fn comment_editor(&self) -> &ViewHandle { &self.comment_editor } pub(crate) fn static_diff_editor(&self) -> Option<&ViewHandle> { match &self.diff_content { Some(CommentDiffContent::StaticEditor(editor)) => Some(editor), _ => None, } } pub(crate) fn uses_editor_lens(&self) -> bool { matches!(self.diff_content, Some(CommentDiffContent::EditorLens)) } /// Refreshes the cached `last_updated_duration` to the current time. pub(crate) fn refresh_last_updated_duration(&mut self) { self.last_updated_duration = Local::now() - self.source.last_update_time; } fn compute_title( source: &AttachedReviewComment, repo_path: Option<&LocalOrRemotePath>, ) -> String { let file_path = source.target.absolute_file_path().map(|p| { repo_path .and_then(|rp| rp.strip_repo_prefix(p)) .unwrap_or_else(|| p.display_path()) }); let line_number = source.target.line_number().map(|lc| lc.as_u32() + 1); match (file_path, line_number) { (Some(path), Some(line)) => format!("{path}:{line}"), (Some(path), None) => path, _ => source .head() .map(|head| head.title()) .unwrap_or_else(|| "Review Comment".to_string()), } } }