//! Only lists packages installed from the browser bundle content has its own flow use std::collections::HashMap; use freya::prelude::*; use oneclient_common::{PackageUpdateMode, Patch}; use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_core::{BrowserPackageUpdate, ProfileUpdate}; use oneclient_db::models::ClusterId; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled}; use crate::hooks::{ package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; use crate::notifications::PackageUpdateGroup; use crate::theme::colors; use crate::ui::border_all_color; const CARD_BG: Color = Color::from_rgb(26, 34, 41); const DIALOG_W: f32 = 520.; const DIALOG_H: f32 = 420.; const CHOICE_DROPDOWN_W: f32 = 150.; type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; /// `Skip` is re-offered next launch `SkipVersion` persists the decline until a newer version #[derive(Clone, Copy, Default, PartialEq, Eq)] enum RowChoice { #[default] Update, Skip, SkipVersion, } impl RowChoice { const ALL: [Self; 3] = [Self::Update, Self::Skip, Self::SkipVersion]; fn label(self) -> &'static str { match self { Self::Update => "Update", Self::Skip => "Skip for now", Self::SkipVersion => "Skip this version", } } fn options() -> Vec { Self::ALL.iter().map(|c| c.label().to_string()).collect() } } type RowKey = (ClusterId, String); fn row_key(update: &BrowserPackageUpdate) -> RowKey { (update.cluster_id, update.hash.clone()) } #[derive(PartialEq)] pub struct PackageUpdatePopup; impl Component for PackageUpdatePopup { fn render(&self) -> impl IntoElement { let snapshot = use_notifications_snapshot(); let dispatch = use_dispatch(); // Absent entries default to Update so groups arriving mid-modal need no reconciliation let choices = use_state(HashMap::::new); let mut dont_show_again = use_state(|| false); let groups = snapshot.package_updates.clone(); // Hooks run unconditionally so this must precede the early return below let mut meta = MetaMap::new(); for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { let ids: Vec = groups .iter() .flatten() .flat_map(|group| &group.packages) .filter(|update| update.provider == provider) .map(|update| update.project_id.clone()) .collect(); let query = use_package_meta_batch(provider, ids); for (project_id, cached) in package_meta_batch(&query) { meta.insert((provider, project_id), cached); } } let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { // The popup stays mounted, so the tick must not survive into the next prompt dont_show_again.set_if_modified(false); return rect().into_element(); }; let close = dispatch.clone(); OverlayPopup::new() .on_close(move |_| close.close_package_updates()) .child( rect() .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() .child(dialog(&groups, &meta, dispatch, choices, dont_show_again)), ) .into_element() } } fn resolve_name(update: &BrowserPackageUpdate, meta: &MetaMap) -> String { meta.get(&(update.provider, update.project_id.clone())) .map(|cached| cached.name.clone()) .filter(|name| !name.is_empty()) .unwrap_or_else(|| update.display_name.clone()) } fn dialog( groups: &[PackageUpdateGroup], meta: &MetaMap, dispatch: crate::Actions, choices: State>, dont_show_again: State, ) -> impl IntoElement { rect() .vertical() .width(Size::px(DIALOG_W)) .height(Size::px(DIALOG_H)) .max_width(Size::window_percent(95.)) .overflow(Overflow::Clip) .corner_radius(CornerRadius::new_all(16.)) .background(CARD_BG) .border(border_all_color(1., colors::component_border())) .shadow(Shadow::from(( 0., 18., 52., 0., Color::from_argb(150, 0, 0, 0), ))) .child(content(groups, meta, dispatch, choices, dont_show_again)) } fn content( groups: &[PackageUpdateGroup], meta: &MetaMap, dispatch: crate::Actions, choices: State>, dont_show_again: State, ) -> impl IntoElement { let dismiss = dispatch.clone(); let proceed_dispatch = dispatch.clone(); let dsa_dispatch = dispatch.clone(); let total: usize = groups.iter().map(|group| group.packages.len()).sum(); let clusters: Vec = groups.iter().map(|group| group.cluster_id).collect(); let subtitle = match groups { [only] => format!( "{total} package{} in {} can be updated. Pick what happens to each, then Proceed.", if total == 1 { "" } else { "s" }, only.cluster_name ), _ => format!( "{total} package{} across {} clusters can be updated. Pick what happens to each, then Proceed.", if total == 1 { "" } else { "s" }, groups.len() ), }; let all: Vec = groups .iter() .flat_map(|group| group.packages.iter().cloned()) .collect(); rect() .vertical() .width(Size::fill()) .height(Size::fill()) .content(Content::Flex) .padding(Gaps::new_all(22.)) .spacing(14.) .child( rect() .vertical() .width(Size::fill()) .spacing(3.) .child( label() .text("Updates available") .font_size(17.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) .child( label() .text(subtitle) .font_size(12.5) .max_lines(3) .color(colors::fg_secondary()), ), ) .child(update_list(groups, meta, groups.len() > 1, choices)) .child( rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .main_align(Alignment::SpaceBetween) .spacing(8.) .child( rect() .direction(Direction::Horizontal) .main_align(Alignment::Start) .cross_align(Alignment::Center) .spacing(6.) .child( checkbox_labeled(dont_show_again, "Don't show again?") ) ) .child( rect() .width(Size::fill()) .direction(Direction::Horizontal) .cross_align(Alignment::Center) .main_align(Alignment::End) .child( Button::new() .ghost() .on_press(move |_| dismiss.close_package_updates()) .text("Cancel"), ) .child( Button::new() .primary() .on_press(move |_| { if *dont_show_again.peek() { // A cluster override outranks the global one, so the prompt // is only silenced by writing the cluster that raised it for cluster_id in &clusters { dsa_dispatch.update_cluster_profile( *cluster_id, ProfileUpdate { browser_update_mode: Patch::Set( PackageUpdateMode::Skip, ), ..Default::default() }, ); } } let answers = choices.read(); let mut chosen = Vec::new(); for update in &all { match answers .get(&row_key(update)) .copied() .unwrap_or_default() { RowChoice::Update => chosen.push(update.clone()), RowChoice::Skip => {} RowChoice::SkipVersion => proceed_dispatch .skip_package_update( update.cluster_id, update.hash.clone(), ), } } proceed_dispatch.proceed_package_updates(chosen); }) .child(Icon::new(IconType::DownloadCloud02).size(15.)) .text("Proceed"), ) ), ) } fn update_list( groups: &[PackageUpdateGroup], meta: &MetaMap, show_headers: bool, choices: State>, ) -> impl IntoElement { let mut scroll = ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) .spacing(4.); for (index, group) in groups.iter().enumerate() { if show_headers { scroll = scroll.child(cluster_header(&group.cluster_name, index == 0)); } for update in &group.packages { let key = format!("{}:{}", group.cluster_id, update.hash); let choice = choices .read() .get(&row_key(update)) .copied() .unwrap_or_default(); scroll = scroll.child( UpdateRow::new(resolve_name(update, meta), update.clone(), choice, choices) .key(key) .into_element(), ); } } scroll.into_element() } fn cluster_header(name: &str, first: bool) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .padding(Gaps::new_symmetric(6., 8.)) .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .child( label() .text(name.to_string()) .font_size(12.) .font_weight(FontWeight::SEMI_BOLD) .max_lines(1) .width(Size::fill()) .color(colors::fg_primary()), ) } struct UpdateRow { name: String, update: BrowserPackageUpdate, choice: RowChoice, choices: State>, key: DiffKey, } impl UpdateRow { fn new( name: String, update: BrowserPackageUpdate, choice: RowChoice, choices: State>, ) -> Self { Self { name, update, choice, choices, key: DiffKey::None, } } } impl PartialEq for UpdateRow { fn eq(&self, other: &Self) -> bool { self.name == other.name && self.update == other.update && self.choice == other.choice } } impl KeyExt for UpdateRow { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } impl Component for UpdateRow { fn render(&self) -> impl IntoElement { let update = self.update.clone(); let choice = self.choice; let mut choices = self.choices; let key = row_key(&update); let versions = match ( update.installed_version_name.is_empty(), update.latest_version_name.is_empty(), ) { (false, false) => format!( "{} to {}", update.installed_version_name, update.latest_version_name ), (true, false) => format!("New version {}", update.latest_version_name), _ => "A newer version is available".to_string(), }; rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(10.) .padding(Gaps::new_symmetric(8., 10.)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .content(Content::Flex) .child( Icon::new(IconType::RefreshCw01) .size(14.) .color(colors::brand()), ) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(2.) .child( label() .text(self.name.clone()) .font_size(12.5) .font_weight(FontWeight::MEDIUM) .max_lines(1) .width(Size::fill()) .color(colors::fg_primary()), ) .child( label() .text(versions) .font_size(11.) .max_lines(1) .width(Size::fill()) .color(colors::fg_secondary()), ), ) .child( Dropdown::new(choice.label(), RowChoice::options()) .width(Size::px(CHOICE_DROPDOWN_W)) .height(Size::px(26.)) .on_select(move |idx: usize| { if let Some(choice) = RowChoice::ALL.get(idx) { choices.write().insert(key.clone(), *choice); } }), ) } }