Skip to main content

modde_ui/app/
model.rs

1use std::path::PathBuf;
2
3use iced::widget::{container, opaque};
4use iced::{Element, Length, Task};
5use modde_core::profile::ProfileManager;
6use modde_core::resolver::GameId;
7
8use super::state::ToolLoadRequest;
9use super::tool_ops::{load_executables_for_game, load_tools_state};
10use super::tool_settings::{
11    apply_derived_tool_settings, build_tool_derived_facts, current_tool_config,
12    format_tool_availability, normalize_tool_settings_for_specs, patch_tool_setting_options,
13    set_tool_options, sync_optiscaler_release_options, tool_apply_is_pending, tool_options,
14};
15use super::{
16    Message, Modde, SettingsState, ToolHistoryUiEntry, ToolLoadSnapshot, ToolReleaseSupport,
17    ToolUiEntry, View, WabbajackInstallerState, build_conflict_rows, build_default_download_meta,
18    detected_game_ids, format_diagnostic_entry, load_active_plugins, load_hidden_files,
19    settings_game_install_paths,
20};
21
22impl Modde {
23    pub fn settings_state(&self) -> SettingsState {
24        SettingsState {
25            nexus_api_key_draft: self.nexus_api_key_draft.clone(),
26            nexus_api_key_visible: self.nexus_api_key_visible,
27            nexus_api_key_source: self.nexus_api_key_source.clone(),
28            nexus_config_key_exists: self.nexus_config_key_exists,
29            game_install_paths: settings_game_install_paths(
30                &self.settings,
31                modde_games::scan_installed_games(),
32            ),
33            download_dir: self.settings.download_dir.clone(),
34            effective_download_dir: self
35                .settings
36                .download_dir
37                .clone()
38                .unwrap_or_else(modde_core::paths::downloads_dir),
39            has_stock_snapshot: self.stock_snapshot_exists,
40            theme_name: self.theme_name.clone(),
41            nexus_status: self.nexus_status.clone(),
42        }
43    }
44
45    pub(super) fn refresh_nexus_api_key_state(&mut self) {
46        self.nexus_config_key_exists = modde_sources::nexus::auth::config_api_key_exists();
47        if let Ok(loaded) = modde_sources::nexus::auth::load_api_key_with_source() {
48            self.nexus_api_key_draft = loaded.key;
49            self.nexus_api_key_source = Some(loaded.source);
50        } else {
51            self.nexus_api_key_draft.clear();
52            self.nexus_api_key_source = None;
53        }
54    }
55
56    pub fn fomod_is_last_step(&self) -> bool {
57        if self.fomod_visible_step_indices.is_empty() {
58            return true;
59        }
60        self.fomod_wizard_pos >= self.fomod_visible_step_indices.len().saturating_sub(1)
61    }
62
63    pub fn reset_fomod(&mut self) {
64        self.fomod_installer = None;
65        self.fomod_source_dir = None;
66        self.fomod_dest_dir = None;
67        self.fomod_visible_step_indices.clear();
68        self.fomod_wizard_pos = 0;
69        self.fomod_selections.clear();
70        self.fomod_conflicts.clear();
71        self.fomod_can_undo = false;
72    }
73
74    pub fn refresh_fomod_visible_steps(&mut self) {
75        if let Some(ref installer) = self.fomod_installer {
76            self.fomod_visible_step_indices = installer
77                .visible_steps()
78                .iter()
79                .map(|&(idx, _)| idx)
80                .collect();
81        }
82    }
83
84    pub(super) fn refresh_fomod_conflicts(&mut self) {
85        if let Some(ref installer) = self.fomod_installer {
86            self.fomod_conflicts = installer.detect_conflicts().into();
87        }
88    }
89
90    pub(super) fn clear_game_scoped_state(&mut self) {
91        self.selected_mod_index = None;
92        self.selected_mod_details = None;
93        self.selected_save_details = None;
94        self.save_snapshots.clear();
95        self.current_fingerprint = None;
96        self.experiment_depth = 0;
97        self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Idle;
98        self.data_tab_conflicts.clear();
99        self.data_tab_state.missing_store_mod_count = 0;
100    }
101
102    pub(super) fn game_supports_save_profiles(game_id: &str) -> bool {
103        modde_games::resolve_game_plugin(game_id)
104            .is_some_and(modde_games::GamePlugin::supports_save_profiles)
105    }
106
107    pub(super) fn current_game_supports_save_profiles(&self) -> bool {
108        self.loaded_profile
109            .as_ref()
110            .map(|p| p.game_id.as_str())
111            .or(self.selected_game.as_deref())
112            .is_some_and(Self::game_supports_save_profiles)
113    }
114
115    pub(super) fn resolve_save_dir(game_id: &str) -> Option<PathBuf> {
116        let plugin = modde_games::resolve_game_plugin(game_id)?;
117        plugin
118            .supports_save_profiles()
119            .then(|| plugin.save_directory())
120            .flatten()
121    }
122
123    pub(super) fn reload_profile(&mut self) {
124        if let Some(ref name) = self.active_profile {
125            if let Ok(pm) = ProfileManager::open() {
126                let selected_game_id = self.selected_game.as_deref().map(GameId::from);
127                if let Some(game_id) = selected_game_id.as_ref() {
128                    self.profiles = pm.list_for_game(game_id).unwrap_or_default();
129                } else {
130                    self.profiles = pm.list().unwrap_or_default();
131                }
132                if let Ok(profile) = pm.load(name, selected_game_id.as_ref()) {
133                    if let Ok(info) = pm.active(&profile.game_id) {
134                        self.experiment_depth = info.map_or(0, |i| i.experiment_depth);
135                    }
136
137                    // Compute save fingerprint
138                    self.current_fingerprint = {
139                        let game_id = profile.game_id.as_str();
140                        let staging_dir = ProfileManager::staging_dir(&profile.name);
141                        modde_games::resolve_game_plugin(game_id)
142                            .filter(|plugin| plugin.supports_save_profiles())
143                            .map(|plugin| {
144                                modde_core::save::SaveFingerprint::compute(
145                                    &profile.mods,
146                                    |mod_id| {
147                                        let mod_path = staging_dir.join(mod_id);
148                                        plugin.classify_mod(&mod_path).affects_saves()
149                                    },
150                                )
151                            })
152                    };
153
154                    self.mod_id_filter_keys = modde_core::filter::mod_id_filter_keys(&profile.mods);
155                    self.loaded_profile = Some(profile);
156                }
157            }
158        } else {
159            self.loaded_profile = None;
160            self.mod_id_filter_keys.clear();
161        }
162
163        self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Idle;
164        self.refresh_data_tab_conflicts();
165        self.refresh_tools_state();
166    }
167
168    pub(super) fn switch_game_context(&mut self, game_id: &str) {
169        self.clear_game_scoped_state();
170
171        let Ok(pm) = ProfileManager::open() else {
172            self.profiles.clear();
173            self.active_profile = None;
174            self.loaded_profile = None;
175            self.mod_id_filter_keys.clear();
176            self.status_message = "Failed to open profile database".to_string();
177            return;
178        };
179
180        let typed_game_id = GameId::from(game_id);
181        self.profiles = pm.list_for_game(&typed_game_id).unwrap_or_default();
182        self.active_profile = pm
183            .active(&typed_game_id)
184            .ok()
185            .flatten()
186            .map(|info| info.profile.name)
187            .or_else(|| self.profiles.first().map(|p| p.name.clone()));
188
189        if self.active_profile.is_some() {
190            self.reload_profile();
191        } else {
192            self.loaded_profile = None;
193            self.mod_id_filter_keys.clear();
194            self.refresh_data_tab_conflicts();
195            self.refresh_tools_state();
196        }
197    }
198
199    pub(super) fn accept_game_selection(&mut self, game_id: String, previous_game: Option<String>) {
200        self.selected_game = Some(game_id.clone());
201        self.settings.selected_game = Some(game_id.clone());
202        let typed_game_id = GameId::from(game_id.as_str());
203
204        let configured_path_valid = self
205            .settings
206            .game_path(&typed_game_id)
207            .is_some_and(|path| path.is_dir());
208        if !configured_path_valid {
209            if let Some(path) = modde_games::find_detected_game(&typed_game_id)
210                .map(|detected| detected.install_path)
211                .or_else(|| {
212                    modde_games::resolve_game_plugin(&game_id)
213                        .and_then(modde_games::GamePlugin::detect_install)
214                })
215            {
216                self.settings.set_game_path(&typed_game_id, path);
217                self.detected_games.insert(game_id.clone());
218            } else {
219                self.game_path_dialog_open = true;
220                self.pending_game_path_game_id = Some(game_id.clone());
221                self.previous_game_before_path_dialog = previous_game;
222                self.game_path_dialog_error = None;
223                self.status_message = format!("Set the game directory for {game_id}");
224                self.save_settings();
225                return;
226            }
227        }
228
229        self.game_path_dialog_open = false;
230        self.pending_game_path_game_id = None;
231        self.previous_game_before_path_dialog = None;
232        self.game_path_dialog_error = None;
233        self.switch_game_context(&game_id);
234        self.sync_browse_game_to_current(true);
235        self.save_settings();
236        self.status_message = format!("Active game set to {game_id}");
237    }
238
239    pub(super) fn save_settings(&self) {
240        self.settings.save();
241    }
242
243    pub(super) fn refresh_available_games(&mut self) {
244        self.available_games = modde_games::supported_games()
245            .iter()
246            .map(|(id, name)| (id.to_string(), name.to_string()))
247            .collect();
248        self.detected_games = detected_game_ids(&self.settings, self.available_games.as_slice());
249    }
250
251    pub(super) fn custom_games(&self) -> Vec<(String, String)> {
252        self.available_games
253            .iter()
254            .filter(|(id, _)| !modde_games::SUPPORTED_GAME_IDS.contains(&id.as_str()))
255            .cloned()
256            .collect()
257    }
258
259    pub(super) fn current_game_id(&self) -> Option<&str> {
260        self.loaded_profile
261            .as_ref()
262            .map(|profile| profile.game_id.as_str())
263            .or(self.selected_game.as_deref())
264    }
265
266    pub(super) fn current_game_dir(&self) -> Option<PathBuf> {
267        let game_id = self.current_game_id()?;
268        self.settings
269            .game_path(&GameId::from(game_id))
270            .cloned()
271            .or_else(|| {
272                modde_games::resolve_game_plugin(game_id)
273                    .and_then(modde_games::GamePlugin::detect_install)
274            })
275    }
276
277    pub(super) fn add_custom_game_modal(&self) -> Element<'_, Message> {
278        opaque(
279            container(crate::views::add_custom_game::add_dialog(
280                &self.add_custom_game,
281            ))
282            .width(Length::Fill)
283            .height(Length::Fill)
284            .center_x(Length::Fill)
285            .center_y(Length::Fill),
286        )
287    }
288
289    pub(super) fn manage_custom_games_modal(&self) -> Element<'_, Message> {
290        opaque(
291            container(crate::views::add_custom_game::manage_dialog(
292                self.custom_games(),
293            ))
294            .width(Length::Fill)
295            .height(Length::Fill)
296            .center_x(Length::Fill)
297            .center_y(Length::Fill),
298        )
299    }
300
301    pub(super) fn current_tool_game_context(&self) -> Option<modde_games::tools::ToolGameContext> {
302        let game_id = self.current_game_id()?;
303        let display_name = self
304            .available_games
305            .iter()
306            .find(|(id, _)| id == game_id)
307            .map(|(_, name)| name.clone())
308            .unwrap_or_else(|| game_id.to_string());
309        let install_path = self.current_game_dir();
310        let detected = modde_games::detection::find_detected_game(&GameId::from(game_id));
311        Some(modde_games::tools::ToolGameContext::from_parts(
312            game_id,
313            display_name,
314            install_path,
315            detected.as_ref(),
316        ))
317    }
318
319    pub(super) fn refresh_data_tab_conflicts(&mut self) {
320        let Some(profile) = self.loaded_profile.as_ref() else {
321            self.data_tab_conflicts.clear();
322            self.data_tab_state.missing_store_mod_count = 0;
323            return;
324        };
325
326        let Ok(pm) = ProfileManager::open() else {
327            self.data_tab_conflicts.clear();
328            self.data_tab_state.missing_store_mod_count = 0;
329            return;
330        };
331
332        let hidden = load_hidden_files(&pm, profile);
333        let classifier = modde_games::resolve_collision_classifier(profile.game_id.as_str());
334
335        match modde_core::diagnostics::analyze_profile_state(
336            profile,
337            &modde_core::paths::store_dir(),
338            &hidden,
339            classifier.as_deref(),
340        ) {
341            Ok(analysis) => {
342                self.data_tab_state.missing_store_mod_count = analysis.missing_store_mods.len();
343                self.data_tab_conflicts = build_conflict_rows(&analysis, &hidden);
344            }
345            Err(err) => {
346                self.data_tab_conflicts.clear();
347                self.data_tab_state.missing_store_mod_count = 0;
348                self.status_message = format!("Failed to load data tab: {err}");
349            }
350        }
351    }
352
353    pub(super) fn run_diagnostics_now(&mut self) {
354        let Some(profile) = self.loaded_profile.clone() else {
355            self.status_message = "Select a profile before running diagnostics".to_string();
356            self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Error(
357                "Select a profile before running diagnostics.".to_string(),
358            );
359            return;
360        };
361
362        let Ok(pm) = ProfileManager::open() else {
363            self.status_message = "Failed to open profile database".to_string();
364            self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Error(
365                "Failed to open profile database.".to_string(),
366            );
367            return;
368        };
369
370        let hidden = load_hidden_files(&pm, &profile);
371        let active_plugins = load_active_plugins(&pm, &profile);
372        let integrity = Self::verify_staging_integrity(&ProfileManager::staging_dir(&profile.name));
373        let engine = match profile.game_id.as_str() {
374            "skyrim-se" | "skyrim-ae" | "fallout4" | "fallout76" => {
375                modde_games::bethesda::diagnostics::bethesda_diagnostics()
376            }
377            _ => modde_core::diagnostics::base_diagnostics(),
378        };
379        let classifier = modde_games::resolve_collision_classifier(profile.game_id.as_str());
380
381        match modde_core::diagnostics::run_profile_diagnostics(
382            profile.game_id.as_str(),
383            &profile,
384            &active_plugins,
385            &modde_core::paths::store_dir(),
386            &ProfileManager::staging_dir(&profile.name),
387            &hidden,
388            classifier.as_deref(),
389            &engine,
390        ) {
391            Ok((diagnostics, analysis)) => {
392                self.data_tab_state.missing_store_mod_count = analysis.missing_store_mods.len();
393                self.data_tab_conflicts = build_conflict_rows(&analysis, &hidden);
394                let entries: Vec<_> = diagnostics.iter().map(format_diagnostic_entry).collect();
395                let diagnostic_count = entries.len();
396                let broken_count = integrity.broken_symlinks.len();
397                self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Complete(
398                    crate::views::diagnostics::DiagnosticsReport {
399                        profile_name: profile.name.clone(),
400                        game_id: profile.game_id.to_string(),
401                        entries,
402                        integrity,
403                    },
404                );
405                self.status_message = if diagnostic_count == 0 && broken_count == 0 {
406                    "Diagnostics complete: no issues found".to_string()
407                } else {
408                    format!(
409                        "Diagnostics complete: {diagnostic_count} issue(s), {broken_count} broken symlink(s)"
410                    )
411                };
412            }
413            Err(err) => {
414                self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Error(
415                    format!("Diagnostics failed: {err}"),
416                );
417                self.status_message = format!("Diagnostics failed: {err}");
418            }
419        }
420    }
421
422    pub(super) fn verify_staging_integrity(
423        staging_dir: &std::path::Path,
424    ) -> crate::views::diagnostics::IntegritySummary {
425        let mut results = crate::views::diagnostics::IntegritySummary::default();
426        if !staging_dir.exists() {
427            return results;
428        }
429
430        fn walk(dir: &std::path::Path, results: &mut crate::views::diagnostics::IntegritySummary) {
431            if let Ok(entries) = std::fs::read_dir(dir) {
432                for entry in entries.flatten() {
433                    let path = entry.path();
434                    if path.is_dir() {
435                        walk(&path, results);
436                    } else if path.is_symlink() {
437                        match std::fs::read_link(&path) {
438                            Ok(target) if target.exists() => {
439                                results.ok_count += 1;
440                            }
441                            _ => results.broken_symlinks.push(path),
442                        }
443                    } else {
444                        results.ok_count += 1;
445                    }
446                }
447            }
448        }
449
450        walk(staging_dir, &mut results);
451        results
452    }
453
454    pub(super) fn start_tools_load(&mut self) -> Task<Message> {
455        let Some(request) = self.tool_load_request() else {
456            self.tool_state.entries.clear();
457            self.tool_state.active_tool_id = None;
458            self.tool_state.game_label = None;
459            self.tool_state.game_dir_configured = false;
460            self.tool_state.loading = false;
461            self.tool_state.load_error = None;
462            self.status_message = "Select a game before loading tools".to_string();
463            return Task::none();
464        };
465        self.tool_state.load_generation = self.tool_state.load_generation.wrapping_add(1);
466        let generation = self.tool_state.load_generation;
467        self.tool_state.loading = true;
468        self.tool_state.load_error = None;
469        Task::perform(load_tools_state(request), move |result| {
470            Message::ToolsLoaded { generation, result }
471        })
472    }
473
474    pub(super) fn tool_load_request(&self) -> Option<ToolLoadRequest> {
475        let game_id = self.current_game_id()?.to_string();
476        let display_name = self
477            .available_games
478            .iter()
479            .find(|(id, _)| id == &game_id)
480            .map(|(_, name)| name.clone())
481            .unwrap_or_else(|| game_id.clone());
482        Some(ToolLoadRequest {
483            game_id,
484            display_name,
485            configured_game_dir: self
486                .settings
487                .game_path(&GameId::from(self.current_game_id()?))
488                .cloned(),
489            optiscaler_releases: self.tool_state.optiscaler_releases.clone(),
490            tool_option_catalog: self.tool_state.tool_option_catalog.clone(),
491            previous_active_tool_id: self.tool_state.active_tool_id.clone(),
492        })
493    }
494
495    pub(super) fn apply_tool_snapshot(&mut self, snapshot: ToolLoadSnapshot) {
496        self.tool_state.entries = snapshot.entries;
497        self.tool_state.active_tool_id = snapshot.active_tool_id;
498        self.tool_state.game_label = snapshot.game_label;
499        self.tool_state.game_dir_configured = snapshot.game_dir_configured;
500        self.tool_state.tool_option_catalog = snapshot.tool_option_catalog;
501        self.tool_state.executables = snapshot.executables;
502        self.tool_state.loading = false;
503        self.tool_state.load_error = None;
504    }
505
506    pub(super) fn start_executables_load(&mut self) -> Task<Message> {
507        let Some(game_id) = self.current_game_id().map(str::to_string) else {
508            self.tool_state.executables.clear();
509            self.tool_state.game_label = None;
510            self.tool_state.executables_loading = false;
511            self.tool_state.executables_load_error = None;
512            self.status_message = "Select a game before loading executables".to_string();
513            return Task::none();
514        };
515        self.tool_state.game_label = self
516            .available_games
517            .iter()
518            .find(|(id, _)| id == &game_id)
519            .map(|(_, name)| name.clone())
520            .or_else(|| Some(game_id.clone()));
521        self.tool_state.executables_load_generation =
522            self.tool_state.executables_load_generation.wrapping_add(1);
523        let generation = self.tool_state.executables_load_generation;
524        self.tool_state.executables_loading = true;
525        self.tool_state.executables_load_error = None;
526        Task::perform(load_executables_for_game(game_id), move |result| {
527            Message::ExecutablesLoaded { generation, result }
528        })
529    }
530
531    pub(super) fn refresh_executables_or_tools(&mut self) -> Task<Message> {
532        if matches!(self.active_view, View::Executables) {
533            self.start_executables_load()
534        } else if self.tool_load_request().is_some() {
535            self.start_tools_load()
536        } else {
537            Task::none()
538        }
539    }
540
541    pub(super) fn refresh_tools_state(&mut self) {
542        let Some(game_id) = self.current_game_id().map(str::to_string) else {
543            self.tool_state.entries.clear();
544            self.tool_state.active_tool_id = None;
545            self.tool_state.game_label = None;
546            self.tool_state.game_dir_configured = false;
547            return;
548        };
549
550        let Ok(db) = modde_core::db::ModdeDb::open() else {
551            self.tool_state.entries.clear();
552            self.tool_state.active_tool_id = None;
553            return;
554        };
555
556        self.tool_state.game_label = self
557            .available_games
558            .iter()
559            .find(|(id, _)| id == &game_id)
560            .map(|(_, name)| name.clone())
561            .or_else(|| Some(game_id.clone()));
562        self.tool_state.game_dir_configured = self.current_game_dir().is_some();
563        if tool_options(
564            &self.tool_state.tool_option_catalog,
565            "proton",
566            "selected_version",
567        )
568        .is_none()
569        {
570            set_tool_options(
571                &mut self.tool_state.tool_option_catalog,
572                "proton",
573                "selected_version",
574                modde_games::tools::proton::proton_version_options(),
575            );
576        }
577        if !self.tool_state.optiscaler_releases.is_empty()
578            && let Ok(config) = current_tool_config(&game_id, "optiscaler")
579        {
580            let mut config = config;
581            sync_optiscaler_release_options(
582                &mut self.tool_state.tool_option_catalog,
583                &self.tool_state.optiscaler_releases,
584                &mut config,
585            );
586        }
587        let context = self.current_tool_game_context();
588
589        let typed_game_id = GameId::from(game_id.as_str());
590        self.tool_state.entries = modde_games::tools::all_tools()
591            .iter()
592            .map(|tool| {
593                let row = db
594                    .load_tool_config(&typed_game_id, tool.tool_id())
595                    .ok()
596                    .flatten();
597                let availability = tool.detect_available();
598                let applied_files = db
599                    .load_applied_files(&typed_game_id, tool.tool_id())
600                    .unwrap_or_default();
601                let status_message = match &availability {
602                    modde_games::tools::ToolAvailability::Available {
603                        version: Some(version),
604                    } => Some(format!("Detected {version}")),
605                    modde_games::tools::ToolAvailability::NotInstalled { install_hint } => {
606                        Some(install_hint.clone())
607                    }
608                    modde_games::tools::ToolAvailability::Available { version: None } => None,
609                };
610                let availability_text = format_tool_availability(&availability);
611                let mut config = row.as_ref().map_or_else(
612                    || tool.default_config_for(context.as_ref()),
613                    |row| modde_games::tools::ToolConfig {
614                        tool_id: row.tool_id.clone(),
615                        enabled: row.enabled,
616                        settings: serde_json::from_str(&row.settings_json).unwrap_or_default(),
617                    },
618                );
619                let mut setting_specs = tool.settings_schema_for(context.as_ref(), &config);
620                let normalized_settings =
621                    normalize_tool_settings_for_specs(&config.settings, &setting_specs);
622                if normalized_settings != config.settings {
623                    config.settings = normalized_settings;
624                    if let Ok(settings_json) = serde_json::to_string(&config.settings) {
625                        let _ = db.save_tool_config(
626                            &typed_game_id,
627                            tool.tool_id(),
628                            config.enabled,
629                            &settings_json,
630                        );
631                    }
632                    setting_specs = tool.settings_schema_for(context.as_ref(), &config);
633                }
634                config.set("_game_id", serde_json::json!(game_id));
635                apply_derived_tool_settings(&mut config, context.as_ref());
636                let mut apply_pending = tool_apply_is_pending(&config, &applied_files);
637                let mut apply_missing_inputs = Vec::new();
638                let generated_config_path = tool
639                    .generate_config_for(context.as_ref(), &config)
640                    .map(|generated| generated.path.display().to_string());
641                let env_preview = tool
642                    .env_vars_for(context.as_ref(), &config)
643                    .into_iter()
644                    .collect();
645                let dll_overrides = tool
646                    .wine_dll_overrides_for(context.as_ref(), &config)
647                    .into_iter()
648                    .collect();
649                let wrapper_preview = tool
650                    .wrapper_command(&config)
651                    .map(|wrapper| {
652                        if wrapper.args.is_empty() {
653                            vec![wrapper.exe]
654                        } else {
655                            vec![format!("{} {}", wrapper.exe, wrapper.args)]
656                        }
657                    })
658                    .unwrap_or_default();
659                patch_tool_setting_options(
660                    tool.tool_id(),
661                    &mut setting_specs,
662                    &self.tool_state.tool_option_catalog,
663                );
664                let mut derived_facts = build_tool_derived_facts(context.as_ref());
665                if matches!(tool.tool_id(), "reshade" | "optiscaler")
666                    && let Some(game_dir) = self.current_game_dir()
667                {
668                    match tool.preview_apply_for(&game_dir, context.as_ref(), &config) {
669                        Ok(preview) => {
670                            let has_changes = preview.has_changes();
671                            apply_missing_inputs = preview.missing_inputs.clone();
672                            apply_pending =
673                                apply_missing_inputs.is_empty() && has_changes;
674                            let summary = if !apply_missing_inputs.is_empty() {
675                                format!("missing input: {}", apply_missing_inputs.join("; "))
676                            } else if has_changes {
677                                format!(
678                                    "{} changed / {} unchanged",
679                                    preview.changed_files.len(),
680                                    preview.unchanged_files.len()
681                                )
682                            } else {
683                                format!("no changes ({} file(s))", preview.planned_files.len())
684                            };
685                            derived_facts.push(("Apply preview".to_string(), summary));
686                        }
687                        Err(err) => {
688                            derived_facts
689                                .push(("Apply preview".to_string(), format!("failed: {err}")));
690                        }
691                    }
692                }
693                let (optiscaler_state, optiscaler_latest_backup, optiscaler_detected_files) =
694                    if tool.tool_id() == "optiscaler" {
695                        let managed =
696                            modde_games::tools::optiscaler::managed_paths_from_config(&config);
697                        if let Some(game_dir) = self.current_game_dir() {
698                            if let Ok(state) =
699                                modde_games::tools::optiscaler::scan_optiscaler_install(
700                                    &game_id, &game_dir, &managed,
701                                )
702                            {
703                                if !matches!(
704                                    state.status,
705                                    modde_games::tools::optiscaler::OptiScalerInstallStatus::Managed
706                                        | modde_games::tools::optiscaler::OptiScalerInstallStatus::PartiallyManaged
707                                ) {
708                                    apply_pending = true;
709                                }
710                                derived_facts
711                                    .push(("OptiScaler state".to_string(), state.summary()));
712                                if let Some(path) = &state.config_path {
713                                    derived_facts.push((
714                                        "OptiScaler config".to_string(),
715                                        format!(
716                                            "{} ({} setting(s))",
717                                            path.display(),
718                                            state.ini_settings.len()
719                                        ),
720                                    ));
721                                }
722                                if let Some(path) = &state.latest_backup {
723                                    derived_facts.push((
724                                        "OptiScaler backup".to_string(),
725                                        path.display().to_string(),
726                                    ));
727                                }
728                                (
729                                    Some(state.summary()),
730                                    state.latest_backup.map(|path| path.display().to_string()),
731                                    state.recognized_files.len(),
732                                )
733                            } else {
734                                (None, None, 0)
735                            }
736                        } else {
737                            (None, None, 0)
738                        }
739                    } else {
740                        (None, None, 0)
741                    };
742                let setting_history = db
743                    .list_tool_setting_history(&typed_game_id, tool.tool_id(), 8)
744                    .unwrap_or_default()
745                    .into_iter()
746                    .map(ToolHistoryUiEntry::from_node)
747                    .collect();
748
749                ToolUiEntry {
750                    tool_id: tool.tool_id().to_string(),
751                    display_name: tool.display_name().to_string(),
752                    description: tool.description().to_string(),
753                    category: tool.category().to_string(),
754                    available: availability.is_available(),
755                    availability_text,
756                    enabled: config.enabled,
757                    settings: config.settings.clone(),
758                    setting_specs,
759                    generated_config_path,
760                    applied_files,
761                    has_file_patching: matches!(tool.tool_id(), "reshade" | "optiscaler"),
762                    release_support: ToolReleaseSupport::from_supports_releases(
763                        tool.supports_releases(),
764                    ),
765                    status_message,
766                    env_preview,
767                    dll_overrides,
768                    wrapper_preview,
769                    derived_facts,
770                    optiscaler_state,
771                    optiscaler_latest_backup,
772                    optiscaler_detected_files,
773                    apply_pending,
774                    apply_missing_inputs,
775                    setting_history,
776                }
777            })
778            .collect();
779
780        let active_still_valid = self
781            .tool_state
782            .active_tool_id
783            .as_deref()
784            .is_some_and(|active| {
785                self.tool_state
786                    .entries
787                    .iter()
788                    .any(|entry| entry.tool_id == active)
789            });
790        if !active_still_valid {
791            self.tool_state.active_tool_id = self
792                .tool_state
793                .entries
794                .first()
795                .map(|entry| entry.tool_id.clone());
796        }
797    }
798
799    pub(super) fn track_download(&mut self, key: &str, name: &str) -> usize {
800        if let Some(id) = self.download_lookup.get(key).copied() {
801            return id;
802        }
803
804        let dest_root = self
805            .settings
806            .download_dir
807            .clone()
808            .unwrap_or_else(modde_core::paths::downloads_dir);
809        let file_name = key.replace(['/', ':', ' '], "_");
810        let dest = dest_root.join(format!("{file_name}.download"));
811        let id = self.download_queue.enqueue(
812            key.to_string(),
813            dest,
814            None,
815            build_default_download_meta(key, name),
816        );
817        self.download_lookup.insert(key.to_string(), id);
818        id
819    }
820
821    pub(super) fn downloads_view_tasks(&self) -> Vec<crate::views::downloads::DownloadTask> {
822        self.download_queue
823            .all()
824            .iter()
825            .map(|task| {
826                let state = match &task.state {
827                    modde_sources::queue::DownloadState::Queued => {
828                        crate::views::downloads::DownloadState::Queued
829                    }
830                    modde_sources::queue::DownloadState::Active {
831                        bytes_downloaded,
832                        total_bytes,
833                    } => crate::views::downloads::DownloadState::Active {
834                        bytes_downloaded: *bytes_downloaded,
835                        total_bytes: *total_bytes,
836                    },
837                    modde_sources::queue::DownloadState::Paused {
838                        bytes_downloaded,
839                        total_bytes,
840                    } => crate::views::downloads::DownloadState::Paused {
841                        bytes_downloaded: *bytes_downloaded,
842                        total_bytes: *total_bytes,
843                    },
844                    modde_sources::queue::DownloadState::Complete { path, .. } => {
845                        crate::views::downloads::DownloadState::Complete { path: path.clone() }
846                    }
847                    modde_sources::queue::DownloadState::Failed { error } => {
848                        crate::views::downloads::DownloadState::Failed {
849                            error: error.clone(),
850                        }
851                    }
852                };
853
854                crate::views::downloads::DownloadTask {
855                    id: task.id,
856                    name: task
857                        .meta
858                        .mod_name
859                        .clone()
860                        .unwrap_or_else(|| task.url.clone()),
861                    state,
862                }
863            })
864            .collect()
865    }
866
867    // ── Browse Nexus helpers (Phase 6) ──────────────────────────
868
869    /// Return the currently-selected game's Nexus domain, if the game
870    /// plugin defines one. Used by the Browse Nexus view to issue
871    /// GraphQL queries scoped to the right game.
872    pub fn current_game_nexus_domain(&self) -> Option<String> {
873        let game_id = self
874            .loaded_profile
875            .as_ref()
876            .map(|p| p.game_id.to_string())
877            .or_else(|| self.selected_game.clone())?;
878        Self::nexus_domain_for_game(&game_id)
879    }
880
881    pub(super) fn nexus_domain_for_game(game_id: &str) -> Option<String> {
882        let game = modde_games::resolve_game(game_id)?;
883        game.nexus_game_id?;
884        game.nexus_domain.map(str::to_string)
885    }
886
887    pub(super) fn first_supported_nexus_game(&self) -> Option<String> {
888        self.available_games
889            .iter()
890            .map(|(id, _)| id)
891            .find(|id| Self::nexus_domain_for_game(id).is_some())
892            .cloned()
893    }
894
895    pub(super) fn default_browse_game_id(&self) -> Option<String> {
896        self.current_game_id()
897            .filter(|game_id| Self::nexus_domain_for_game(game_id).is_some())
898            .map(str::to_string)
899            .or_else(|| self.first_supported_nexus_game())
900    }
901
902    pub(super) fn browse_game_nexus_domain(&self) -> Option<String> {
903        self.browse_nexus
904            .selected_game_id
905            .as_deref()
906            .and_then(Self::nexus_domain_for_game)
907    }
908
909    pub(super) fn clear_browse_results(&mut self) {
910        self.browse_nexus.mods.clear();
911        self.browse_nexus.collections.clear();
912        self.browse_nexus.error = None;
913        self.browse_nexus.install_status = None;
914    }
915
916    pub(super) fn sync_browse_game_to_current(&mut self, force: bool) {
917        let selected_is_supported = self
918            .browse_nexus
919            .selected_game_id
920            .as_deref()
921            .is_some_and(|game_id| Self::nexus_domain_for_game(game_id).is_some());
922        if !force && selected_is_supported {
923            return;
924        }
925        let next = self.default_browse_game_id();
926        if self.browse_nexus.selected_game_id != next {
927            self.browse_nexus.selected_game_id = next;
928            self.clear_browse_results();
929        }
930    }
931
932    pub(super) fn initialize_wabbajack_game_filter(&self, state: &mut WabbajackInstallerState) {
933        if state.game_filter_user_edited || state.game_filter.is_some() {
934            return;
935        }
936        state.game_filter = self.current_game_id().map(str::to_string);
937    }
938
939    /// Kick off an async feed load for the Browse Nexus view. Picks
940    /// the right GraphQL query based on the tab.
941    pub fn spawn_browse_load(
942        &mut self,
943        tab: crate::views::browse_nexus::BrowseTab,
944        game_domain: String,
945        search_query: String,
946    ) -> Task<Message> {
947        use crate::views::browse_nexus::BrowseTab;
948        self.browse_nexus.loading = true;
949        self.browse_nexus.error = None;
950        match tab {
951            BrowseTab::Top | BrowseTab::Month => {
952                let kind = match tab {
953                    BrowseTab::Top => modde_sources::nexus::graphql::ModFeedKind::Trending,
954                    _ => modde_sources::nexus::graphql::ModFeedKind::MonthlyTop,
955                };
956                Task::perform(
957                    async move {
958                        let api_key = modde_sources::nexus::auth::load_api_key()
959                            .map_err(|e| e.to_string())?;
960                        let client = reqwest::Client::new();
961                        let api = modde_sources::nexus::api::NexusApi::new(client, api_key);
962                        api.browse_feed_gql(&game_domain, kind)
963                            .await
964                            .map_err(|e| e.to_string())
965                    },
966                    Message::BrowseModsLoaded,
967                )
968            }
969            BrowseTab::Search => Task::perform(
970                async move {
971                    let api_key =
972                        modde_sources::nexus::auth::load_api_key().map_err(|e| e.to_string())?;
973                    let client = reqwest::Client::new();
974                    let api = modde_sources::nexus::api::NexusApi::new(client, api_key);
975                    api.search_mods_gql(&game_domain, &search_query, 1)
976                        .await
977                        .map_err(|e| e.to_string())
978                },
979                Message::BrowseModsLoaded,
980            ),
981            BrowseTab::Collections => {
982                let term = if search_query.is_empty() {
983                    None
984                } else {
985                    Some(search_query)
986                };
987                Task::perform(
988                    async move {
989                        let api_key = modde_sources::nexus::auth::load_api_key()
990                            .map_err(|e| e.to_string())?;
991                        let client = reqwest::Client::new();
992                        let api = modde_sources::nexus::api::NexusApi::new(client, api_key);
993                        api.collections_feed_gql(&game_domain, term.as_deref())
994                            .await
995                            .map_err(|e| e.to_string())
996                    },
997                    Message::BrowseCollectionsLoaded,
998                )
999            }
1000        }
1001    }
1002}