mod header_revamp; use galaxy_core::features::FeatureFlag; use galaxyui::elements::{ Align, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack, Text, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::platform::Cursor; use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::{AppContext, Element, ModelHandle, ViewHandle}; use pathfinder_geometry::vector::vec2f; use crate::appearance::Appearance; use crate::code_review::code_review_view::{ get_discard_button_disabled_tooltip, CodeReviewAction, CodeReviewHeaderFields, CodeReviewView, LoadedState, CONTENT_TOP_MARGIN, }; use crate::code_review::diff_state::DiffStateModel; use crate::menu::Menu; use crate::ui_components::icons::Icon; use crate::view_components::action_button::ActionButton; // This is a best effort guess of the size of all of the elements in the header to know when we should start to wrap to the second row const HEADER_WRAP_BREAKPOINT: f32 = 450.; pub(crate) const HEADER_BUTTON_PADDING: Coords = Coords { top: 2., bottom: 2., left: 6., right: 6., }; #[derive(Default)] struct StateHandles { branch_name_tooltip: MouseStateHandle, discard_all_button: MouseStateHandle, add_diff_set_context_button: MouseStateHandle, } pub struct CodeReviewHeader { state_handles: StateHandles, } impl CodeReviewHeader { pub fn new() -> Self { Self { state_handles: StateHandles::default(), } } pub fn render( &self, state: &LoadedState, appearance: &Appearance, code_review_header_fields: &CodeReviewHeaderFields, app: &AppContext, ) -> Box { let wide_layout = self.render_wide_layout(state, appearance, code_review_header_fields, app); let compact_layout = self.render_compact_layout(state, appearance, code_review_header_fields, app); let header_switch = SizeConstraintSwitch::new( wide_layout, vec![( SizeConstraintCondition::WidthLessThan(HEADER_WRAP_BREAKPOINT), compact_layout, )], ) .finish(); Container::new(Clipped::new(Shrinkable::new(1., header_switch).finish()).finish()) .with_margin_top(CONTENT_TOP_MARGIN) .with_margin_bottom(12.) .finish() } fn render_wide_layout( &self, state: &LoadedState, appearance: &Appearance, code_review_header_fields: &CodeReviewHeaderFields, app: &AppContext, ) -> Box { let theme = appearance.theme(); let mut left_section_wide = Flex::row() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Center); left_section_wide.add_child( Shrinkable::new( 100.0, self.create_branch_tooltip( &code_review_header_fields.diff_state_model, appearance, app, ), ) .finish(), ); left_section_wide.add_child( Container::new(CodeReviewView::render_diff_stats( &state.to_diff_stats(), appearance, )) .with_margin_right(8.) .finish(), ); let mut right_section_wide = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center); if code_review_header_fields.conflict_count > 0 { right_section_wide.add_child( Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ChildView::new(&code_review_header_fields.conflict_filter_button) .finish(), ) .with_child( Container::new( Text::new( format!( "{} unresolved", code_review_header_fields.conflict_count ), appearance.ui_font_family(), appearance.ui_font_size() * 0.85, ) .with_color(theme.sub_text_color(theme.surface_2()).into()) .finish(), ) .with_margin_left(6.) .finish(), ) .finish(), ) .with_margin_right(4.) .finish(), ); } right_section_wide .add_child(ChildView::new(&code_review_header_fields.diff_selector).finish()); let has_no_changes = state.to_diff_stats().has_no_changes(); if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() { right_section_wide.add_child(self.create_discard_button( state, &code_review_header_fields.diff_state_model, appearance, app, )); } if FeatureFlag::DiffSetAsContext.is_enabled() && !has_no_changes { if FeatureFlag::FileAndDiffSetComments.is_enabled() { right_section_wide.add_child(self.render_header_dropdown_button( &code_review_header_fields.header_dropdown_button, &code_review_header_fields.header_menu, code_review_header_fields.header_menu_open, )); } else { right_section_wide.add_child(self.render_add_diff_set_context_button(appearance)); } } if code_review_header_fields.is_in_split_pane { right_section_wide = right_section_wide.with_child(self.render_maximize_pane_button( &code_review_header_fields.maximize_button, appearance, )); } Clipped::new( Shrinkable::new( 1., Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(2., left_section_wide.finish()).finish()) .with_child(right_section_wide.finish()) .finish(), ) .finish(), ) .finish() } fn render_compact_layout( &self, state: &LoadedState, appearance: &Appearance, code_review_header_fields: &CodeReviewHeaderFields, app: &AppContext, ) -> Box { let theme = appearance.theme(); let mut left_section_compact = Flex::row() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Center); left_section_compact.add_child( Shrinkable::new( 100.0, self.create_branch_tooltip( &code_review_header_fields.diff_state_model, appearance, app, ), ) .finish(), ); left_section_compact.add_child( Container::new(CodeReviewView::render_diff_stats( &state.to_diff_stats(), appearance, )) .with_margin_right(8.) .finish(), ); let mut right_subsection_compact = Flex::row() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Center); if FeatureFlag::DiscardPerFileAndAllChanges.is_enabled() { right_subsection_compact.add_child(self.create_discard_button( state, &code_review_header_fields.diff_state_model, appearance, app, )); } let has_no_changes = state.to_diff_stats().has_no_changes(); if FeatureFlag::DiffSetAsContext.is_enabled() && !has_no_changes { if FeatureFlag::FileAndDiffSetComments.is_enabled() { right_subsection_compact.add_child(self.render_header_dropdown_button( &code_review_header_fields.header_dropdown_button, &code_review_header_fields.header_menu, code_review_header_fields.header_menu_open, )); } else { right_subsection_compact .add_child(self.render_add_diff_set_context_button(appearance)); } } if code_review_header_fields.is_in_split_pane { right_subsection_compact.add_child(self.render_maximize_pane_button( &code_review_header_fields.maximize_button, appearance, )); } let mut right_section_compact = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Center); if code_review_header_fields.conflict_count > 0 { right_section_compact.add_child( Container::new( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ChildView::new(&code_review_header_fields.conflict_filter_button) .finish(), ) .with_child( Container::new( Text::new( format!( "{} unresolved", code_review_header_fields.conflict_count ), appearance.ui_font_family(), appearance.ui_font_size() * 0.85, ) .with_color(theme.sub_text_color(theme.surface_2()).into()) .finish(), ) .with_margin_left(6.) .finish(), ) .finish(), ) .with_margin_right(4.) .finish(), ); } right_section_compact .add_child(ChildView::new(&code_review_header_fields.diff_selector).finish()); right_section_compact.add_child(Container::new(right_subsection_compact.finish()).finish()); Clipped::new( Shrinkable::new( 1., Flex::column() .with_main_axis_alignment(MainAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Min) .with_child(Align::new(left_section_compact.finish()).left().finish()) .with_child( Container::new(Align::new(right_section_compact.finish()).right().finish()) .with_margin_top(8.) .finish(), ) .finish(), ) .finish(), ) .finish() } fn create_branch_name_element( diff_state_model: &ModelHandle, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); let header_text = Self::get_header_text(diff_state_model, app); Container::new( galaxyui::elements::Text::new_inline( header_text, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.main_text_color(theme.background()).into()) .finish(), ) .with_margin_right(8.) .finish() } fn create_branch_tooltip( &self, diff_state_model: &ModelHandle, appearance: &Appearance, app: &AppContext, ) -> Box { let header_text = Self::get_header_text(diff_state_model, app); appearance.ui_builder().overlay_tool_tip_on_element( header_text, self.state_handles.branch_name_tooltip.clone(), Self::create_branch_name_element(diff_state_model, appearance, app), ParentAnchor::BottomLeft, ChildAnchor::TopLeft, vec2f(0., 4.), ) } fn create_discard_button( &self, state: &LoadedState, diff_state_model: &ModelHandle, appearance: &Appearance, app: &AppContext, ) -> Box { let theme = appearance.theme(); let has_no_changes = state.to_diff_stats().has_no_changes(); let git_operation_blocked = diff_state_model.as_ref(app).is_git_operation_blocked(app); let is_disabled = has_no_changes || git_operation_blocked; let sub_text_color = theme.sub_text_color(theme.background()); let mut button_builder = appearance .ui_builder() .button( ButtonVariant::Secondary, self.state_handles.discard_all_button.clone(), ) .with_style(UiComponentStyles::default().set_padding(HEADER_BUTTON_PADDING)) .with_style(UiComponentStyles { font_color: Some(sub_text_color.into()), ..Default::default() }) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Discard all".to_string(), Icon::ReverseLeft.to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( sub_text_color.into_solid(), )), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(16., 16.), ) .with_inner_padding(4.), ); if is_disabled { let disabled_styles = UiComponentStyles { font_color: Some(theme.disabled_text_color(theme.background()).into_solid()), ..Default::default() }; button_builder = button_builder.with_style(disabled_styles).with_cursor(None); } let mut button_hoverable = button_builder.build(); if !is_disabled { button_hoverable = button_hoverable.on_click(|ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::ShowDiscardConfirmDialog(None)); }); } let button_element = button_hoverable.finish(); if is_disabled { let tooltip_text = get_discard_button_disabled_tooltip(git_operation_blocked); Container::new(CodeReviewHeader::wrap_disabled_button_with_tooltip( button_element, tooltip_text, self.state_handles.discard_all_button.clone(), appearance, )) .with_margin_left(4.) .finish() } else { Container::new(button_element).with_margin_left(4.).finish() } } fn wrap_disabled_button_with_tooltip( button_element: Box, tooltip_text: String, mouse_state: MouseStateHandle, appearance: &Appearance, ) -> Box { let ui_builder = appearance.ui_builder(); Hoverable::new(mouse_state, move |state| { let mut stack = Stack::new().with_child(button_element); if state.is_hovered() { let disabled_tooltip = ui_builder.tool_tip(tooltip_text).build().finish(); let tooltip_offset = OffsetPositioning::offset_from_parent( vec2f(0., -8.), ParentOffsetBounds::WindowByPosition, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, ); stack.add_positioned_overlay_child(disabled_tooltip, tooltip_offset); } stack.finish() }) .with_cursor(Cursor::Arrow) .finish() } pub(super) fn render_maximize_pane_button( &self, maximize_button: &ViewHandle, appearance: &Appearance, ) -> Box { Container::new( ConstrainedBox::new(ChildView::new(maximize_button).finish()) .with_height(appearance.ui_font_size() + 10.) .with_width(appearance.ui_font_size() + 10.) .finish(), ) .with_margin_left(8.) .with_margin_right(6.) .finish() } fn render_add_diff_set_context_button(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let ui_builder = appearance.ui_builder().clone(); let button = ui_builder .button( ButtonVariant::Secondary, self.state_handles.add_diff_set_context_button.clone(), ) .with_text_and_icon_label(TextAndIcon::new( TextAndIconAlignment::IconFirst, "", Icon::Paperclip.to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid( theme.main_text_color(theme.background()).into(), )), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(16., 16.), )) // manual overrides so it matches the branch dropdown and discard all button .with_style(UiComponentStyles::default().set_padding(Coords { top: 6., bottom: 6., left: 6., right: 6., })) .with_tooltip(move || { ui_builder .tool_tip("Add diff set as context".to_owned()) .build() .finish() }) .with_tooltip_position( galaxyui::ui_components::button::ButtonTooltipPosition::AboveLeft, ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(CodeReviewAction::AddDiffSetAsContext( crate::code_review::DiffSetScope::All, )); }) .finish(); Container::new(button).with_margin_left(4.).finish() } /// Renders the header dropdown trigger /// /// This button dispatches a CodeReviewAction and, when the header menu is open, renders the /// attached menu in a Stack overlay positioned relative to the button (like other overflow /// buttons in the app, e.g. Drive's "create new" button). fn render_header_dropdown_button( &self, header_dropdown_button: &ViewHandle, header_menu: &ViewHandle>, header_menu_open: bool, ) -> Box { let button_container = Container::new( ConstrainedBox::new(ChildView::new(header_dropdown_button).finish()) .with_height(galaxy_core::ui::icons::ICON_DIMENSIONS) .with_width(galaxy_core::ui::icons::ICON_DIMENSIONS) .finish(), ) .with_margin_left(4.) .finish(); let mut stack = Stack::new().with_child(button_container); if header_menu_open { stack.add_positioned_overlay_child( ChildView::new(header_menu).finish(), OffsetPositioning::offset_from_parent( vec2f(0., 0.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomRight, ChildAnchor::TopRight, ), ); } stack.finish() } fn get_header_text(diff_state_model: &ModelHandle, app: &AppContext) -> String { let branch_name = diff_state_model.read(app, |model, ctx| model.get_current_branch_name(ctx)); branch_name.unwrap_or("Reviewing open changes".to_string()) } }