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