From f606e3ca9b94e9e93d039cfd309706152ebd302b Mon Sep 17 00:00:00 2001 From: James Tucker Date: Tue, 5 May 2026 19:59:06 -0700 Subject: [PATCH] Add Dependencies and Dependents tabs to addon details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Dependencies tab on the addon details view shows per-dependency resolution (installed by an existing addon, satisfied by a manual override, ignored, or unresolved with ranked install suggestions). Each row supports install, ignore, satisfied-by, and revoke actions, with a top-level "Install all" for batch-install of all unresolved suggestions. Suggestions are ranked using the same directory-count / download / date ordering as the global recommender. New Dependents tab lists installed addons that declare this addon as a dependency, derived from the same addon_dependency table. Both tabs become inert (greyed out, "No Dependencies" / "No Dependents" labels) when empty rather than disappearing. Adds the supporting types (AddonRef, Resolution, DepStatus, AddonDependencyView) in service::result and the service methods that back the tabs (get_addon_dependency_view, set_dep_ignored, set_dep_satisfied_by, revoke_dep_override, install_dep_suggestions). Also trigger check_missing_deps directly from handle_addons_changed so the missing-deps sidebar entry clears as soon as the user resolves all missing deps, instead of relying on the installed_addons -> check_missing_deps chain (which only fires after the filesystem walk completes, and never fires at all if install_missing_dependencies hits the unwrap() panic path). handle_addons_changed now kicks off both refreshes in parallel, so guard the auto-navigate to the MissingDeps view against installed_addons.value being None — only the modal auto-pop is deferred until both promises have results; the sidebar entry itself updates correctly. --- core/src/service/mod.rs | 273 +++++++++++++++++++++++++++++++++++ core/src/service/result.rs | 27 ++++ src/main.rs | 13 +- src/views/addon_details.rs | 288 ++++++++++++++++++++++++++++++++++++- 4 files changed, 595 insertions(+), 6 deletions(-) diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index 1f11ec40..57e688c6 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -739,6 +739,279 @@ where i.addon_id is null }) } + pub fn get_addon_dependency_view( + &self, + addon_id: i32, + ) -> ImmediateValuePromise { + let db = self.db.clone(); + + ImmediateValuePromise::new(async move { + #[derive(FromQueryResult)] + struct DirOwner { + dir: String, + id: i32, + name: String, + } + #[derive(FromQueryResult)] + struct SuggestionRow { + dir: String, + id: i32, + name: String, + } + + let dep_rows = AddonDep::Entity::find() + .filter(AddonDep::Column::AddonId.eq(addon_id)) + .all(&db) + .await + .context(error::DbGetSnafu)?; + let dep_dirs: Vec = dep_rows.iter().map(|r| r.dependency_dir.clone()).collect(); + + let dependents = AddonRef::find_by_statement(Statement::from_sql_and_values( + DbBackend::Sqlite, + r#"select distinct a.id as id, a.name as name + from addon_dir my_dir + inner join addon_dependency adp on adp.dependency_dir = my_dir.dir + inner join installed_addon i on i.addon_id = adp.addon_id + inner join addon a on a.id = adp.addon_id + where my_dir.addon_id = ? and adp.addon_id <> ? + order by a.name"#, + [addon_id.into(), addon_id.into()], + )) + .all(&db) + .await + .context(error::DbGetSnafu)?; + + let installed_addons = AddonRef::find_by_statement(Statement::from_sql_and_values( + DbBackend::Sqlite, + r#"select a.id as id, a.name as name + from installed_addon i + inner join addon a on a.id = i.addon_id + order by a.name"#, + [], + )) + .all(&db) + .await + .context(error::DbGetSnafu)?; + + if dep_dirs.is_empty() { + return Ok(AddonDependencyView { + forward: vec![], + dependents, + installed_addons, + }); + } + + let manual_rows = ManualDependency::Entity::find() + .filter(ManualDependency::Column::AddonDir.is_in(dep_dirs.clone())) + .all(&db) + .await + .context(error::DbGetSnafu)?; + let satisfied_ids: Vec = + manual_rows.iter().filter_map(|m| m.satisfied_by).collect(); + let satisfied_addons = if satisfied_ids.is_empty() { + vec![] + } else { + DbAddon::Entity::find() + .filter(DbAddon::Column::Id.is_in(satisfied_ids)) + .all(&db) + .await + .context(error::DbGetSnafu)? + }; + let satisfied_name_map: HashMap = satisfied_addons + .iter() + .map(|a| (a.id, a.name.clone())) + .collect(); + + let placeholders = vec!["?"; dep_dirs.len()].join(","); + let owner_sql = format!( + r#"select ad.dir as dir, a.id as id, a.name as name + from addon_dir ad + inner join installed_addon i on i.addon_id = ad.addon_id + inner join addon a on a.id = ad.addon_id + where ad.dir in ({placeholders})"# + ); + let owner_values: Vec = + dep_dirs.iter().map(|d| d.clone().into()).collect(); + let owner_rows = DirOwner::find_by_statement(Statement::from_sql_and_values( + DbBackend::Sqlite, + owner_sql, + owner_values, + )) + .all(&db) + .await + .context(error::DbGetSnafu)?; + let installed_owner_map: HashMap = owner_rows + .into_iter() + .map(|r| { + ( + r.dir, + AddonRef { + id: r.id, + name: r.name, + }, + ) + }) + .collect(); + + let suggestion_sql = format!( + r#"select ad.dir as dir, a.id as id, a.name as name + from addon_dir ad + inner join addon a on a.id = ad.addon_id + left outer join ( + select addon_id, count(*) as dir_count + from addon_dir + group by addon_id + ) dc on dc.addon_id = a.id + where ad.dir in ({placeholders}) + order by ad.dir, + (a.name = ad.dir) desc, + coalesce(dc.dir_count, 999) asc, + cast(coalesce(nullif(a.download_monthly, ''), '0') as integer) desc, + cast(coalesce(nullif(a.date, ''), '0') as integer) desc"# + ); + let suggestion_values: Vec = + dep_dirs.iter().map(|d| d.clone().into()).collect(); + let suggestion_rows = SuggestionRow::find_by_statement(Statement::from_sql_and_values( + DbBackend::Sqlite, + suggestion_sql, + suggestion_values, + )) + .all(&db) + .await + .context(error::DbGetSnafu)?; + let mut suggestion_map: HashMap> = HashMap::new(); + for row in suggestion_rows { + suggestion_map.entry(row.dir).or_default().push(AddonRef { + id: row.id, + name: row.name, + }); + } + + let forward: Vec = dep_dirs + .iter() + .map(|dir| { + let resolution = if let Some(owner) = installed_owner_map.get(dir) { + Resolution::Installed(owner.clone()) + } else if let Some(manual) = manual_rows.iter().find(|m| m.addon_dir == *dir) { + if manual.ignore.unwrap_or(false) { + Resolution::Ignored + } else if let Some(sb_id) = manual.satisfied_by { + Resolution::SatisfiedBy(AddonRef { + id: sb_id, + name: satisfied_name_map.get(&sb_id).cloned().unwrap_or_default(), + }) + } else { + Resolution::Unresolved { + suggestions: suggestion_map.remove(dir).unwrap_or_default(), + } + } + } else { + Resolution::Unresolved { + suggestions: suggestion_map.remove(dir).unwrap_or_default(), + } + }; + DepStatus { + dep_dir: dir.clone(), + resolution, + } + }) + .collect(); + + Ok(AddonDependencyView { + forward, + dependents, + installed_addons, + }) + }) + } + + pub fn set_dep_ignored(&self, dep_dir: String) -> ImmediateValuePromise<()> { + let db = self.db.clone(); + ImmediateValuePromise::new(async move { + ManualDependency::Entity::insert(ManualDependency::ActiveModel { + addon_dir: ActiveValue::Set(dep_dir), + ignore: ActiveValue::Set(Some(true)), + satisfied_by: ActiveValue::Set(None), + }) + .on_conflict( + OnConflict::column(ManualDependency::Column::AddonDir) + .update_columns([ + ManualDependency::Column::Ignore, + ManualDependency::Column::SatisfiedBy, + ]) + .to_owned(), + ) + .exec(&db) + .await + .context(error::DbPutSnafu)?; + Ok(()) + }) + } + + pub fn set_dep_satisfied_by( + &self, + dep_dir: String, + addon_id: i32, + ) -> ImmediateValuePromise<()> { + let db = self.db.clone(); + ImmediateValuePromise::new(async move { + ManualDependency::Entity::insert(ManualDependency::ActiveModel { + addon_dir: ActiveValue::Set(dep_dir), + ignore: ActiveValue::Set(Some(false)), + satisfied_by: ActiveValue::Set(Some(addon_id)), + }) + .on_conflict( + OnConflict::column(ManualDependency::Column::AddonDir) + .update_columns([ + ManualDependency::Column::Ignore, + ManualDependency::Column::SatisfiedBy, + ]) + .to_owned(), + ) + .exec(&db) + .await + .context(error::DbPutSnafu)?; + Ok(()) + }) + } + + pub fn revoke_dep_override(&self, dep_dir: String) -> ImmediateValuePromise<()> { + let db = self.db.clone(); + ImmediateValuePromise::new(async move { + ManualDependency::Entity::delete_by_id(dep_dir) + .exec(&db) + .await + .context(error::DbDeleteSnafu)?; + Ok(()) + }) + } + + pub fn install_dep_suggestions(&self, items: Vec<(String, i32)>) -> ImmediateValuePromise<()> { + let service = self.clone(); + ImmediateValuePromise::new(async move { + for (dep_dir, addon_id) in items { + service.p_install(addon_id, false).await?; + ManualDependency::Entity::insert(ManualDependency::ActiveModel { + addon_dir: ActiveValue::Set(dep_dir), + ignore: ActiveValue::Set(Some(false)), + satisfied_by: ActiveValue::Set(Some(addon_id)), + }) + .on_conflict( + OnConflict::column(ManualDependency::Column::AddonDir) + .update_columns([ + ManualDependency::Column::Ignore, + ManualDependency::Column::SatisfiedBy, + ]) + .to_owned(), + ) + .exec(&service.db) + .await + .context(error::DbPutSnafu)?; + } + Ok(()) + }) + } + pub fn get_addon_details( &self, addon_id: i32, diff --git a/core/src/service/result.rs b/core/src/service/result.rs index b9764d9c..716e9481 100644 --- a/core/src/service/result.rs +++ b/core/src/service/result.rs @@ -14,6 +14,33 @@ pub struct AddonDepOption { pub option_name: Option, } +#[derive(FromQueryResult, Clone, Default, Debug)] +pub struct AddonRef { + pub id: i32, + pub name: String, +} + +#[derive(Clone, Debug)] +pub enum Resolution { + Installed(AddonRef), + SatisfiedBy(AddonRef), + Ignored, + Unresolved { suggestions: Vec }, +} + +#[derive(Clone, Debug)] +pub struct DepStatus { + pub dep_dir: String, + pub resolution: Resolution, +} + +#[derive(Clone, Default, Debug)] +pub struct AddonDependencyView { + pub forward: Vec, + pub dependents: Vec, + pub installed_addons: Vec, +} + #[derive(Default, Clone)] pub struct MissingDepView { pub missing_dir: String, diff --git a/src/main.rs b/src/main.rs index 651c9bb9..50902517 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,8 @@ struct EamApp { hm_data: Option>, missing_deps: PromisedValue>, install_missing_deps: PromisedValue<()>, + /// Only auto-nav to MissingDeps when newly discovered, not on every refresh. + had_missing_deps: bool, } impl EamApp { @@ -151,6 +153,7 @@ impl EamApp { hm_data: None, missing_deps: PromisedValue::default(), install_missing_deps: PromisedValue::default(), + had_missing_deps: false, }; if app.service.config.update_on_launch { // check for update on init @@ -219,8 +222,8 @@ impl EamApp { self.missing_deps.poll(); if self.missing_deps.is_ready() { self.missing_deps.handle(); - if !self.missing_deps.value.as_ref().unwrap().is_empty() { - // make sure installed addon name/ID map set + let has_missing = !self.missing_deps.value.as_ref().unwrap().is_empty(); + if has_missing { self.missing_dep.set_addons( self.installed_addons .value @@ -230,11 +233,13 @@ impl EamApp { .map(|x| (x.id, x.name.to_string())) .collect(), ); - // we need to resolve missing dependencies self.missing_dep .set_deps(self.missing_deps.value.as_ref().unwrap().to_owned()); - self.change_view(ViewOpt::MissingDeps); + if !self.had_missing_deps { + self.change_view(ViewOpt::MissingDeps); + } } + self.had_missing_deps = has_missing; } // poll installing missing dependencies diff --git a/src/views/addon_details.rs b/src/views/addon_details.rs index 965b9f74..d848f25c 100644 --- a/src/views/addon_details.rs +++ b/src/views/addon_details.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::{ ResetView, View, ui_helpers::{AddonResponse, AddonResponseType, PromisedValue, truncate_len, ui_show_star}, @@ -7,7 +9,7 @@ use eframe::egui::{self, Image, Layout, RichText, ScrollArea, vec2}; use egui::Button; use eso_addons_core::service::{ AddonService, - result::{AddonImageResult, AddonShowDetails}, + result::{AddonDependencyView, AddonImageResult, AddonShowDetails, Resolution}, }; #[derive(PartialEq, Default)] @@ -17,6 +19,14 @@ enum DetailView { ChangeLog, Pictures, FileInfo, + Dependencies, + Dependents, +} + +#[derive(Default)] +struct DepRowState { + selected_suggestion: Option, + selected_satisfied_by: Option, } #[derive(Default)] @@ -31,16 +41,36 @@ pub struct Details { bb_changelog_state: BBState, images: PromisedValue>, selected_image: String, + dep_view: PromisedValue, + dep_mutation: PromisedValue<()>, + dep_mutation_was_install: bool, + pending_addons_changed: bool, + row_state: HashMap, } impl Details { - fn poll(&mut self, _: &mut AddonService) { + fn poll(&mut self, service: &mut AddonService) { self.details.poll(); if self.details.is_ready() { self.details.handle(); self.build_bb_views(); } self.images.poll(); + self.dep_view.poll(); + if self.dep_view.is_ready() { + self.dep_view.handle(); + } + self.dep_mutation.poll(); + if self.dep_mutation.is_ready() { + self.dep_mutation.handle(); + self.dep_view + .set(service.get_addon_dependency_view(self.addon_id)); + self.row_state.clear(); + if self.dep_mutation_was_install { + self.dep_mutation_was_install = false; + self.pending_addons_changed = true; + } + } } fn build_bb_views(&mut self) { @@ -60,12 +90,17 @@ impl Details { self.addon_id = addon_id; self.details.set(service.get_addon_details(addon_id)); self.images.set(service.get_addon_images(addon_id)); + self.dep_view + .set(service.get_addon_dependency_view(addon_id)); self.view = DetailView::default(); self.selected_image = String::default(); self.bb_description = None; self.bb_changelog = None; self.bb_description_state = BBState::default(); self.bb_changelog_state = BBState::default(); + self.row_state.clear(); + self.dep_mutation_was_install = false; + self.pending_addons_changed = false; } } impl View for Details { @@ -78,6 +113,12 @@ impl View for Details { let mut response = AddonResponse::default(); self.poll(service); + if self.pending_addons_changed { + self.pending_addons_changed = false; + response.response_type = AddonResponseType::AddonsChanged; + return response; + } + if self.details.is_polling() { ui.spinner(); return response; @@ -211,6 +252,38 @@ impl View for Details { DetailView::ChangeLog, RichText::new("Change Log").heading(), ); + let (deps_label, deps_enabled, dependents_label, dependents_enabled) = + match self.dep_view.value.as_ref() { + Some(v) => ( + if v.forward.is_empty() { + "No Dependencies" + } else { + "Dependencies" + }, + !v.forward.is_empty(), + if v.dependents.is_empty() { + "No Dependents" + } else { + "Dependents" + }, + !v.dependents.is_empty(), + ), + None => ("Dependencies", true, "Dependents", true), + }; + ui.add_enabled_ui(deps_enabled, |ui| { + ui.selectable_value( + &mut self.view, + DetailView::Dependencies, + RichText::new(deps_label).heading(), + ); + }); + ui.add_enabled_ui(dependents_enabled, |ui| { + ui.selectable_value( + &mut self.view, + DetailView::Dependents, + RichText::new(dependents_label).heading(), + ); + }); ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { ui.checkbox(&mut self.show_raw_text, "Raw"); }); @@ -288,11 +361,219 @@ impl View for Details { } }); } + DetailView::Dependencies => { + let Some(dep_view) = self.dep_view.value.as_ref() else { + ui.spinner(); + return; + }; + let mut action: Option = None; + let install_all_items: Vec<(String, i32)> = dep_view + .forward + .iter() + .filter_map(|d| match &d.resolution { + Resolution::Unresolved { suggestions } => { + suggestions.first().map(|s| (d.dep_dir.clone(), s.id)) + } + _ => None, + }) + .collect(); + if !install_all_items.is_empty() { + ui.horizontal(|ui| { + if ui + .button( + RichText::new(format!( + "⮋ Install All ({})", + install_all_items.len() + )) + .heading(), + ) + .clicked() + { + action = + Some(DepAction::InstallBatch(install_all_items.clone())); + } + }); + ui.separator(); + } + for dep in &dep_view.forward { + ui.horizontal_wrapped(|ui| { + ui.strong(format!("{}:", dep.dep_dir)); + match &dep.resolution { + Resolution::Installed(r) => { + if ui + .selectable_label( + false, + format!("{} (installed)", r.name), + ) + .clicked() + { + action = Some(DepAction::Navigate(r.id)); + } + } + Resolution::SatisfiedBy(r) => { + ui.label("satisfied by"); + if ui.selectable_label(false, &r.name).clicked() { + action = Some(DepAction::Navigate(r.id)); + } + if ui.button("revoke").clicked() { + action = Some(DepAction::Revoke(dep.dep_dir.clone())); + } + } + Resolution::Ignored => { + ui.label("ignored"); + if ui.button("revoke").clicked() { + action = Some(DepAction::Revoke(dep.dep_dir.clone())); + } + } + Resolution::Unresolved { suggestions } => { + let row = + self.row_state.entry(dep.dep_dir.clone()).or_default(); + if row.selected_suggestion.is_none() { + row.selected_suggestion = + suggestions.first().map(|s| s.id); + } + if ui.button("Ignore").clicked() { + action = + Some(DepAction::SetIgnored(dep.dep_dir.clone())); + } + ui.label("satisfied by:"); + let sb_text = row + .selected_satisfied_by + .and_then(|id| { + dep_view + .installed_addons + .iter() + .find(|a| a.id == id) + .map(|a| a.name.as_str()) + }) + .unwrap_or(""); + egui::ComboBox::from_id_salt(format!("sb_{}", dep.dep_dir)) + .selected_text(sb_text) + .width(180.0) + .show_ui(ui, |ui| { + for a in &dep_view.installed_addons { + if ui + .selectable_label( + row.selected_satisfied_by == Some(a.id), + &a.name, + ) + .clicked() + { + row.selected_satisfied_by = Some(a.id); + action = Some(DepAction::SetSatisfiedBy( + dep.dep_dir.clone(), + a.id, + )); + } + } + }); + if !suggestions.is_empty() { + ui.label("install:"); + let suggestion_text = row + .selected_suggestion + .and_then(|id| { + suggestions + .iter() + .find(|s| s.id == id) + .map(|s| s.name.as_str()) + }) + .unwrap_or(""); + egui::ComboBox::from_id_salt(format!( + "sg_{}", + dep.dep_dir + )) + .selected_text(suggestion_text) + .width(180.0) + .show_ui( + ui, + |ui| { + for s in suggestions { + if ui + .selectable_label( + row.selected_suggestion + == Some(s.id), + &s.name, + ) + .clicked() + { + row.selected_suggestion = Some(s.id); + } + } + }, + ); + if ui.button("Install").clicked() + && let Some(id) = row.selected_suggestion + { + action = Some(DepAction::InstallBatch(vec![( + dep.dep_dir.clone(), + id, + )])); + } + } + } + } + }); + ui.separator(); + } + if let Some(action) = action { + match action { + DepAction::Navigate(id) => { + response.addon_id = id; + response.response_type = AddonResponseType::AddonName; + } + DepAction::SetIgnored(dir) => { + self.dep_mutation.set(service.set_dep_ignored(dir)); + } + DepAction::SetSatisfiedBy(dir, id) => { + self.dep_mutation.set(service.set_dep_satisfied_by(dir, id)); + } + DepAction::Revoke(dir) => { + self.dep_mutation.set(service.revoke_dep_override(dir)); + } + DepAction::InstallBatch(items) => { + self.dep_mutation + .set(service.install_dep_suggestions(items)); + self.dep_mutation_was_install = true; + } + } + } + } + DetailView::Dependents => { + let Some(dep_view) = self.dep_view.value.as_ref() else { + ui.spinner(); + return; + }; + let count = dep_view.dependents.len(); + ui.label(format!( + "Required by {} installed addon{}:", + count, + if count == 1 { "" } else { "s" } + )); + ui.add_space(5.0); + let mut nav: Option = None; + for r in &dep_view.dependents { + if ui.selectable_label(false, &r.name).clicked() { + nav = Some(r.id); + } + } + if let Some(id) = nav { + response.addon_id = id; + response.response_type = AddonResponseType::AddonName; + } + } }); }); response } } + +enum DepAction { + Navigate(i32), + SetIgnored(String), + SetSatisfiedBy(String, i32), + Revoke(String), + InstallBatch(Vec<(String, i32)>), +} impl ResetView for Details { fn reset(&mut self, service: &mut AddonService) { // do not get if not addon id set yet @@ -301,6 +582,9 @@ impl ResetView for Details { } // re-get details for same addon self.details.set(service.get_addon_details(self.addon_id)); + self.dep_view + .set(service.get_addon_dependency_view(self.addon_id)); + self.row_state.clear(); self.bb_description = None; self.bb_changelog = None; }