//! Push / publish mode for [`GitDialog`]. //! //! Renders the branch's unpushed commit list with per-commit file expansion. //! Each commit's file list is captured up front on `Commit.files`, so //! expansion is a pure toggle (no per-commit fetch). A single `publish: bool` //! flag toggles between pushing an existing branch and publishing a new one //! (setting upstream). On confirm, spawns `run_push`. use std::collections::HashMap; use warp_core::send_telemetry_from_ctx; use warp_core::ui::appearance::Appearance; use warp_errors::report_error; use warpui::ViewContext; use warpui::elements::{ Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Text, }; use warpui::platform::Cursor; use crate::code::editor::{add_color, remove_color}; use crate::code_review::git_dialog::{ GitDialog, GitDialogAction, GitDialogEvent, GitDialogMode, render_branch_section, render_chevron_icon, render_file_list, show_toast, user_facing_git_error, }; use crate::code_review::telemetry_event::{ CodeReviewTelemetryEvent, GitDialogStatus, GitOperationKind, }; use crate::ui_components::icons::Icon; use crate::util::git::Commit; /// Push-specific sub-actions, dispatched wrapped in `GitDialogAction::Push`. #[derive(Clone, Debug, PartialEq)] pub enum PushSubAction { ToggleCommit(String), } pub struct PushState { pub(super) publish: bool, commits: Vec, expanded: HashMap, commit_mouse_states: HashMap, commits_scroll_state: ClippedScrollStateHandle, } pub(super) fn new_state(publish: bool, commits: Vec) -> PushState { let commit_mouse_states = commits .iter() .map(|c| (c.hash.clone(), MouseStateHandle::default())) .collect(); PushState { publish, commits, expanded: HashMap::new(), commit_mouse_states, commits_scroll_state: ClippedScrollStateHandle::default(), } } pub(super) fn confirm_label(publish: bool) -> &'static str { if publish { "Publish" } else { "Push" } } pub(super) fn confirm_icon(publish: bool) -> Icon { if publish { Icon::UploadCloud } else { Icon::ArrowUp } } fn loading_label(publish: bool) -> &'static str { if publish { "Publishing…" } else { "Pushing…" } } pub(super) fn handle_sub_action( me: &mut GitDialog, action: &PushSubAction, ctx: &mut ViewContext, ) { match action { PushSubAction::ToggleCommit(hash) => { // File lists are captured up front on `Commit.files`, so expansion // is a pure toggle. if let GitDialogMode::Push(state) = me.mode_mut() { let is_expanded = state.expanded.entry(hash.clone()).or_insert(false); *is_expanded = !*is_expanded; } ctx.notify(); } } } pub(super) fn start_confirm(me: &mut GitDialog, ctx: &mut ViewContext) { let publish = match me.mode() { GitDialogMode::Push(state) => state.publish, _ => return, }; let branch = me.branch_name().to_string(); me.set_loading(loading_label(publish), ctx); me.diff_state_model().update(ctx, |m, ctx| { m.git_push(branch, ctx); }); } /// Shared push completion: toast + telemetry + close. pub(super) fn finish_push( me: &GitDialog, publish: bool, result: anyhow::Result<()>, ctx: &mut ViewContext, ) { let (status, error) = match &result { Ok(_) => (GitDialogStatus::Succeeded, None), Err(err) => (GitDialogStatus::Failed, Some(err.to_string())), }; match result { Ok(_) => { let toast_msg = if publish { "Branch successfully published." } else { "Changes successfully pushed." }; show_toast(toast_msg, ctx); } Err(e) => { report_error!(&e); show_toast(user_facing_git_error(&e.to_string()), ctx); } } send_telemetry_from_ctx!( CodeReviewTelemetryEvent::GitDialogCompleted { is_local: Some(!me.repo_location().is_remote()), operation: if publish { GitOperationKind::Publish } else { GitOperationKind::Push }, status, error, }, ctx ); ctx.emit(GitDialogEvent::Completed); } pub(super) fn render_body( state: &PushState, branch_name: &str, appearance: &Appearance, ) -> Box { let mut body = Flex::column().with_child( Container::new(render_branch_section(branch_name, appearance)) .with_margin_bottom(16.) .finish(), ); if !state.commits.is_empty() { body.add_child(render_commits_section(state, appearance)); } body.finish() } fn render_commits_section(state: &PushState, appearance: &Appearance) -> Box { let theme = appearance.theme(); let main_color = theme.main_text_color(theme.surface_1()).into_solid(); let sub_color = theme.sub_text_color(theme.surface_1()).into_solid(); let label = Text::new( "Included commits", appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(main_color) .finish(); let mut commit_list = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); for commit in &state.commits { let is_expanded = state.expanded.get(&commit.hash).copied().unwrap_or(false); let hash = commit.hash.clone(); let subject = Text::new( commit.subject.clone(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(main_color) .soft_wrap(false) .finish(); let stats_text = format!( "{} {}", commit.files_changed, if commit.files_changed == 1 { "file" } else { "files" }, ); let mut stats_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Text::new( stats_text, appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(sub_color) .finish(), ); if commit.additions > 0 { stats_row.add_child( Container::new( Text::new( format!("+{}", commit.additions), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(add_color(appearance)) .finish(), ) .with_margin_left(4.) .finish(), ); } if commit.deletions > 0 { stats_row.add_child( Container::new( Text::new( format!("-{}", commit.deletions), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(remove_color(appearance)) .finish(), ) .with_margin_left(4.) .finish(), ); } let info_col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(subject) .with_child( Container::new(stats_row.finish()) .with_margin_top(2.) .finish(), ) .finish(); let chevron = render_chevron_icon(is_expanded, appearance); let summary_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(info_col) .with_child(chevron) .finish(); let mouse_state = state .commit_mouse_states .get(&commit.hash) .cloned() .unwrap_or_default(); let clickable_summary = Hoverable::new(mouse_state, |_| { Container::new(summary_row) .with_padding_top(6.) .with_padding_bottom(6.) .with_padding_left(12.) .with_padding_right(8.) .finish() }) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(GitDialogAction::Push(PushSubAction::ToggleCommit( hash.clone(), ))); }) .with_cursor(Cursor::PointingHand) .finish(); let mut commit_col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); commit_col.add_child(clickable_summary); if is_expanded { commit_col.add_child(render_file_list(&commit.files, appearance)); } let bordered_commit = Container::new(commit_col.finish()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .with_border(Border::all(1.).with_border_fill(theme.surface_3())) .finish(); commit_list.add_child( Container::new(bordered_commit) .with_margin_bottom(4.) .finish(), ); } const MAX_COMMITS_HEIGHT: f32 = 300.; let commit_content = commit_list.finish(); let commits_element = ConstrainedBox::new( ClippedScrollable::vertical( state.commits_scroll_state.clone(), commit_content, ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), warpui::elements::Fill::None, ) .finish(), ) .with_max_height(MAX_COMMITS_HEIGHT) .finish(); Flex::column() .with_child(Container::new(label).with_margin_bottom(8.).finish()) .with_child(commits_element) .finish() }