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