diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 747edfd8..6f1ac851 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -27,6 +27,7 @@ mod navbar; mod notifications; mod optional_mods_popup; mod overlay_popup; +mod package_delete_dialog; mod package_row; mod package_update_popup; mod pagination; @@ -78,6 +79,7 @@ pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; +pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon}; pub use package_row::{CardLayout, PackageEntry, PackageRow, package_context_menu, provider_badge}; pub use package_update_popup::PackageUpdatePopup; diff --git a/packages/oneclient_app/src/components/package_delete_dialog.rs b/packages/oneclient_app/src/components/package_delete_dialog.rs new file mode 100644 index 00000000..570c8b95 --- /dev/null +++ b/packages/oneclient_app/src/components/package_delete_dialog.rs @@ -0,0 +1,139 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +pub fn use_shared_delete( + delete: impl Into>, +) -> (EventHandler<(String, String)>, Option) { + let mut pending = use_state(|| None::<(String, String, usize)>); + let delete = delete.into(); + + let request = { + let delete = delete.clone(); + EventHandler::new_current(move |(name, hash): (String, String)| { + let delete = delete.clone(); + spawn(async move { + let clusters = match crate::launcher::state() { + Ok(state) => oneclient_core::clusters_sharing_artifact( + &hash, + &state.services.content(), + ) + .await + .unwrap_or_else(|err| { + tracing::warn!(%err, "could not check whether this package is shared"); + None + }), + Err(_) => None, + }; + match clusters { + Some(clusters) if clusters > 1 => pending.set(Some((name, hash, clusters))), + _ => delete.call((name, hash)), + } + }); + }) + }; + + let dialog = pending.read().clone().map(|(name, hash, clusters)| { + SharedPackageDeleteDialog { + name: name.clone(), + clusters, + on_cancel: (move |()| pending.set(None)).into(), + on_confirm: (move |()| { + delete.call((name.clone(), hash.clone())); + pending.set(None); + }) + .into(), + } + .into_element() + }); + + (request, dialog) +} + +#[derive(PartialEq)] +struct SharedPackageDeleteDialog { + name: String, + clusters: usize, + on_cancel: EventHandler<()>, + on_confirm: EventHandler<()>, +} + +impl Component for SharedPackageDeleteDialog { + fn render(&self) -> impl IntoElement { + let title = format!("Delete {} from every cluster?", self.name); + let body = format!( + "Resource packs and shaders are shared, so this deletes it from all {} clusters that use it.", + self.clusters + ); + let close = self.on_cancel.clone(); + let cancel = self.on_cancel.clone(); + let confirm = self.on_confirm.clone(); + + OverlayPopup::new().on_close(move |_| close.call(())).child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(body) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.call(())) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| confirm.call(())) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 6ccfe897..5e4525be 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -1,13 +1,10 @@ use freya::prelude::*; -use freya::query::UseMutation; use freya::router::RouterContext; use oneclient_content::packages::ProviderId; use oneclient_core::SeenStatus; use crate::components::{ContextMenu, Icon, IconType, toggle_controlled}; -use crate::hooks::{ - ClusterAction, ClusterMutation, loaded_image, use_cached_image, use_cluster_mutation, -}; +use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; use crate::routes::Route; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -194,7 +191,7 @@ pub fn package_context_menu( item: &PackageEntry, cluster_id: i64, package_type: &'static str, - cluster: UseMutation, + on_delete: EventHandler<(String, String)>, ) -> ContextMenu { let mut menu = ContextMenu::new(x, y).title(item.name.clone()); @@ -212,21 +209,21 @@ pub fn package_context_menu( } if let Some(hash) = item.hash.clone() { - menu = menu.action(IconType::Folder, "View in folder", move |()| { - reveal_in_store(hash.clone()); - }); + menu = menu.action( + IconType::Folder, + "View in folder", + EventHandler::new_current(move |()| reveal_in_store(hash.clone())), + ); } if item.installed && !item.in_bundle() { let hash = item.hash.clone(); + let name = item.name.clone(); menu = menu .separator() .danger_action(IconType::Trash01, "Delete", move |()| { if let Some(hash) = &hash { - cluster.mutate(ClusterAction::RemoveArtifact { - cluster_id, - hash: hash.clone(), - }); + on_delete.call((name.clone(), hash.clone())); } }); } diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index aa016bab..45e10bad 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -1194,13 +1194,8 @@ impl Actions { }; let events = state.services.events.clone(); - match oneclient_core::remove_artifact_from_cluster( - cluster_id, - &hash, - true, - &state.services.content(), - ) - .await + match oneclient_core::delete_artifact(cluster_id, &hash, &state.services.content()) + .await { Ok(()) => { events.notify("Removed").body(display_name).send(); diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 5dbff147..124e7f0a 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -164,7 +164,7 @@ impl MutationCapability for ClusterMutation { } }), ClusterAction::RemoveArtifact { cluster_id, hash } => { - oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await + oneclient_core::delete_artifact(*cluster_id, hash, content).await } ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index dec9b70b..a559ffad 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -1,7 +1,7 @@ use freya::prelude::*; use oneclient_content::packages::{ContentType, ProviderId}; -use crate::components::ScrollArea; +use crate::components::{ScrollArea, use_shared_delete}; use crate::hooks::use_cluster; use crate::hooks::{ bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, @@ -148,6 +148,21 @@ impl Component for BrowserPackage { .remove(&(provider, project_id.clone())); let project = project_detail(&project_query); + + let remove_id = project.as_ref().map(|p| p.id.clone()); + let remove_dispatch = dispatch.clone(); + let (on_remove, remove_dialog) = use_shared_delete(move |(name, hash)| { + if let Some(project_id) = &remove_id { + remove_dispatch.remove_package_version( + cluster_id, + provider, + project_id.clone(), + hash, + name, + ); + } + }); + let versions = version_list(&versions_query); let total_versions = versions_total(&versions_query); let latest_version = versions.first().map(|v| v.version_id.clone()); @@ -171,6 +186,7 @@ impl Component for BrowserPackage { project.id.clone(), cluster_id, dispatch.clone(), + on_remove, installed.clone(), installing, ) @@ -217,6 +233,7 @@ impl Component for BrowserPackage { .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)) .children([row]), ) + .maybe_child(remove_dialog) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index d8cacdd2..26fec0c1 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -111,6 +111,7 @@ pub(super) fn versions_panel( project_id: String, cluster_id: i64, dispatch: Actions, + on_remove: EventHandler<(String, String)>, installed: Option, installing: bool, ) -> impl IntoElement { @@ -148,6 +149,7 @@ pub(super) fn versions_panel( project_id.clone(), cluster_id, dispatch.clone(), + on_remove.clone(), tag, duplicated, installing, @@ -222,6 +224,7 @@ fn version_row( project_id: String, cluster_id: i64, dispatch: Actions, + on_remove: EventHandler<(String, String)>, installed: Option, // Saying which version is live only tells the user anything when there are several duplicated: bool, @@ -285,7 +288,8 @@ fn version_row( .map(|installed| activity_badge(installed.enabled).into_element()), ) .child(version_button( - installed, v.name, provider, project_id, version_id, cluster_id, dispatch, installing, + installed, v.name, provider, project_id, version_id, cluster_id, dispatch, on_remove, + installing, )) } @@ -299,6 +303,7 @@ fn version_button( version_id: String, cluster_id: i64, dispatch: Actions, + on_remove: EventHandler<(String, String)>, busy: bool, ) -> impl IntoElement { let Some(installed) = installed else { @@ -329,15 +334,7 @@ fn version_button( .danger() .small() .enabled(!busy) - .on_press(move |_| { - dispatch.remove_package_version( - cluster_id, - provider, - project_id.clone(), - hash.clone(), - version_name.clone(), - ); - }) + .on_press(move |_| on_remove.call((version_name.clone(), hash.clone()))) .text("Remove") } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index d572e87b..54c80139 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -7,9 +7,9 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, Segment, - SegmentedControl, TextInput, package_context_menu, + SegmentedControl, TextInput, package_context_menu, use_shared_delete, }; -use crate::hooks::{use_cluster_mutation, use_dispatch, use_overlay_claim}; +use crate::hooks::{ClusterAction, use_cluster_mutation, use_dispatch, use_overlay_claim}; use crate::routes::Route; use crate::theme::colors; use crate::{Actions, utils}; @@ -742,6 +742,9 @@ impl Component for ContentBox { let dispatch = use_dispatch(); let cluster = use_cluster_mutation(); let mut menu = use_state(|| None::<(f32, f32, PackageEntry)>); + let (on_delete, delete_dialog) = use_shared_delete(move |(_, hash)| { + cluster.mutate(ClusterAction::RemoveArtifact { cluster_id, hash }); + }); let row = { let items = items.clone(); @@ -773,7 +776,7 @@ impl Component for ContentBox { }); let menu_overlay = menu.read().clone().map(|(x, y, item)| { - package_context_menu(x, y, &item, cluster_id, package_type, cluster) + package_context_menu(x, y, &item, cluster_id, package_type, on_delete) .on_close(move |_| menu.set(None)) .into_element() }); @@ -821,6 +824,7 @@ impl Component for ContentBox { .maybe_child(scroll) .maybe_child(empty) .maybe_child(menu_overlay) + .maybe_child(delete_dialog) } } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 8ad10c99..9a890ce1 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -641,12 +641,7 @@ pub async fn set_artifact_enabled_to( // which clusters have to record what the user just did async fn override_scope(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult> { - let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) - .await? - .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) - .is_some_and(ContentType::is_global); - - if !global { + if !is_shared_artifact(hash, ctx).await? { return Ok(vec![cluster_id]); } @@ -785,6 +780,46 @@ pub async fn remove_artifact_from_cluster( Ok(()) } +async fn is_shared_artifact(hash: &str, ctx: &ContentCtx) -> ContentResult { + Ok(artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(ContentType::is_global)) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn clusters_sharing_artifact( + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + if !is_shared_artifact(hash, ctx).await? { + return Ok(None); + } + + Ok(Some( + artifact_dao::list_clusters_linking(&ctx.db, hash) + .await? + .len(), + )) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn delete_artifact(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { + if !is_shared_artifact(hash, ctx).await? { + return remove_artifact_from_cluster(cluster_id, hash, true, ctx).await; + } + + let mut first_error = None; + for linked_cluster in artifact_dao::list_clusters_linking(&ctx.db, hash).await? { + if let Err(err) = remove_artifact_from_cluster(linked_cluster, hash, true, ctx).await { + tracing::warn!(cluster_id = linked_cluster, hash, error = %err, "failed to delete shared package from a cluster"); + first_error.get_or_insert(err); + } + } + + first_error.map_or(Ok(()), Err) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 86f1f759..da48742a 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -10,12 +10,13 @@ mod updates; pub use error::BundleError; pub use install::{ - effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, - heal_bundle_activity, install_bundle, install_cluster_bundles, install_enabled_bundle_files, - install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, - on_user_enable_artifact, on_user_remove_artifact, remove_artifact_from_cluster, - set_artifact_enabled_to, set_bundle_package_enabled, set_bundle_package_opt_in, - set_bundle_package_override, set_bundle_package_overrides, + clusters_sharing_artifact, delete_artifact, effective_enabled, enabled_bundle_bytes, + extract_bundle_overrides_for_cluster, heal_bundle_activity, install_bundle, + install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle, + list_cluster_bundle_overrides, on_user_disable_artifact, on_user_enable_artifact, + on_user_remove_artifact, remove_artifact_from_cluster, set_artifact_enabled_to, + set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, + set_bundle_package_overrides, }; pub use manager::{Bundle, BundlesManager}; pub use manifest::BundleManifest as RemoteBundleManifest; diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index a925a086..bb615f59 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -45,12 +45,13 @@ pub use oneclient_content::bundles::{ ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult, BundleWithUpdateStatus, BundlesManager, FileUpdateStatus, PendingOptionalMod, apply_bundle_updates, - apply_bundle_updates_with, check_bundle_updates, cluster_has_bundle_content, effective_enabled, - get_bundles_with_update_status, install_bundle, install_cluster_bundles, - install_package_from_bundle, list_cluster_bundle_overrides, pending_optional_mods, - remove_artifact_from_cluster, resolve_optional_mods, set_artifact_enabled_to, - set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, - set_bundle_package_overrides, skip_optional_mods, + apply_bundle_updates_with, check_bundle_updates, cluster_has_bundle_content, + clusters_sharing_artifact, delete_artifact, effective_enabled, get_bundles_with_update_status, + install_bundle, install_cluster_bundles, install_package_from_bundle, + list_cluster_bundle_overrides, pending_optional_mods, remove_artifact_from_cluster, + resolve_optional_mods, set_artifact_enabled_to, set_bundle_package_enabled, + set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, + skip_optional_mods, }; pub use oneclient_content::packages::LinkedArtifactInfo; pub use oneclient_content::packages::updates::{ diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index ad8eba7b..7a84ee91 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -312,6 +312,13 @@ pub async fn is_cluster_linked( Ok(row.is_some()) } +pub async fn list_clusters_linking(pool: &SqlitePool, hash: &str) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT cluster_id FROM cluster_artifacts WHERE hash = ?") + .bind(hash) + .fetch_all(pool) + .await +} + pub async fn unlink_cluster_artifact( pool: &SqlitePool, cluster_id: i64,