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::{
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    /// Reload the active profile, its data-tab conflicts, and the tool state
122    /// off the render thread, returning the `Task` that resolves to
123    /// `Message::ProfileContextLoaded`. Replaces the old synchronous
124    /// `reload_profile` (which drove the whole multi-query reload via
125    /// `block_on` on the iced thread).
126    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    /// Like [`Self::reload_profile`], but re-runs diagnostics after the load
132    /// resolves when the Diagnostics view is active. Used only by the
133    /// profile-switch handler to preserve its previous synchronous behavior.
134    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    /// Reload, recomputing the active profile from the DB (active pointer, then
140    /// first listed profile). Used after deleting the active profile in a
141    /// no-game context, where the previous active profile is gone.
142    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    /// Build the request for an in-place reload (no scoped-state clear, keeps
149    /// the current `selected_game`/`active_profile`).
150    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    /// Bump the context generation, mark the tool state as loading (when a tool
162    /// reload is folded in), and spawn the off-thread loader.
163    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        // A composite reload recomputes the data-tab conflicts authoritatively,
168        // so any in-flight standalone data-tab refresh is now stale — invalidate
169        // it so its older result can't clobber the new game's conflicts.
170        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    /// Apply a resolved [`ProfileContextSnapshot`] into `self` — the synchronous
182    /// tail of the old `reload_profile`/`switch_game_context`/
183    /// `refresh_data_tab_conflicts`/`refresh_tools_state` helpers.
184    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            // No game in scope — clear the tool state (mirrors the old
212            // `refresh_tools_state` empty-game branch).
213            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    /// Synchronously drive [`load_profile_context`] + [`Self::apply_profile_context`]
223    /// for tests, which pump `update` but discard returned `Task`s.
224    #[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    /// Synchronously complete the game-context switch that a `SelectGame` /
233    /// `GamePathDialogPathSelected` handler just kicked off (the returned
234    /// `Task` is discarded by the test harness). Mirrors the request
235    /// `switch_game_context` builds, using the already-set `selected_game`.
236    #[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    /// Switch the game context: clear game-scoped state and the previously
256    /// loaded profile (so the view repaints to a "loading" state immediately
257    /// rather than showing the old game's data), then reload the new game's
258    /// profile/conflicts/tools off-thread. Returns the resolving `Task`.
259    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    /// Refresh the Data tab's conflict rows off the render thread. Returns the
400    /// `Task` resolving to `Message::DataTabConflictsLoaded`. The heavy
401    /// `analyze_profile_state` pass and hidden-files DB read run on a blocking
402    /// task rather than inline via `block_on`.
403    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    /// Build a tool-load request for an explicit game id, rather than relying on
525    /// `current_game_id()`. Used by `switch_game_context`, where `loaded_profile`
526    /// has just been cleared so `current_game_id()` could not be trusted.
527    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    // ── Browse Nexus helpers (Phase 6) ──────────────────────────
660
661    /// Return the currently-selected game's Nexus domain, if the game
662    /// plugin defines one. Used by the Browse Nexus view to issue
663    /// GraphQL queries scoped to the right game.
664    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    /// Kick off an async feed load for the Browse Nexus view. Picks
732    /// the right GraphQL query based on the tab.
733    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
796/// Async loader for the profile + data-tab + tool context. Pure DB work is
797/// awaited directly; synchronous data-tab analysis and tool-state generation
798/// move into their own blocking bridges.
799pub(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    // Profile list (scoped to the game when one is selected, else all).
807    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    // Active profile: recompute from the DB for game switches; otherwise trust
813    // the caller-provided name.
814    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    // Load the active profile.
830    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            // Load failed — keep the previously-loaded profile (and its derived
849            // fields) untouched, as the old synchronous helper did.
850            Err(_) => ProfileLoadOutcome::KeepPrevious,
851        },
852        None => ProfileLoadOutcome::Cleared,
853    };
854
855    // Data-tab conflicts come from whichever profile will be displayed: the
856    // freshly loaded one, the kept previous one, or none.
857    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    // On a data-tab analysis failure the conflicts are cleared (matching the
863    // old behavior). The error is NOT surfaced via `status_message` here: every
864    // composite-load caller set its own status after the old synchronous
865    // `reload_profile`, so the data-tab error was never visible on this path.
866    // The standalone `load_data_tab_conflicts` (Data-tab open) still surfaces it.
867    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    // Fold in the tool reload when a game is in scope.
875    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
891/// Compute the save fingerprint for a profile (lifted verbatim from the old
892/// `reload_profile`). `None` when the game doesn't support save profiles.
893fn 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
908/// Compute data-tab conflict rows + missing-store count for a profile. Shared
909/// by [`load_profile_context`] and [`load_data_tab_conflicts`].
910async 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
958/// Off-thread lightweight data-tab conflict refresh (fired when the Data tab is
959/// opened).
960pub(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}