feat: added deleting browser and local mods (#853)

* feat: added deleting browser and local mods by context menu and on list view by trashcan icon

* fix: only confirm shared deletes that reach other clusters

* fix: confirm shared removals from the browser package page too

* fix: keep "View in folder" alive after the context menu closes

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 17:30:10 +02:00 • committed by GitHub
parent ac66752b99
commit abc452f294
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 247 additions and 52 deletions

View file

@ -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;

View file

@ -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)>>,
) -> (EventHandler<(String, String)>, Option<Element>) {
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"),
),
),
),
)
}
}

View file

@ -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<ClusterMutation>,
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()));
}
});
}

View file

@ -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();

View file

@ -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

View file

@ -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()
}
}

View file

@ -111,6 +111,7 @@ pub(super) fn versions_panel(
project_id: String,
cluster_id: i64,
dispatch: Actions,
on_remove: EventHandler<(String, String)>,
installed: Option<Installed>,
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<InstalledVersion>,
// 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")
}

View file

@ -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)
}
}

View file

@ -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<Vec<i64>> {
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<bool> {
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<Option<usize>> {
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::*;

View file

@ -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;

View file

@ -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::{

View file

@ -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<Vec<i64>, 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,