Files
galaxy/app/src/code_review/comment_rendering.rs
T

482 lines
16 KiB
Rust

//! 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<dyn Fn(&mut EventContext) + 'static>,
}
/// Wraps the given content element in the standard comment card chrome
/// (rounded corners, neutral background, outline border).
fn comment_card_container(
content: Box<dyn Element>,
theme: &galaxy_core::ui::theme::GalaxyTheme,
) -> Box<dyn Element> {
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<Box<dyn Element>>,
on_header_click: Option<&HeaderClickHandler>,
app: &AppContext,
) -> Box<dyn Element> {
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<Box<dyn Element>>,
corner_radius: CornerRadius,
on_header_click: Option<&HeaderClickHandler>,
appearance: &Appearance,
) -> Box<dyn Element> {
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<RichTextEditorView>,
last_updated_duration: Duration,
is_imported_from_github: bool,
metadata_trailing_element: Option<Box<dyn Element>>,
appearance: &Appearance,
) -> Box<dyn Element> {
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<V: View>(
content: &LineDiffContent,
file_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) -> ViewHandle<CodeEditorView> {
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<CodeEditorView>,
app: &AppContext,
) -> Box<dyn Element> {
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<CodeEditorView>),
}
/// 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<RichTextEditorView>,
diff_content: Option<CommentDiffContent>,
source: AttachedReviewComment,
title: String,
last_updated_duration: Duration,
is_collapsed: bool,
}
impl CommentViewCard {
pub(crate) fn new<V: View>(
source: AttachedReviewComment,
always_use_static_diff: bool,
disable_scrolling: bool,
max_width: Option<Pixels>,
repo_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) -> 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<V: View>(
comment: &AttachedReviewComment,
always_use_static_diff: bool,
ctx: &mut ViewContext<V>,
) -> Option<CommentDiffContent> {
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<V: View>(
&mut self,
new_source: AttachedReviewComment,
repo_path: Option<&LocalOrRemotePath>,
ctx: &mut ViewContext<V>,
) {
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<Box<dyn Element>>,
header_trailing_element: Option<Box<dyn Element>>,
metadata_trailing_element: Option<Box<dyn Element>>,
on_header_click: Option<&HeaderClickHandler>,
app: &AppContext,
) -> Box<dyn Element> {
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<RichTextEditorView> {
&self.comment_editor
}
pub(crate) fn static_diff_editor(&self) -> Option<&ViewHandle<CodeEditorView>> {
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()),
}
}
}