398 lines
12 KiB
Rust
398 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use freya::{prelude::*, router::RouterContext};
|
|
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
|
|
|
|
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem};
|
|
use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch};
|
|
use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary};
|
|
use crate::routes::Route;
|
|
use crate::theme::colors;
|
|
use crate::ui::border_all_color;
|
|
|
|
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
|
|
const DIALOG_W: f32 = 420.;
|
|
const DIALOG_H: f32 = 400.;
|
|
|
|
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum UpdateTab {
|
|
Updates,
|
|
Additions,
|
|
Removals,
|
|
Optional,
|
|
}
|
|
|
|
impl UpdateTab {
|
|
const ALL: [UpdateTab; 4] = [
|
|
UpdateTab::Updates,
|
|
UpdateTab::Additions,
|
|
UpdateTab::Removals,
|
|
UpdateTab::Optional,
|
|
];
|
|
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
UpdateTab::Updates => "Updated",
|
|
UpdateTab::Additions => "Added",
|
|
UpdateTab::Removals => "Removed",
|
|
UpdateTab::Optional => "Optional",
|
|
}
|
|
}
|
|
|
|
fn empty_text(self) -> &'static str {
|
|
match self {
|
|
UpdateTab::Updates => "Nothing was updated.",
|
|
UpdateTab::Additions => "Nothing was added.",
|
|
UpdateTab::Removals => "Nothing was removed.",
|
|
UpdateTab::Optional => "Nothing optional was offered.",
|
|
}
|
|
}
|
|
|
|
fn icon(self) -> IconType {
|
|
match self {
|
|
UpdateTab::Updates => IconType::RefreshCw01,
|
|
UpdateTab::Additions => IconType::Plus,
|
|
UpdateTab::Removals => IconType::Trash01,
|
|
UpdateTab::Optional => IconType::Plus,
|
|
}
|
|
}
|
|
|
|
fn accent(self) -> Color {
|
|
match self {
|
|
UpdateTab::Updates => colors::brand(),
|
|
UpdateTab::Additions => colors::success(),
|
|
UpdateTab::Removals => colors::danger(),
|
|
UpdateTab::Optional => colors::brand(),
|
|
}
|
|
}
|
|
|
|
fn items(self, summary: &ClusterUpdateSummary) -> &[ClusterUpdateItem] {
|
|
match self {
|
|
UpdateTab::Updates => &summary.updated,
|
|
UpdateTab::Additions => &summary.added,
|
|
UpdateTab::Removals => &summary.removed,
|
|
UpdateTab::Optional => &summary.optional,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ClusterGroup {
|
|
cluster_id: i64,
|
|
cluster_name: String,
|
|
names: Vec<String>,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
pub struct ClusterUpdatePopup;
|
|
|
|
impl Component for ClusterUpdatePopup {
|
|
fn render(&self) -> impl IntoElement {
|
|
let snapshot = use_notifications_snapshot();
|
|
let dispatch = use_dispatch();
|
|
let active = use_state(|| UpdateTab::Updates);
|
|
|
|
let summaries = snapshot.cluster_update.clone();
|
|
|
|
// Hooks must run unconditionally so query every provider before the early return below
|
|
let all_items: Vec<&ClusterUpdateItem> = summaries
|
|
.iter()
|
|
.flatten()
|
|
.flat_map(|s| {
|
|
s.updated
|
|
.iter()
|
|
.chain(&s.added)
|
|
.chain(&s.removed)
|
|
.chain(&s.optional)
|
|
})
|
|
.collect();
|
|
let mut meta = MetaMap::new();
|
|
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
|
|
let ids: Vec<String> = all_items
|
|
.iter()
|
|
.filter(|i| i.provider == provider)
|
|
.filter_map(|i| i.project_id.clone())
|
|
.collect();
|
|
let query = use_package_meta_batch(provider, ids);
|
|
for (pid, m) in package_meta_batch(&query) {
|
|
meta.insert((provider, pid), m);
|
|
}
|
|
}
|
|
|
|
let Some(summaries) = summaries.filter(|s| !s.is_empty()) else {
|
|
return rect().into_element();
|
|
};
|
|
|
|
let close = dispatch.clone();
|
|
|
|
OverlayPopup::new()
|
|
.on_close(move |_| close.close_cluster_update())
|
|
.child(
|
|
rect()
|
|
.width(Size::window_percent(100.))
|
|
.height(Size::window_percent(100.))
|
|
.center()
|
|
.child(dialog(&summaries, &meta, active, dispatch)),
|
|
)
|
|
.into_element()
|
|
}
|
|
}
|
|
|
|
fn resolve_name(item: &ClusterUpdateItem, meta: &MetaMap) -> String {
|
|
item.project_id
|
|
.as_ref()
|
|
.and_then(|pid| meta.get(&(item.provider, pid.clone())))
|
|
.map(|m| m.name.clone())
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or_else(|| item.fallback.clone())
|
|
}
|
|
|
|
fn dialog(
|
|
summaries: &[ClusterUpdateSummary],
|
|
meta: &MetaMap,
|
|
active: State<UpdateTab>,
|
|
dispatch: crate::Actions,
|
|
) -> 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(summaries, meta, active, dispatch))
|
|
}
|
|
|
|
fn content(
|
|
summaries: &[ClusterUpdateSummary],
|
|
meta: &MetaMap,
|
|
active: State<UpdateTab>,
|
|
dispatch: crate::Actions,
|
|
) -> impl IntoElement {
|
|
let dismiss = dispatch.clone();
|
|
let total: usize = summaries.iter().map(|s| s.total()).sum();
|
|
let active_tab = *active.read();
|
|
|
|
// A batch sync can touch several clusters single-cluster keeps the footer shortcut
|
|
let single = match summaries {
|
|
[only] => Some((only.cluster_id, only.cluster_name.clone())),
|
|
_ => None,
|
|
};
|
|
|
|
let subtitle = match &single {
|
|
Some((_, name)) => format!(
|
|
"{total} change{} synced to {name}.",
|
|
if total == 1 { "" } else { "s" }
|
|
),
|
|
None => format!(
|
|
"{total} change{} across {} clusters.",
|
|
if total == 1 { "" } else { "s" },
|
|
summaries.len()
|
|
),
|
|
};
|
|
|
|
// Every category is shown even at zero so the modal shape stays stable
|
|
let tabs = TabBar::new()
|
|
.width(Size::fill())
|
|
.height(Size::px(30.))
|
|
.spacing(20.)
|
|
.tabs(UpdateTab::ALL.into_iter().map(|tab| {
|
|
let count: usize = summaries.iter().map(|s| tab.items(s).len()).sum();
|
|
let mut set = active;
|
|
TabItem::new(tab.label(), tab == active_tab)
|
|
.count_text(count.to_string())
|
|
.on_press(move |_| *set.write() = tab)
|
|
}));
|
|
|
|
// Clusters empty in the active category drop out avoiding empty section headers
|
|
let groups: Vec<ClusterGroup> = summaries
|
|
.iter()
|
|
.filter_map(|summary| {
|
|
let names: Vec<String> = active_tab
|
|
.items(summary)
|
|
.iter()
|
|
.map(|item| resolve_name(item, meta))
|
|
.collect();
|
|
(!names.is_empty()).then(|| ClusterGroup {
|
|
cluster_id: summary.cluster_id,
|
|
cluster_name: summary.cluster_name.clone(),
|
|
names,
|
|
})
|
|
})
|
|
.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("Changes applied")
|
|
.font_size(17.)
|
|
.font_weight(FontWeight::SEMI_BOLD)
|
|
.color(colors::fg_primary()),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(subtitle)
|
|
.font_size(12.5)
|
|
.max_lines(2)
|
|
.color(colors::fg_secondary()),
|
|
),
|
|
)
|
|
.child(tabs)
|
|
.child(change_list(
|
|
active_tab,
|
|
&groups,
|
|
single.is_none(),
|
|
&dispatch,
|
|
))
|
|
.child(
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.cross_align(Alignment::Center)
|
|
.main_align(Alignment::SpaceBetween)
|
|
.spacing(8.)
|
|
.child(
|
|
Button::new()
|
|
.ghost()
|
|
.on_press(move |_| dismiss.close_cluster_update())
|
|
.text("Dismiss"),
|
|
)
|
|
.maybe_child(single.map(|(cluster_id, _)| {
|
|
let open = dispatch.clone();
|
|
Button::new()
|
|
.primary()
|
|
.on_press(move |_| open_cluster(&open, cluster_id))
|
|
.text("Open cluster")
|
|
.child(Icon::new(IconType::ArrowRight).size(15.))
|
|
.into_element()
|
|
})),
|
|
)
|
|
}
|
|
|
|
fn open_cluster(dispatch: &crate::Actions, cluster_id: i64) {
|
|
dispatch.close_cluster_update();
|
|
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
|
|
}
|
|
|
|
fn change_list(
|
|
tab: UpdateTab,
|
|
groups: &[ClusterGroup],
|
|
show_headers: bool,
|
|
dispatch: &crate::Actions,
|
|
) -> impl IntoElement {
|
|
let accent = tab.accent();
|
|
|
|
let mut scroll = ScrollArea::new()
|
|
.width(Size::fill())
|
|
.height(Size::flex(1.0))
|
|
.spacing(1.);
|
|
|
|
if groups.is_empty() {
|
|
return scroll
|
|
.child(
|
|
rect()
|
|
.width(Size::fill())
|
|
.height(Size::px(80.))
|
|
.center()
|
|
.child(
|
|
label()
|
|
.text(tab.empty_text())
|
|
.font_size(12.5)
|
|
.color(colors::fg_secondary()),
|
|
),
|
|
)
|
|
.into_element();
|
|
}
|
|
|
|
for (index, group) in groups.iter().enumerate() {
|
|
if show_headers {
|
|
scroll = scroll.child(cluster_header(group, index == 0, dispatch.clone()));
|
|
}
|
|
|
|
for name in &group.names {
|
|
scroll = scroll.child(
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.cross_align(Alignment::Center)
|
|
.spacing(9.)
|
|
.padding(Gaps::new_symmetric(5., 6.))
|
|
.child(Icon::new(tab.icon()).size(13.).color(accent))
|
|
.child(
|
|
label()
|
|
.text(name.clone())
|
|
.font_size(12.5)
|
|
.max_lines(1)
|
|
.width(Size::flex(1.0))
|
|
.color(colors::fg_primary()),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
scroll.into_element()
|
|
}
|
|
|
|
/// Multi-cluster stand-in for the footer's "Open cluster" shortcut
|
|
fn cluster_header(
|
|
group: &ClusterGroup,
|
|
first: bool,
|
|
dispatch: crate::Actions,
|
|
) -> impl IntoElement {
|
|
let cluster_id = group.cluster_id;
|
|
let count = group.names.len();
|
|
|
|
rect()
|
|
.horizontal()
|
|
.width(Size::fill())
|
|
.content(Content::Flex)
|
|
.cross_align(Alignment::Center)
|
|
.spacing(8.)
|
|
.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())
|
|
.cursor(CursorIcon::Pointer)
|
|
.on_press(move |_| open_cluster(&dispatch, cluster_id))
|
|
.child(
|
|
label()
|
|
.text(group.cluster_name.clone())
|
|
.font_size(12.)
|
|
.font_weight(FontWeight::SEMI_BOLD)
|
|
.max_lines(1)
|
|
.width(Size::flex(1.0))
|
|
.color(colors::fg_primary()),
|
|
)
|
|
.child(
|
|
label()
|
|
.text(count.to_string())
|
|
.font_size(11.)
|
|
.color(colors::fg_secondary()),
|
|
)
|
|
.child(
|
|
Icon::new(IconType::ArrowRight)
|
|
.size(12.)
|
|
.color(colors::fg_secondary()),
|
|
)
|
|
}
|