Skip to main content

modde_ui/
app.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use iced::widget::{button, column, container, mouse_area, pick_list, row, text};
5use iced::{keyboard, window, Element, Length, Subscription, Task, Theme};
6use smallvec::SmallVec;
7
8use modde_core::filter::{FilterCriterion, FilterKind, FilterMode};
9use modde_core::manifest::collection::CollectionManifest;
10use modde_core::profile::ProfileManager;
11use modde_core::save::SaveSnapshot;
12use modde_core::settings::AppSettings;
13
14/// Settings view state — consumed by the settings view.
15#[derive(Debug, Clone, Default)]
16pub struct SettingsState {
17    pub nexus_api_key: String,
18    pub game_path: Option<PathBuf>,
19    pub download_dir: Option<PathBuf>,
20    pub has_stock_snapshot: bool,
21    pub theme_name: String,
22    pub nexus_status: Option<NexusAuthStatus>,
23}
24
25#[derive(Debug, Clone)]
26pub enum NexusAuthStatus {
27    Checking,
28    Valid { username: String, is_premium: bool },
29    Invalid(String),
30}
31
32/// Top-level application state.
33pub struct Modde {
34    pub active_view: View,
35    pub active_profile: Option<String>,
36    pub profiles: Vec<modde_core::profile::ProfileSummary>,
37    pub status_message: String,
38    pub settings: AppSettings,
39    pub collection_search: String,
40    pub collections: Vec<CollectionManifest>,
41    pub fomod_installer: Option<FOMODWizardState>,
42    pub fomod_visible_step_indices: SmallVec<[usize; 16]>,
43    pub fomod_wizard_pos: usize,
44    pub fomod_source_dir: Option<PathBuf>,
45    pub fomod_dest_dir: Option<PathBuf>,
46    pub fomod_conflicts: SmallVec<[String; 4]>,
47    pub fomod_can_undo: bool,
48    pub fomod_selections: HashMap<(usize, usize), Vec<usize>>,
49    pub selected_mod_index: Option<usize>,
50    /// Loaded Nexus metadata for the currently selected mod — populates the
51    /// detail panel at the bottom of the left nav sidebar. `None` means no
52    /// Nexus-tracked mod is selected (either nothing is selected or the
53    /// selected mod has no `nexus_mod_id`).
54    pub selected_mod_details: Option<crate::views::mod_details::ModDetailsState>,
55    pub mod_filter: String,
56    pub theme_name: String,
57    pub wabbajack_manifest: Option<modde_core::WabbajackManifest>,
58    pub active_downloads: Vec<crate::views::collections::CollectionDownload>,
59    // ── New state fields ──
60    pub loaded_profile: Option<modde_core::Profile>,
61    pub save_snapshots: Vec<SaveSnapshot>,
62    pub current_fingerprint: Option<modde_core::save::SaveFingerprint>,
63    pub selected_save_details: Option<crate::views::save_details::SaveDetailsState>,
64    pub experiment_depth: usize,
65    pub nexus_status: Option<NexusAuthStatus>,
66    pub verify: VerifyState,
67    pub new_profile_name: String,
68    pub available_games: SmallVec<[(String, String); 8]>,
69    pub selected_game: Option<String>,
70    pub stock_snapshot_exists: bool,
71    pub window_id: window::Id,
72    /// Which category groups are collapsed in the mod list view.
73    /// `None` key = the "Uncategorized" group.
74    pub collapsed_categories: HashSet<Option<i64>>,
75    /// Category id-to-name mapping for the mod list view.
76    pub mod_categories: Vec<(Option<i64>, String)>,
77    pub data_tab_state: crate::views::data_tab::DataTabState,
78    pub data_tab_conflicts: Vec<(String, Vec<String>)>,
79    /// State for the Browse Nexus view (Phase 6 of the installer pipeline).
80    pub browse_nexus: crate::views::browse_nexus::NexusBrowseState,
81    pub diagnostics_state: crate::views::diagnostics::DiagnosticsState,
82    pub tool_state: ToolState,
83    /// Filter mode (AND/OR) for the mod list filter toolbar.
84    pub filter_mode: FilterMode,
85    /// Active tri-state filter criteria for the mod list.
86    pub filter_criteria: Vec<FilterCriterion>,
87    /// Whether the mod list uses compact row rendering.
88    pub compact_mod_list: bool,
89}
90
91
92#[derive(Debug, Clone)]
93pub struct VerifyResults {
94    pub missing_mods: SmallVec<[String; 8]>,
95    pub hash_mismatches: Vec<(PathBuf, String, String)>,
96    pub broken_symlinks: SmallVec<[PathBuf; 8]>,
97    pub ok_count: usize,
98}
99
100/// Compile-time–friendly state machine for the verification pipeline.
101///
102/// Replaces the previous `verify_running: bool` + `verify_results: Option<VerifyResults>`
103/// pair, which could represent invalid states (e.g. `running = false, results = None`
104/// after a failed run vs. before any run). This enum makes every state explicit.
105#[derive(Debug, Clone, Default)]
106pub enum VerifyState {
107    /// No verification has been requested.
108    #[default]
109    Idle,
110    /// Verification is in progress.
111    Running,
112    /// Verification completed with results.
113    Complete(VerifyResults),
114}
115
116impl Modde {
117    pub fn settings_state(&self) -> SettingsState {
118        SettingsState {
119            nexus_api_key: self.settings.nexus_api_key.clone(),
120            game_path: self
121                .settings
122                .game_paths
123                .first()
124                .map(|gp| gp.path.clone()),
125            download_dir: self.settings.download_dir.clone(),
126            has_stock_snapshot: self.stock_snapshot_exists,
127            theme_name: self.theme_name.clone(),
128            nexus_status: self.nexus_status.clone(),
129        }
130    }
131
132    pub fn fomod_is_last_step(&self) -> bool {
133        if self.fomod_visible_step_indices.is_empty() {
134            return true;
135        }
136        self.fomod_wizard_pos >= self.fomod_visible_step_indices.len().saturating_sub(1)
137    }
138
139    pub fn reset_fomod(&mut self) {
140        self.fomod_installer = None;
141        self.fomod_source_dir = None;
142        self.fomod_dest_dir = None;
143        self.fomod_visible_step_indices.clear();
144        self.fomod_wizard_pos = 0;
145        self.fomod_selections.clear();
146        self.fomod_conflicts.clear();
147        self.fomod_can_undo = false;
148    }
149
150    pub fn refresh_fomod_visible_steps(&mut self) {
151        if let Some(ref installer) = self.fomod_installer {
152            self.fomod_visible_step_indices = installer
153                .visible_steps()
154                .iter()
155                .map(|&(idx, _)| idx)
156                .collect();
157        }
158    }
159
160    fn refresh_fomod_conflicts(&mut self) {
161        if let Some(ref installer) = self.fomod_installer {
162            self.fomod_conflicts = installer.detect_conflicts().into();
163        }
164    }
165
166    fn reload_profile(&mut self) {
167        if let Some(ref name) = self.active_profile {
168            if let Ok(pm) = ProfileManager::open() {
169                self.profiles = pm.list().unwrap_or_default();
170                if let Ok(profile) = pm.load(name, None) {
171                    if let Ok(info) = pm.active(&profile.game_id) {
172                        self.experiment_depth = info.map(|i| i.experiment_depth).unwrap_or(0);
173                    }
174
175                    // Compute save fingerprint
176                    self.current_fingerprint = {
177                        let game_id = profile.game_id.as_str();
178                        let staging_dir = ProfileManager::staging_dir(&profile.name);
179                        modde_games::resolve_game_plugin(game_id).map(|plugin| {
180                            modde_core::save::SaveFingerprint::compute(&profile.mods, |mod_id| {
181                                let mod_path = staging_dir.join(mod_id);
182                                plugin.classify_mod(&mod_path).affects_saves()
183                            })
184                        })
185                    };
186
187                    self.loaded_profile = Some(profile);
188                }
189            }
190        }
191    }
192
193    fn save_settings(&self) {
194        self.settings.save();
195    }
196
197    // ── Browse Nexus helpers (Phase 6) ──────────────────────────
198
199    /// Return the currently-selected game's Nexus domain, if the game
200    /// plugin defines one. Used by the Browse Nexus view to issue
201    /// GraphQL queries scoped to the right game.
202    pub fn current_game_nexus_domain(&self) -> Option<String> {
203        let game_id = self
204            .loaded_profile
205            .as_ref()
206            .map(|p| p.game_id.to_string())
207            .or_else(|| self.selected_game.clone())?;
208        modde_games::resolve_game_plugin(&game_id)
209            .and_then(|p| p.nexus_game_domain())
210            .map(str::to_string)
211    }
212
213    /// Kick off an async feed load for the Browse Nexus view. Picks
214    /// the right GraphQL query based on the tab.
215    pub fn spawn_browse_load(
216        &mut self,
217        tab: crate::views::browse_nexus::BrowseTab,
218        game_domain: String,
219        search_query: String,
220    ) -> Task<Message> {
221        use crate::views::browse_nexus::BrowseTab;
222        self.browse_nexus.loading = true;
223        self.browse_nexus.error = None;
224        match tab {
225            BrowseTab::Top | BrowseTab::Month => {
226                let kind = match tab {
227                    BrowseTab::Top => modde_sources::nexus::graphql::ModFeedKind::Trending,
228                    _ => modde_sources::nexus::graphql::ModFeedKind::MonthlyTop,
229                };
230                Task::perform(
231                    async move {
232                        let api_key = modde_sources::nexus::auth::load_api_key()
233                            .map_err(|e| e.to_string())?;
234                        let client = reqwest::Client::new();
235                        let api =
236                            modde_sources::nexus::api::NexusApi::new(client, api_key);
237                        api.browse_feed_gql(&game_domain, kind)
238                            .await
239                            .map_err(|e| e.to_string())
240                    },
241                    Message::BrowseModsLoaded,
242                )
243            }
244            BrowseTab::Search => Task::perform(
245                async move {
246                    let api_key = modde_sources::nexus::auth::load_api_key()
247                        .map_err(|e| e.to_string())?;
248                    let client = reqwest::Client::new();
249                    let api = modde_sources::nexus::api::NexusApi::new(client, api_key);
250                    api.search_mods_gql(&game_domain, &search_query, 1)
251                        .await
252                        .map_err(|e| e.to_string())
253                },
254                Message::BrowseModsLoaded,
255            ),
256            BrowseTab::Collections => {
257                let term = if search_query.is_empty() {
258                    None
259                } else {
260                    Some(search_query)
261                };
262                Task::perform(
263                    async move {
264                        let api_key = modde_sources::nexus::auth::load_api_key()
265                            .map_err(|e| e.to_string())?;
266                        let client = reqwest::Client::new();
267                        let api =
268                            modde_sources::nexus::api::NexusApi::new(client, api_key);
269                        api.collections_feed_gql(&game_domain, term.as_deref())
270                            .await
271                            .map_err(|e| e.to_string())
272                    },
273                    Message::BrowseCollectionsLoaded,
274                )
275            }
276        }
277    }
278}
279
280/// Run the full install pipeline for a single Nexus mod, invoked from
281/// the Browse Nexus **Install** button. Owned as a free function so
282/// the `update()` arm can hand it to `Task::perform` without borrowing
283/// `self`.
284async fn run_browse_install(game_domain: String, mod_id: u64) -> Result<String, String> {
285    let api_key =
286        modde_sources::nexus::auth::load_api_key().map_err(|e| e.to_string())?;
287    let client = reqwest::Client::new();
288    let api = modde_sources::nexus::api::NexusApi::new(client.clone(), api_key.clone());
289
290    // Look up the latest MAIN file id.
291    let files = api
292        .get_mod_files(&game_domain, mod_id)
293        .await
294        .map_err(|e| e.to_string())?;
295    let mut candidates: Vec<_> = files
296        .files
297        .into_iter()
298        .filter(|f| f.category_name.as_deref() == Some("MAIN"))
299        .collect();
300    candidates.sort_by_key(|f| std::cmp::Reverse(f.uploaded_timestamp));
301    let file_id = candidates
302        .first()
303        .map(|f| f.file_id)
304        .ok_or_else(|| format!("no MAIN file found for mod {mod_id}"))?;
305
306    let mod_info = api
307        .get_mod(&game_domain, mod_id)
308        .await
309        .map_err(|e| e.to_string())?;
310
311    // Build a probe from whichever game plugin is registered.
312    let probe = modde_games::resolve_game_plugin(&game_domain)
313        .map(modde_games::game_probe)
314        .unwrap_or_else(modde_core::installer::InstallProbe::noop);
315
316    let outcome = modde_sources::nexus::install::install_single_mod(
317        &client,
318        &api_key,
319        &game_domain,
320        mod_id,
321        file_id,
322        &mod_info,
323        &probe,
324    )
325    .await
326    .map_err(|e| e.to_string())?;
327
328    use modde_core::installer::InstallStatus;
329    use modde_sources::nexus::install::InstallOutcome;
330
331    let mod_id_str = format!("{game_domain}_{mod_id}_{file_id}");
332    let pm = modde_core::profile::ProfileManager::open().map_err(|e| e.to_string())?;
333
334    // Prefer an existing profile for the game; fall back to creating a
335    // Manual profile named after the game domain if none exist.
336    let profile_name = pm
337        .list()
338        .ok()
339        .and_then(|profiles| {
340            profiles
341                .into_iter()
342                .find(|p| p.game_id.as_str() == game_domain)
343                .map(|p| p.name)
344        })
345        .unwrap_or_else(|| game_domain.clone());
346    let mut profile = match pm.load(&profile_name, None) {
347        Ok(p) => p,
348        Err(_) => modde_core::profile::Profile {
349            id: None,
350            name: profile_name.clone(),
351            game_id: modde_core::resolver::GameId::from(game_domain.clone()),
352            source: modde_core::profile::ProfileSource::Manual,
353            mods: Vec::new(),
354            overrides: modde_core::profile::ProfileManager::default_overrides(
355                &profile_name,
356            ),
357            load_order_rules: smallvec::SmallVec::new(),
358            load_order_lock: None,
359        },
360    };
361    let status = match &outcome {
362        InstallOutcome::Installed(_) | InstallOutcome::AlreadyStaged => {
363            InstallStatus::Installed
364        }
365        InstallOutcome::PendingUserInput { .. } => InstallStatus::PendingUserInput,
366        InstallOutcome::Unknown { .. } => InstallStatus::Unknown,
367    };
368    if !profile.mods.iter().any(|m| m.mod_id == mod_id_str) {
369        profile.mods.push(modde_core::profile::EnabledMod {
370            mod_id: mod_id_str.clone(),
371            display_name: Some(mod_info.name.clone()),
372            enabled: true,
373            version: Some(mod_info.version.clone()),
374            nexus_mod_id: Some(mod_id as i64),
375            nexus_file_id: Some(file_id as i64),
376            nexus_game_domain: Some(game_domain.clone()),
377            install_status: Some(status.as_str().to_string()),
378            ..Default::default()
379        });
380    }
381    pm.create_or_update(&profile).map_err(|e| e.to_string())?;
382
383    if let InstallOutcome::Installed(plan) = &outcome {
384        let mut db = modde_core::ModdeDb::open().map_err(|e| e.to_string())?;
385        let profile_id = pm
386            .load(&profile_name, None)
387            .map_err(|e| e.to_string())?
388            .id
389            .ok_or_else(|| "saved profile has no id".to_string())?;
390        db.record_install(profile_id, &mod_id_str, plan, InstallStatus::Installed)
391            .map_err(|e| e.to_string())?;
392    }
393
394    Ok(match outcome {
395        InstallOutcome::Installed(_) | InstallOutcome::AlreadyStaged => {
396            format!("Installed '{}'", mod_info.name)
397        }
398        InstallOutcome::PendingUserInput { method } => {
399            format!("'{}' needs {method} wizard", mod_info.name)
400        }
401        InstallOutcome::Unknown { dossier_path, .. } => {
402            format!(
403                "Unknown install layout — dossier: {}",
404                dossier_path.display()
405            )
406        }
407    })
408}
409
410/// Direction for `Message::ReorderMod`. Re-export of the core type so
411/// view and message construction sites don't have to import from
412/// `modde_core::profile` directly. The enforcement logic itself lives in
413/// `modde_core::profile::try_reorder` and is the single source of truth.
414pub use modde_core::profile::ReorderDirection;
415
416/// Render a `LockReason` as a short, human-readable phrase for status
417/// messages and UI banners. Centralised so lock_reason strings stay
418/// consistent across the handler, load_order banner, and mod_list row.
419pub(crate) fn format_lock_reason(reason: &modde_core::LockReason) -> String {
420    use modde_core::LockReason::*;
421    match reason {
422        Wabbajack { manifest_hash } => format!("Wabbajack (hash {manifest_hash})"),
423        NexusCollection { slug, version } => format!("Nexus Collection '{slug}' v{version}"),
424        TomlImport { source_path } => format!("TOML import from {source_path}"),
425        Manual { note: Some(n) } => format!("manual ({n})"),
426        Manual { note: None } => "manual".to_string(),
427    }
428}
429
430/// Which view is currently displayed.
431#[derive(Debug, Clone)]
432pub enum View {
433    ModList,
434    Collections,
435    /// Unified Nexus browse surface — Top / Month / Collections / Search.
436    BrowseNexus,
437    WabbajackInstaller(WabbajackInstallerState),
438    FOMODWizard(FOMODWizardState),
439    Settings,
440    Saves,
441    Verify,
442    Downloads,
443    DataTab,
444    Diagnostics,
445    Tools,
446}
447
448#[derive(Debug, Clone, Default)]
449pub struct ToolState {
450    pub entries: Vec<ToolUiEntry>,
451}
452
453#[derive(Debug, Clone)]
454pub struct ToolUiEntry {
455    pub tool_id: String,
456    pub display_name: String,
457    pub category: String,
458    pub available: bool,
459    pub enabled: bool,
460    pub applied_files: usize,
461    pub has_file_patching: bool,
462    pub status_message: Option<String>,
463}
464
465#[derive(Debug, Clone, Default)]
466pub struct WabbajackInstallerState {
467    pub file_path: Option<PathBuf>,
468    pub progress: f32,
469    pub status: String,
470    pub log_lines: Vec<String>,
471}
472
473pub struct FOMODWizardState {
474    pub current_step: usize,
475    pub total_steps: usize,
476    inner: Option<fomod_oxide::installer::Installer>,
477}
478
479impl std::fmt::Debug for FOMODWizardState {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        f.debug_struct("FOMODWizardState")
482            .field("current_step", &self.current_step)
483            .field("total_steps", &self.total_steps)
484            .field("has_inner", &self.inner.is_some())
485            .finish()
486    }
487}
488
489impl Clone for FOMODWizardState {
490    fn clone(&self) -> Self {
491        Self {
492            current_step: self.current_step,
493            total_steps: self.total_steps,
494            inner: None,
495        }
496    }
497}
498
499impl FOMODWizardState {
500    pub fn new() -> Self {
501        Self {
502            current_step: 0,
503            total_steps: 0,
504            inner: None,
505        }
506    }
507
508    pub fn with_installer(installer: fomod_oxide::installer::Installer) -> Self {
509        let total = installer.visible_steps().len();
510        Self {
511            current_step: 0,
512            total_steps: total,
513            inner: Some(installer),
514        }
515    }
516
517    pub fn visible_steps(&self) -> Vec<(usize, &fomod_oxide::config::InstallStep)> {
518        match &self.inner {
519            Some(installer) => installer.visible_steps(),
520            None => vec![],
521        }
522    }
523
524    pub fn config(&self) -> &fomod_oxide::config::ModuleConfig {
525        self.inner.as_ref().expect("no FOMOD installer").config()
526    }
527
528    pub fn module_image_path(&self) -> Option<&str> {
529        self.inner.as_ref()?.module_image_path()
530    }
531
532    pub fn resolve_image(&self, base_path: &std::path::Path, image_path: &str) -> Option<PathBuf> {
533        self.inner.as_ref()?.resolve_image(base_path, image_path)
534    }
535
536    pub fn completion_status(&self) -> fomod_oxide::installer::CompletionStatus {
537        match &self.inner {
538            Some(installer) => installer.completion_status(),
539            None => fomod_oxide::installer::CompletionStatus {
540                total_steps: 0,
541                visible_steps: 0,
542                total_groups: 0,
543                satisfied_groups: 0,
544            },
545        }
546    }
547
548    pub fn validate_step(&self, step_index: usize) -> Vec<fomod_oxide::installer::ValidationHint> {
549        match &self.inner {
550            Some(installer) => installer.validate_step(step_index),
551            None => vec![],
552        }
553    }
554
555    pub fn plugin_type_at(
556        &self,
557        step: usize,
558        group: usize,
559        plugin: usize,
560    ) -> Option<fomod_oxide::config::PluginType> {
561        self.inner.as_ref()?.plugin_type_at(step, group, plugin)
562    }
563
564    pub fn plugin_image_path(&self, step: usize, group: usize, plugin: usize) -> Option<&str> {
565        self.inner.as_ref()?.plugin_image_path(step, group, plugin)
566    }
567
568    pub fn preview_plugin(
569        &self,
570        step: usize,
571        group: usize,
572        plugin: usize,
573    ) -> Vec<fomod_oxide::installer::FileOperation> {
574        match &self.inner {
575            Some(installer) => installer.preview_plugin(step, group, plugin),
576            None => vec![],
577        }
578    }
579
580    pub fn preview_current(&self) -> fomod_oxide::installer::InstallPlan {
581        match &self.inner {
582            Some(installer) => installer.preview_current(),
583            None => fomod_oxide::installer::InstallPlan {
584                operations: vec![],
585            },
586        }
587    }
588
589    pub fn is_ready_to_install(&self) -> bool {
590        match &self.inner {
591            Some(installer) => installer.is_ready_to_install(),
592            None => false,
593        }
594    }
595
596    pub fn group_type_at(
597        &self,
598        step: usize,
599        group: usize,
600    ) -> Option<fomod_oxide::config::GroupType> {
601        self.inner.as_ref()?.group_type_at(step, group)
602    }
603
604    pub fn select(&mut self, step: usize, group: usize, plugin_indices: Vec<usize>) {
605        if let Some(ref mut installer) = self.inner {
606            installer.select(step, group, plugin_indices);
607        }
608    }
609
610    pub fn checkpoint(&mut self) {
611        if let Some(ref mut installer) = self.inner {
612            installer.checkpoint();
613        }
614    }
615
616    pub fn rollback(&mut self) -> bool {
617        match &mut self.inner {
618            Some(installer) => installer.rollback(),
619            None => false,
620        }
621    }
622
623    pub fn history_len(&self) -> usize {
624        match &self.inner {
625            Some(installer) => installer.history_len(),
626            None => 0,
627        }
628    }
629
630    pub fn selections(&self) -> HashMap<(usize, usize), Vec<usize>> {
631        match &self.inner {
632            Some(installer) => installer.selections().clone(),
633            None => HashMap::new(),
634        }
635    }
636
637    pub fn detect_conflicts(&self) -> Vec<String> {
638        match &self.inner {
639            Some(installer) => installer
640                .detect_conflicts()
641                .into_iter()
642                .map(|c| c.destination)
643                .collect(),
644            None => vec![],
645        }
646    }
647
648    pub fn resolve(&self) -> fomod_oxide::installer::InstallPlan {
649        match &self.inner {
650            Some(installer) => installer.resolve(),
651            None => fomod_oxide::installer::InstallPlan {
652                operations: vec![],
653            },
654        }
655    }
656
657    pub fn default_selections(&self) -> Vec<(usize, usize, Vec<usize>)> {
658        let installer = match &self.inner {
659            Some(i) => i,
660            None => return vec![],
661        };
662        let visible = installer.visible_steps();
663        let mut defaults = Vec::new();
664        for &(step_idx, step) in &visible {
665            if let Some(ref groups) = step.optional_file_groups {
666                for (group_idx, group) in groups.groups.iter().enumerate() {
667                    let sel = fomod_oxide::Installer::default_selections(group);
668                    defaults.push((step_idx, group_idx, sel));
669                }
670            }
671        }
672        defaults
673    }
674}
675
676// ─── Application Messages ────────────────────────────────────────
677
678#[derive(Debug, Clone)]
679pub enum Message {
680    // Navigation
681    SwitchView(View),
682    SwitchProfile(String),
683    CreateProfile { name: String, game_id: String },
684    DeleteProfile(String),
685    ForkProfile { source: String, new_name: String },
686
687    // Profile dialog
688    NewProfileNameChanged(String),
689
690    // Game selection
691    SelectGame(String),
692
693    // Window controls (custom title bar)
694    GotWindowId(Option<window::Id>),
695    TitleBarDrag,
696    WindowMinimize,
697    WindowToggleMaximize,
698    WindowClose,
699
700    // Mod list
701    ToggleMod { mod_id: String, enabled: bool },
702    FilterChanged(String),
703    AddMod,
704    AddModFromPath(PathBuf),
705    RemoveMod(usize),
706    SelectMod(usize),
707    /// Initial Nexus v1 `get_mod` response for the selected mod. Carries
708    /// `nexus_mod_id` so stale responses (from a previous selection) are
709    /// discarded when they race a newer click.
710    ModDetailsLoaded {
711        nexus_mod_id: i64,
712        result: Result<modde_sources::nexus::api::NexusMod, String>,
713    },
714    /// Gallery image URL list returned by the v2 GraphQL endpoint.
715    ModGalleryLoaded {
716        nexus_mod_id: i64,
717        urls: Vec<String>,
718    },
719    /// Image bytes downloaded for a specific gallery slot. Guarded by both
720    /// `nexus_mod_id` and `gallery_index` so clicking through the gallery
721    /// rapidly doesn't let an old image overwrite a newer one.
722    ModThumbnailLoaded {
723        nexus_mod_id: i64,
724        gallery_index: usize,
725        bytes: Vec<u8>,
726    },
727    /// User clicked the thumbnail — advance to the next image in the gallery.
728    ModGalleryNext,
729    /// User clicked the "Open in Nexus" link.
730    OpenModPage,
731    Deploy,
732    DeployComplete(Result<String, String>),
733
734    // Load order
735    /// Move a specific mod up or down by one position. Mod-id-based (not
736    /// index-based) because the load_order view and mod_list view operate
737    /// on different index spaces — `resolved_order` vs. `profile.mods` —
738    /// and an index-based message was latently unsound. Also lets the
739    /// handler consult the per-mod lock without an index round-trip.
740    ReorderMod { mod_id: String, direction: ReorderDirection },
741    /// Pin an individual mod in place (per-mod lock).
742    LockMod { mod_id: String },
743    /// Release an individual mod's per-mod pin.
744    UnlockMod { mod_id: String },
745
746
747    // Collections
748    SearchCollections(String),
749    InstallCollection { slug: String, version: String },
750
751    // ── Browse Nexus (Phase 6) ───────────────────────────────
752    /// Switch the active browse tab. Fires a task to load the feed
753    /// for the new tab if its contents are empty.
754    BrowseTabSwitched(crate::views::browse_nexus::BrowseTab),
755    /// Live search box keystroke.
756    BrowseSearchChanged(String),
757    /// Submit the search (Enter pressed). Runs the appropriate query
758    /// depending on the active tab.
759    BrowseSearchSubmit,
760    /// Async result of a mods feed fetch.
761    BrowseModsLoaded(
762        Result<Vec<modde_sources::nexus::graphql::GqlModTile>, String>,
763    ),
764    /// Async result of a collections feed fetch.
765    BrowseCollectionsLoaded(
766        Result<Vec<modde_sources::nexus::graphql::GqlCollectionTile>, String>,
767    ),
768    /// User clicked "Install" on a mod tile. Runs the install
769    /// pipeline via `modde_sources::nexus::install::install_single_mod`.
770    BrowseInstallMod { game_domain: String, mod_id: u64 },
771    /// Async completion of a browse install. The `Ok` payload is a
772    /// short human-readable status message; `Err` is an error string.
773    BrowseInstallResult(Result<String, String>),
774
775    // Wabbajack
776    OpenWabbajackFile,
777    WabbajackFileSelected(PathBuf),
778    WabbajackProgress(f32),
779    WabbajackStartInstall,
780    WabbajackLog(String),
781
782    // FOMOD
783    StartFOMOD { mod_path: PathBuf, dest_path: PathBuf },
784    FOMODChoice {
785        step: usize,
786        group: usize,
787        option: usize,
788        selected: bool,
789    },
790    FOMODNext,
791    FOMODBack,
792    FOMODCancel,
793    FOMODUndo,
794    FOMODInstallComplete(Result<(), String>),
795
796    // Downloads
797    DownloadProgress { id: String, bytes: u64, total: u64 },
798    DownloadComplete { id: String },
799    DownloadFailed { id: String, error: String },
800
801    // Settings
802    SetNexusApiKey(String),
803    SetGamePath { game_id: String, path: PathBuf },
804    SetDownloadDir(PathBuf),
805    BrowseGamePath,
806    BrowseDownloadDir,
807    SetTheme(String),
808    ValidateNexusKey,
809    NexusKeyValidated(Result<(String, bool), String>),
810
811    // Stock game
812    CreateStockSnapshot,
813    StockSnapshotCreated(Result<String, String>),
814    VerifyStockSnapshot,
815    StockVerifyResult(Result<String, String>),
816
817    // Experiments
818    TryProfile,
819    RollbackExperiment,
820    CommitExperiment,
821
822    // Saves
823    LoadSaveHistory,
824    RestoreSaveSnapshot(String),
825    SelectSaveSnapshot(String),
826
827    // Verification
828    RunVerify,
829    VerifyComplete(VerifyResults),
830
831    // Mod list – category separators
832    ToggleSeparator(Option<i64>),
833
834    // Data tab
835    DataTabFilterChanged(String),
836    DataTabToggleConflicts(bool),
837
838    // Diagnostics
839    RunDiagnostics,
840
841    // Tools
842    RefreshTools,
843    ToggleTool { tool_id: String, enabled: bool },
844    ApplyTool(String),
845    RevertTool(String),
846
847    // Downloads
848    PauseDownload(usize),
849    ResumeDownload(usize),
850    CancelDownload(usize),
851
852    // Sidebar mod detail — Nexus interactions
853    /// User clicked the endorse/abstain toggle button.
854    ModEndorseToggle,
855    /// Async result of an endorse or abstain call. Carries the target
856    /// status the handler optimistically applied, so it can roll back if
857    /// the request failed.
858    ModEndorseResult {
859        nexus_mod_id: i64,
860        new_status: String,
861        result: Result<(), String>,
862    },
863    /// User clicked the track/untrack toggle button.
864    ModTrackToggle,
865    /// Async result of a track or untrack call.
866    ModTrackResult {
867        nexus_mod_id: i64,
868        new_tracked: bool,
869        result: Result<(), String>,
870    },
871    /// Async result of the initial `get_tracked_mods` call fired alongside
872    /// `get_mod` when a mod is selected.
873    ModTrackedSetLoaded {
874        nexus_mod_id: i64,
875        is_tracked: bool,
876    },
877
878    // Overwrite management
879    ClearOverwrite,
880    MoveOverwriteToMod(String),
881
882    // Mod list filter toolbar
883    ToggleFilterMode,
884    CycleFilter(FilterKind),
885    ClearFilters,
886    ToggleCompactModList,
887
888    // Misc
889    Noop,
890}
891
892// ─── Application Logic ──────────────────────────────────────────
893
894impl Modde {
895    fn new() -> (Self, Task<Message>) {
896        let settings = AppSettings::load();
897        let theme_name = if settings.theme.is_empty() {
898            "Dark".to_string()
899        } else {
900            settings.theme.clone()
901        };
902        let selected_game = settings.selected_game.clone();
903
904        let profiles = ProfileManager::open()
905            .and_then(|pm| pm.list())
906            .unwrap_or_default();
907
908        let available_games: SmallVec<[(String, String); 8]> = modde_games::supported_games()
909            .iter()
910            .map(|(id, name)| (id.to_string(), name.to_string()))
911            .collect();
912
913        let mut app = Self {
914            active_view: View::ModList,
915            active_profile: profiles.first().map(|p| p.name.clone()),
916            profiles,
917            status_message: "Ready".to_string(),
918            settings,
919            collection_search: String::new(),
920            collections: Vec::new(),
921            fomod_installer: None,
922            fomod_visible_step_indices: SmallVec::new(),
923            fomod_wizard_pos: 0,
924            fomod_source_dir: None,
925            fomod_dest_dir: None,
926            fomod_conflicts: SmallVec::new(),
927            fomod_can_undo: false,
928            fomod_selections: HashMap::new(),
929            selected_mod_index: None,
930            selected_mod_details: None,
931            mod_filter: String::new(),
932            theme_name,
933            wabbajack_manifest: None,
934            active_downloads: Vec::new(),
935            loaded_profile: None,
936            save_snapshots: Vec::new(),
937            current_fingerprint: None,
938            selected_save_details: None,
939            experiment_depth: 0,
940            nexus_status: None,
941            verify: VerifyState::Idle,
942            new_profile_name: String::new(),
943            available_games,
944            selected_game,
945            stock_snapshot_exists: false,
946            window_id: window::Id::unique(),
947            collapsed_categories: HashSet::new(),
948            mod_categories: vec![(None, "Uncategorized".to_string())],
949            data_tab_state: Default::default(),
950            data_tab_conflicts: Vec::new(),
951            diagnostics_state: Default::default(),
952            tool_state: Default::default(),
953            browse_nexus: Default::default(),
954            filter_mode: FilterMode::default(),
955            filter_criteria: vec![
956                FilterCriterion::new(FilterKind::Enabled),
957                FilterCriterion::new(FilterKind::HasNotes),
958                FilterCriterion::new(FilterKind::HasNexusId),
959            ],
960            compact_mod_list: false,
961        };
962
963        // Auto-detect: if no game is selected but profiles exist, pick the first profile's game
964        if app.selected_game.is_none() {
965            if let Some(first) = app.profiles.first() {
966                app.selected_game = Some(first.game_id.to_string());
967                app.settings.selected_game = Some(first.game_id.to_string());
968            }
969        }
970
971        // Auto-detect: if the selected game has no path in settings, try detect_install()
972        if let Some(ref game_id) = app.selected_game {
973            if app.settings.game_path(game_id).is_none() {
974                if let Some(plugin) = modde_games::resolve_game_plugin(game_id) {
975                    if let Some(path) = plugin.detect_install() {
976                        app.settings.set_game_path(game_id, path);
977                    }
978                }
979            }
980            app.save_settings();
981        }
982
983        app.reload_profile();
984        (app, window::oldest().map(Message::GotWindowId))
985    }
986
987    fn title(&self) -> String {
988        "modde".to_string()
989    }
990
991    fn update(&mut self, message: Message) -> Task<Message> {
992        match message {
993            // ── Navigation ───────────────────────────────────────
994            Message::SwitchView(view) => {
995                // Auto-load save history when switching to Saves view
996                if matches!(view, View::Saves) {
997                    self.active_view = view;
998                    return self.update(Message::LoadSaveHistory);
999                }
1000                self.active_view = view;
1001            }
1002            Message::SwitchProfile(name) => {
1003                self.active_profile = Some(name);
1004                self.reload_profile();
1005                self.selected_save_details = None;
1006                self.status_message = "Profile switched".to_string();
1007            }
1008            Message::CreateProfile { name, game_id } => {
1009                match ProfileManager::open() {
1010                    Ok(pm) => {
1011                        let profile = modde_core::Profile {
1012                            id: None,
1013                            name: name.clone(),
1014                            game_id: modde_core::GameId::from(game_id),
1015                            source: modde_core::ProfileSource::Manual,
1016                            mods: Vec::new(),
1017                            overrides: PathBuf::from("overrides"),
1018                            load_order_rules: smallvec::SmallVec::new(),
1019                            load_order_lock: None,
1020                        };
1021                        match pm.create(&profile) {
1022                            Ok(_) => {
1023                                self.profiles = pm.list().unwrap_or_default();
1024                                self.active_profile = Some(name);
1025                                self.reload_profile();
1026                                self.new_profile_name.clear();
1027                                self.status_message = "Profile created".to_string();
1028                            }
1029                            Err(e) => {
1030                                self.status_message = format!("Failed to create profile: {e}");
1031                            }
1032                        }
1033                    }
1034                    Err(e) => {
1035                        self.status_message = format!("Failed to open profile manager: {e}");
1036                    }
1037                }
1038            }
1039            Message::DeleteProfile(name) => {
1040                match ProfileManager::open() {
1041                    Ok(pm) => match pm.delete(&name, None) {
1042                        Ok(()) => {
1043                            self.profiles = pm.list().unwrap_or_default();
1044                            if self.active_profile.as_deref() == Some(&name) {
1045                                self.active_profile = self.profiles.first().map(|p| p.name.clone());
1046                                self.reload_profile();
1047                            }
1048                            self.status_message = format!("Profile '{name}' deleted");
1049                        }
1050                        Err(e) => self.status_message = format!("Failed to delete profile: {e}"),
1051                    },
1052                    Err(e) => self.status_message = format!("Error: {e}"),
1053                }
1054            }
1055            Message::ForkProfile { source, new_name } => {
1056                if let Some(ref profile) = self.loaded_profile {
1057                    let game_id = profile.game_id.clone();
1058                    match ProfileManager::open() {
1059                        Ok(pm) => match pm.fork(&source, &new_name, &game_id) {
1060                            Ok(_) => {
1061                                self.profiles = pm.list().unwrap_or_default();
1062                                self.active_profile = Some(new_name.clone());
1063                                self.reload_profile();
1064                                self.status_message = format!("Profile forked as '{new_name}'");
1065                            }
1066                            Err(e) => self.status_message = format!("Fork failed: {e}"),
1067                        },
1068                        Err(e) => self.status_message = format!("Error: {e}"),
1069                    }
1070                }
1071            }
1072
1073            // ── Profile dialog ───────────────────────────────────
1074            Message::NewProfileNameChanged(name) => self.new_profile_name = name,
1075
1076            // ── Game selection ────────────────────────────────────
1077            Message::SelectGame(game_id) => {
1078                self.selected_game = Some(game_id.clone());
1079                self.settings.selected_game = Some(game_id);
1080                self.save_settings();
1081            }
1082
1083            // ── Window controls (custom title bar) ───────────────
1084            Message::GotWindowId(Some(id)) => {
1085                self.window_id = id;
1086            }
1087            Message::GotWindowId(None) => {}
1088            Message::TitleBarDrag => {
1089                return window::drag(self.window_id);
1090            }
1091            Message::WindowMinimize => {
1092                return window::minimize(self.window_id, true);
1093            }
1094            Message::WindowToggleMaximize => {
1095                return window::toggle_maximize(self.window_id);
1096            }
1097            Message::WindowClose => {
1098                return window::close(self.window_id);
1099            }
1100
1101            // ── Mod list ─────────────────────────────────────────
1102            Message::ToggleMod { mod_id, enabled } => {
1103                if let Some(ref profile_name) = self.active_profile {
1104                    if let Ok(pm) = ProfileManager::open() {
1105                        if let Ok(mut profile) = pm.load(profile_name, None) {
1106                            if let Some(m) = profile.mods.iter_mut().find(|m| m.mod_id == mod_id) {
1107                                m.enabled = enabled;
1108                            }
1109                            let _ = pm.create(&profile).or_else(|_| pm.update(&profile).map(|_| 0));
1110                            self.status_message = format!(
1111                                "Mod {mod_id} {}",
1112                                if enabled { "enabled" } else { "disabled" }
1113                            );
1114                            self.reload_profile();
1115                        }
1116                    }
1117                }
1118            }
1119            Message::FilterChanged(filter) => self.mod_filter = filter,
1120            Message::ToggleFilterMode => {
1121                self.filter_mode = self.filter_mode.toggle();
1122            }
1123            Message::CycleFilter(kind) => {
1124                if let Some(c) = self.filter_criteria.iter_mut().find(|c| c.kind == kind) {
1125                    c.state = c.state.cycle();
1126                }
1127            }
1128            Message::ClearFilters => {
1129                for c in &mut self.filter_criteria {
1130                    c.state = modde_core::filter::TriState::Ignore;
1131                }
1132            }
1133            Message::ToggleCompactModList => {
1134                self.compact_mod_list = !self.compact_mod_list;
1135            }
1136            Message::ToggleSeparator(cat_id) => {
1137                if !self.collapsed_categories.remove(&cat_id) {
1138                    self.collapsed_categories.insert(cat_id);
1139                }
1140            }
1141            Message::AddMod => {
1142                return Task::perform(
1143                    async {
1144                        rfd::AsyncFileDialog::new()
1145                            .set_title("Select Mod Archive or Directory")
1146                            .add_filter("Archives", &["zip", "7z", "rar"])
1147                            .pick_file()
1148                            .await
1149                            .map(|h| h.path().to_path_buf())
1150                    },
1151                    |path| match path {
1152                        Some(p) => Message::AddModFromPath(p),
1153                        None => Message::Noop,
1154                    },
1155                );
1156            }
1157            Message::AddModFromPath(path) => {
1158                if let Some(ref profile_name) = self.active_profile {
1159                    let mod_name = path
1160                        .file_stem()
1161                        .map(|s| s.to_string_lossy().to_string())
1162                        .unwrap_or_else(|| "unknown-mod".to_string());
1163
1164                    if let Ok(pm) = ProfileManager::open() {
1165                        if let Ok(mut profile) = pm.load(profile_name, None) {
1166                            profile.mods.push(modde_core::EnabledMod {
1167                                mod_id: mod_name.clone(),
1168                                enabled: true,
1169                                ..Default::default()
1170                            });
1171                            let _ = pm.create(&profile).or_else(|_| pm.update(&profile).map(|_| 0));
1172                            self.status_message = format!("Added mod: {mod_name}");
1173                            self.reload_profile();
1174                        }
1175                    }
1176                } else {
1177                    self.status_message = "No active profile — create one first".to_string();
1178                }
1179            }
1180            Message::RemoveMod(index) => {
1181                if let Some(ref profile_name) = self.active_profile {
1182                    if let Ok(pm) = ProfileManager::open() {
1183                        if let Ok(mut profile) = pm.load(profile_name, None) {
1184                            if index < profile.mods.len() {
1185                                let removed = profile.mods.remove(index);
1186                                let _ = pm.create(&profile).or_else(|_| pm.update(&profile).map(|_| 0));
1187                                self.selected_mod_index = None;
1188                                self.status_message = format!("Removed mod: {}", removed.mod_id);
1189                                self.reload_profile();
1190                            }
1191                        }
1192                    }
1193                }
1194            }
1195            Message::SelectMod(index) => {
1196                self.selected_mod_index = Some(index);
1197
1198                // Look up the selected mod and, if it carries Nexus metadata,
1199                // kick off an async fetch for its full details. Otherwise
1200                // clear the detail panel.
1201                //
1202                // Nexus game domains are canonically lowercase (e.g.
1203                // "cyberpunk2077"). Historical DB records came from URL path
1204                // segments and may be mixed-case, which causes Nexus v1 to
1205                // return 401 Unauthorized rather than 404 — so we lowercase
1206                // defensively before any API call.
1207                let nexus_info = self
1208                    .loaded_profile
1209                    .as_ref()
1210                    .and_then(|p| p.mods.get(index))
1211                    .and_then(|m| {
1212                        let nid = m.nexus_mod_id?;
1213                        let domain = m.nexus_game_domain.clone()?.to_lowercase();
1214                        Some((
1215                            nid,
1216                            domain,
1217                            m.display_name
1218                                .clone()
1219                                .unwrap_or_else(|| m.mod_id.clone()),
1220                            m.version.clone().unwrap_or_default(),
1221                        ))
1222                    });
1223
1224                match nexus_info {
1225                    Some((nexus_mod_id, game_domain, name, version)) => {
1226                        self.selected_mod_details =
1227                            Some(crate::views::mod_details::ModDetailsState::loading(
1228                                nexus_mod_id,
1229                                game_domain.clone(),
1230                                name,
1231                                version,
1232                            ));
1233
1234                        return Task::perform(
1235                            async move {
1236                                let api_key = modde_sources::nexus::auth::load_api_key()
1237                                    .map_err(|e| e.to_string())?;
1238                                let client = reqwest::Client::new();
1239                                let api = modde_sources::nexus::api::NexusApi::new(
1240                                    client, api_key,
1241                                );
1242                                api.get_mod(&game_domain, nexus_mod_id as u64)
1243                                    .await
1244                                    .map_err(|e| e.to_string())
1245                            },
1246                            move |result| Message::ModDetailsLoaded {
1247                                nexus_mod_id,
1248                                result,
1249                            },
1250                        );
1251                    }
1252                    None => {
1253                        self.selected_mod_details = None;
1254                    }
1255                }
1256            }
1257            Message::ModDetailsLoaded { nexus_mod_id, result } => {
1258                // Guard against stale responses for a previous selection.
1259                let matches = self
1260                    .selected_mod_details
1261                    .as_ref()
1262                    .is_some_and(|s| s.nexus_mod_id == nexus_mod_id);
1263                if !matches {
1264                    return Task::none();
1265                }
1266
1267                match result {
1268                    Ok(nexus_mod) => {
1269                        let picture_url = nexus_mod.picture_url.clone();
1270                        let game_domain = self
1271                            .selected_mod_details
1272                            .as_ref()
1273                            .map(|s| s.game_domain.clone())
1274                            .unwrap_or_default();
1275
1276                        if let Some(ref mut s) = self.selected_mod_details {
1277                            s.loading = false;
1278                            s.name = nexus_mod.name;
1279                            s.author = nexus_mod.author;
1280                            s.version = nexus_mod.version;
1281                            s.summary = nexus_mod.summary;
1282                            s.endorse_status = nexus_mod
1283                                .endorsement
1284                                .as_ref()
1285                                .map(|e| e.endorse_status.clone());
1286                            s.endorsement_count = nexus_mod.endorsement_count;
1287                            if let Some(ref url) = picture_url {
1288                                s.gallery = vec![url.clone()];
1289                                s.gallery_index = 0;
1290                            }
1291                        }
1292
1293                        // Fire follow-up fetches: (a) primary picture bytes,
1294                        // (b) full gallery via GraphQL. Both are best-effort.
1295                        // Key is loaded via `auth::load_api_key` inside the
1296                        // task so OAuth/keyring/env are all honored.
1297                        let mut tasks: Vec<Task<Message>> = Vec::new();
1298
1299                        if let Some(url) = picture_url {
1300                            tasks.push(Task::perform(
1301                                async move {
1302                                    let api_key =
1303                                        modde_sources::nexus::auth::load_api_key().ok()?;
1304                                    let client = reqwest::Client::new();
1305                                    let api = modde_sources::nexus::api::NexusApi::new(
1306                                        client, api_key,
1307                                    );
1308                                    api.fetch_bytes(&url).await.ok()
1309                                },
1310                                move |bytes_opt| match bytes_opt {
1311                                    Some(bytes) => Message::ModThumbnailLoaded {
1312                                        nexus_mod_id,
1313                                        gallery_index: 0,
1314                                        bytes,
1315                                    },
1316                                    None => Message::Noop,
1317                                },
1318                            ));
1319                        }
1320
1321                        if !game_domain.is_empty() {
1322                            let domain = game_domain.clone();
1323                            tasks.push(Task::perform(
1324                                async move {
1325                                    let api_key =
1326                                        modde_sources::nexus::auth::load_api_key()
1327                                            .unwrap_or_default();
1328                                    if api_key.is_empty() {
1329                                        return Vec::new();
1330                                    }
1331                                    let client = reqwest::Client::new();
1332                                    let api = modde_sources::nexus::api::NexusApi::new(
1333                                        client, api_key,
1334                                    );
1335                                    api.get_mod_media(&domain, nexus_mod_id as u64)
1336                                        .await
1337                                        .unwrap_or_default()
1338                                },
1339                                move |urls| Message::ModGalleryLoaded {
1340                                    nexus_mod_id,
1341                                    urls,
1342                                },
1343                            ));
1344                        }
1345
1346                        // Check whether the current user is tracking this
1347                        // mod. The v1 endpoint is not filterable by mod_id,
1348                        // so we download the full tracked list and filter.
1349                        // Best-effort — on failure, the Track button stays
1350                        // disabled (is_tracked = None).
1351                        if !game_domain.is_empty() {
1352                            let domain = game_domain.clone();
1353                            tasks.push(Task::perform(
1354                                async move {
1355                                    let api_key =
1356                                        modde_sources::nexus::auth::load_api_key().ok()?;
1357                                    let client = reqwest::Client::new();
1358                                    let api = modde_sources::nexus::api::NexusApi::new(
1359                                        client, api_key,
1360                                    );
1361                                    let list = api.get_tracked_mods().await.ok()?;
1362                                    let target = nexus_mod_id as u64;
1363                                    Some(list.iter().any(|t| {
1364                                        t.mod_id == target
1365                                            && t.domain_name.eq_ignore_ascii_case(&domain)
1366                                    }))
1367                                },
1368                                move |is_tracked_opt| match is_tracked_opt {
1369                                    Some(is_tracked) => Message::ModTrackedSetLoaded {
1370                                        nexus_mod_id,
1371                                        is_tracked,
1372                                    },
1373                                    None => Message::Noop,
1374                                },
1375                            ));
1376                        }
1377
1378                        return Task::batch(tasks);
1379                    }
1380                    Err(e) => {
1381                        if let Some(ref mut s) = self.selected_mod_details {
1382                            s.loading = false;
1383                            s.error = Some(e);
1384                        }
1385                    }
1386                }
1387            }
1388            Message::ModGalleryLoaded { nexus_mod_id, urls } => {
1389                let Some(ref mut s) = self.selected_mod_details else {
1390                    return Task::none();
1391                };
1392                if s.nexus_mod_id != nexus_mod_id {
1393                    return Task::none();
1394                }
1395                if urls.is_empty() {
1396                    return Task::none();
1397                }
1398
1399                // Merge: keep the existing picture_url (gallery[0]) as the
1400                // first entry so the already-fetched thumbnail stays valid,
1401                // then append any gallery URLs not already in the list.
1402                let mut merged: Vec<String> = s.gallery.clone();
1403                for url in urls {
1404                    if !merged.contains(&url) {
1405                        merged.push(url);
1406                    }
1407                }
1408                s.gallery = merged;
1409            }
1410            Message::ModThumbnailLoaded {
1411                nexus_mod_id,
1412                gallery_index,
1413                bytes,
1414            } => {
1415                let Some(ref mut s) = self.selected_mod_details else {
1416                    return Task::none();
1417                };
1418                if s.nexus_mod_id != nexus_mod_id || s.gallery_index != gallery_index {
1419                    return Task::none();
1420                }
1421                s.thumbnail = Some(resize_thumbnail_bytes(&bytes));
1422            }
1423            Message::ModGalleryNext => {
1424                let (nexus_mod_id, next_index, url) = {
1425                    let Some(ref mut s) = self.selected_mod_details else {
1426                        return Task::none();
1427                    };
1428                    if s.gallery.len() < 2 {
1429                        return Task::none();
1430                    }
1431                    s.gallery_index = (s.gallery_index + 1) % s.gallery.len();
1432                    s.thumbnail = None;
1433                    let url = s.gallery[s.gallery_index].clone();
1434                    (s.nexus_mod_id, s.gallery_index, url)
1435                };
1436                return Task::perform(
1437                    async move {
1438                        let api_key = modde_sources::nexus::auth::load_api_key().ok()?;
1439                        let client = reqwest::Client::new();
1440                        let api =
1441                            modde_sources::nexus::api::NexusApi::new(client, api_key);
1442                        api.fetch_bytes(&url).await.ok()
1443                    },
1444                    move |bytes_opt| match bytes_opt {
1445                        Some(bytes) => Message::ModThumbnailLoaded {
1446                            nexus_mod_id,
1447                            gallery_index: next_index,
1448                            bytes,
1449                        },
1450                        None => Message::Noop,
1451                    },
1452                );
1453            }
1454            Message::OpenModPage => {
1455                if let Some(ref s) = self.selected_mod_details {
1456                    let url = s.mod_page_url.clone();
1457                    // Surface the URL in the status bar so the user can
1458                    // verify exactly what's being passed to the browser
1459                    // (useful for diagnosing case-sensitivity issues with
1460                    // historical capitalized DB records).
1461                    self.status_message = format!("Opening: {url}");
1462                    tracing::info!(url = %url, "opening mod page in browser");
1463                    // Spawn on blocking pool — `open::that` forks xdg-open
1464                    // and usually returns quickly, but we don't want any
1465                    // chance of stalling the UI event loop.
1466                    return Task::perform(
1467                        async move {
1468                            let _ = tokio::task::spawn_blocking(move || {
1469                                let _ = open::that(&url);
1470                            })
1471                            .await;
1472                        },
1473                        |_| Message::Noop,
1474                    );
1475                }
1476            }
1477            Message::ModEndorseToggle => {
1478                // Snapshot what we need from state and optimistically
1479                // flip the UI before the API request returns.
1480                let Some(ref mut s) = self.selected_mod_details else {
1481                    return Task::none();
1482                };
1483                if s.action_pending {
1484                    return Task::none();
1485                }
1486                let nexus_mod_id = s.nexus_mod_id;
1487                let game_domain = s.game_domain.clone();
1488                let version = s.version.clone();
1489                let was_endorsed = s.endorse_status.as_deref() == Some("Endorsed");
1490                let new_status = if was_endorsed { "Abstained" } else { "Endorsed" };
1491                s.endorse_status = Some(new_status.to_string());
1492                // Adjust the visible total: +1 when going to Endorsed, -1
1493                // when leaving it. `saturating_sub` guards against weirdness
1494                // if the count happens to be 0.
1495                if was_endorsed {
1496                    s.endorsement_count = s.endorsement_count.saturating_sub(1);
1497                } else {
1498                    s.endorsement_count = s.endorsement_count.saturating_add(1);
1499                }
1500                s.action_pending = true;
1501
1502                let target_status = new_status.to_string();
1503                return Task::perform(
1504                    async move {
1505                        let api_key = modde_sources::nexus::auth::load_api_key()
1506                            .map_err(|e| e.to_string())?;
1507                        let client = reqwest::Client::new();
1508                        let api =
1509                            modde_sources::nexus::api::NexusApi::new(client, api_key);
1510                        if was_endorsed {
1511                            api.abstain_mod(&game_domain, nexus_mod_id as u64, &version)
1512                                .await
1513                                .map_err(|e| e.to_string())
1514                        } else {
1515                            api.endorse_mod(&game_domain, nexus_mod_id as u64, &version)
1516                                .await
1517                                .map_err(|e| e.to_string())
1518                        }
1519                    },
1520                    move |result| Message::ModEndorseResult {
1521                        nexus_mod_id,
1522                        new_status: target_status.clone(),
1523                        result,
1524                    },
1525                );
1526            }
1527            Message::ModEndorseResult {
1528                nexus_mod_id,
1529                new_status,
1530                result,
1531            } => {
1532                let Some(ref mut s) = self.selected_mod_details else {
1533                    return Task::none();
1534                };
1535                if s.nexus_mod_id != nexus_mod_id {
1536                    return Task::none();
1537                }
1538                s.action_pending = false;
1539                match result {
1540                    Ok(()) => {
1541                        // Optimistic state already matches — nothing to do.
1542                        self.status_message = if new_status == "Endorsed" {
1543                            "Endorsed on Nexus".to_string()
1544                        } else {
1545                            "Endorsement withdrawn".to_string()
1546                        };
1547                    }
1548                    Err(e) => {
1549                        // Roll back the optimistic update.
1550                        let reverted = if new_status == "Endorsed" {
1551                            "Abstained"
1552                        } else {
1553                            "Endorsed"
1554                        };
1555                        s.endorse_status = Some(reverted.to_string());
1556                        if new_status == "Endorsed" {
1557                            s.endorsement_count = s.endorsement_count.saturating_sub(1);
1558                        } else {
1559                            s.endorsement_count = s.endorsement_count.saturating_add(1);
1560                        }
1561                        self.status_message = format!("Endorse failed: {e}");
1562                    }
1563                }
1564            }
1565            Message::ModTrackToggle => {
1566                let Some(ref mut s) = self.selected_mod_details else {
1567                    return Task::none();
1568                };
1569                if s.action_pending {
1570                    return Task::none();
1571                }
1572                let nexus_mod_id = s.nexus_mod_id;
1573                let game_domain = s.game_domain.clone();
1574                // If is_tracked is None (not yet fetched), assume not
1575                // tracked — clicking Track will try to track, and the
1576                // result is idempotent enough on the server side.
1577                let was_tracked = s.is_tracked.unwrap_or(false);
1578                let new_tracked = !was_tracked;
1579                s.is_tracked = Some(new_tracked);
1580                s.action_pending = true;
1581
1582                return Task::perform(
1583                    async move {
1584                        let api_key = modde_sources::nexus::auth::load_api_key()
1585                            .map_err(|e| e.to_string())?;
1586                        let client = reqwest::Client::new();
1587                        let api =
1588                            modde_sources::nexus::api::NexusApi::new(client, api_key);
1589                        if was_tracked {
1590                            api.untrack_mod(&game_domain, nexus_mod_id as u64)
1591                                .await
1592                                .map_err(|e| e.to_string())
1593                        } else {
1594                            api.track_mod(&game_domain, nexus_mod_id as u64)
1595                                .await
1596                                .map_err(|e| e.to_string())
1597                        }
1598                    },
1599                    move |result| Message::ModTrackResult {
1600                        nexus_mod_id,
1601                        new_tracked,
1602                        result,
1603                    },
1604                );
1605            }
1606            Message::ModTrackResult {
1607                nexus_mod_id,
1608                new_tracked,
1609                result,
1610            } => {
1611                let Some(ref mut s) = self.selected_mod_details else {
1612                    return Task::none();
1613                };
1614                if s.nexus_mod_id != nexus_mod_id {
1615                    return Task::none();
1616                }
1617                s.action_pending = false;
1618                match result {
1619                    Ok(()) => {
1620                        self.status_message = if new_tracked {
1621                            "Now tracking on Nexus".to_string()
1622                        } else {
1623                            "Stopped tracking".to_string()
1624                        };
1625                    }
1626                    Err(e) => {
1627                        // Roll back the optimistic flip.
1628                        s.is_tracked = Some(!new_tracked);
1629                        self.status_message = format!("Track toggle failed: {e}");
1630                    }
1631                }
1632            }
1633            Message::ModTrackedSetLoaded {
1634                nexus_mod_id,
1635                is_tracked,
1636            } => {
1637                if let Some(ref mut s) = self.selected_mod_details {
1638                    if s.nexus_mod_id == nexus_mod_id {
1639                        s.is_tracked = Some(is_tracked);
1640                    }
1641                }
1642            }
1643            Message::Deploy => {
1644                self.status_message = "Deploying mods...".to_string();
1645                if let Some(ref profile) = self.loaded_profile {
1646                    let profile_name = profile.name.clone();
1647                    let game_id = profile.game_id.clone();
1648                    return Task::perform(
1649                        async move {
1650                            tokio::task::spawn_blocking(move || -> Result<String, String> {
1651                                let pm = ProfileManager::open().map_err(|e| e.to_string())?;
1652                                let profile = pm.load(&profile_name, Some(&game_id)).map_err(|e| e.to_string())?;
1653                                let resolved = modde_core::resolver::resolve(&profile).map_err(|e| e.to_string())?;
1654                                let game_plugin = modde_games::resolve_game_plugin(&game_id)
1655                                    .ok_or_else(|| format!("unsupported game: {game_id}"))?;
1656                                let install_path = game_plugin.detect_install()
1657                                    .ok_or_else(|| format!("could not detect install for {game_id}"))?;
1658                                let mod_dir = game_plugin.mod_directory(&install_path);
1659                                let staging_dir = ProfileManager::staging_dir(&profile.name);
1660                                game_plugin.deploy(&staging_dir, &mod_dir).map_err(|e| e.to_string())?;
1661                                game_plugin.post_deploy(&install_path).map_err(|e| e.to_string())?;
1662                                Ok(format!("Deployed {} mod(s) for {}", resolved.order.len(), game_id))
1663                            }).await.map_err(|e| e.to_string())?
1664                        },
1665                        Message::DeployComplete,
1666                    );
1667                }
1668            }
1669            Message::DeployComplete(result) => match result {
1670                Ok(msg) => self.status_message = msg,
1671                Err(e) => self.status_message = format!("Deploy failed: {e}"),
1672            },
1673
1674            // ── Load order ───────────────────────────────────────
1675            Message::ReorderMod { mod_id, direction } => {
1676                let Some(ref profile_name) = self.active_profile else {
1677                    return Task::none();
1678                };
1679                let Ok(pm) = ProfileManager::open() else {
1680                    self.status_message = "Failed to open profile database".to_string();
1681                    return Task::none();
1682                };
1683                let Ok(mut profile) = pm.load(profile_name, None) else {
1684                    return Task::none();
1685                };
1686
1687                // All enforcement lives in `modde_core::profile::try_reorder`
1688                // — profile lock, per-mod pin, adjacent pin, boundary. The
1689                // UI just translates refusal reasons into status messages.
1690                use modde_core::profile::{try_reorder, ReorderError};
1691                match try_reorder(&mut profile, &mod_id, direction) {
1692                    Ok(()) => {
1693                        let _ = pm
1694                            .create(&profile)
1695                            .or_else(|_| pm.update(&profile).map(|_| 0));
1696                        self.status_message = format!(
1697                            "Moved '{mod_id}' {}",
1698                            match direction {
1699                                ReorderDirection::Up => "up",
1700                                ReorderDirection::Down => "down",
1701                            }
1702                        );
1703                        self.reload_profile();
1704                    }
1705                    Err(ReorderError::ProfileLocked { reason }) => {
1706                        self.status_message = format!(
1707                            "Load order is locked by {} — unlock the profile to reorder.",
1708                            format_lock_reason(&reason)
1709                        );
1710                    }
1711                    Err(ReorderError::ModPinned { mod_id: mid, reason }) => {
1712                        self.status_message = format!(
1713                            "'{mid}' is pinned ({}) — unpin it to reorder.",
1714                            format_lock_reason(&reason)
1715                        );
1716                    }
1717                    Err(ReorderError::AdjacentPinned { neighbor_id, .. }) => {
1718                        self.status_message =
1719                            format!("Cannot move past a pinned mod ('{neighbor_id}').");
1720                    }
1721                    Err(ReorderError::ModNotFound { mod_id: mid }) => {
1722                        self.status_message = format!("Mod not found in profile: {mid}");
1723                    }
1724                    Err(ReorderError::AtBoundary) => {
1725                        // Silent — the view shouldn't have offered the
1726                        // button, but if we got here anyway it's a no-op.
1727                    }
1728                }
1729            }
1730
1731            Message::LockMod { mod_id } => {
1732                let Some(ref profile_name) = self.active_profile else {
1733                    return Task::none();
1734                };
1735                let Ok(pm) = ProfileManager::open() else {
1736                    return Task::none();
1737                };
1738                let Ok(mut profile) = pm.load(profile_name, None) else {
1739                    return Task::none();
1740                };
1741                if let Some(m) = profile.mods.iter_mut().find(|m| m.mod_id == mod_id) {
1742                    m.lock = Some(modde_core::LockReason::Manual { note: None });
1743                    if pm.update(&profile).is_ok() {
1744                        self.status_message = format!("Pinned '{mod_id}'");
1745                        self.reload_profile();
1746                    }
1747                }
1748            }
1749            Message::UnlockMod { mod_id } => {
1750                let Some(ref profile_name) = self.active_profile else {
1751                    return Task::none();
1752                };
1753                let Ok(pm) = ProfileManager::open() else {
1754                    return Task::none();
1755                };
1756                let Ok(mut profile) = pm.load(profile_name, None) else {
1757                    return Task::none();
1758                };
1759                if let Some(m) = profile.mods.iter_mut().find(|m| m.mod_id == mod_id) {
1760                    m.lock = None;
1761                    if pm.update(&profile).is_ok() {
1762                        self.status_message = format!("Unpinned '{mod_id}'");
1763                        self.reload_profile();
1764                    }
1765                }
1766            }
1767
1768            // ── Collections ──────────────────────────────────────
1769            Message::SearchCollections(query) => {
1770                self.collection_search = query.clone();
1771                if query.is_empty() {
1772                    self.collections = Vec::new();
1773                    return Task::none();
1774                }
1775                self.status_message = "Searching collections...".to_string();
1776                return Task::perform(
1777                    async move {
1778                        Ok::<Vec<CollectionManifest>, anyhow::Error>(Vec::new())
1779                    },
1780                    |result| match result {
1781                        Ok(_) => Message::Noop,
1782                        Err(_) => Message::Noop,
1783                    },
1784                );
1785            }
1786            Message::InstallCollection { slug, version } => {
1787                self.status_message = format!("Installing collection {slug} v{version}...");
1788            }
1789
1790            // ── Browse Nexus (Phase 6) ───────────────────────────
1791            Message::BrowseTabSwitched(tab) => {
1792                self.browse_nexus.active_tab = tab;
1793                self.browse_nexus.error = None;
1794                let domain = match self.current_game_nexus_domain() {
1795                    Some(d) => d,
1796                    None => return Task::none(),
1797                };
1798                return self.spawn_browse_load(tab, domain, self.browse_nexus.search_query.clone());
1799            }
1800            Message::BrowseSearchChanged(query) => {
1801                self.browse_nexus.search_query = query;
1802            }
1803            Message::BrowseSearchSubmit => {
1804                self.browse_nexus.active_tab =
1805                    crate::views::browse_nexus::BrowseTab::Search;
1806                self.browse_nexus.error = None;
1807                let domain = match self.current_game_nexus_domain() {
1808                    Some(d) => d,
1809                    None => return Task::none(),
1810                };
1811                let tab = self.browse_nexus.active_tab;
1812                let query = self.browse_nexus.search_query.clone();
1813                return self.spawn_browse_load(tab, domain, query);
1814            }
1815            Message::BrowseModsLoaded(result) => {
1816                self.browse_nexus.loading = false;
1817                match result {
1818                    Ok(mods) => {
1819                        self.browse_nexus.mods = mods;
1820                        self.browse_nexus.error = None;
1821                    }
1822                    Err(e) => {
1823                        self.browse_nexus.mods.clear();
1824                        self.browse_nexus.error = Some(e);
1825                    }
1826                }
1827            }
1828            Message::BrowseCollectionsLoaded(result) => {
1829                self.browse_nexus.loading = false;
1830                match result {
1831                    Ok(cols) => {
1832                        self.browse_nexus.collections = cols;
1833                        self.browse_nexus.error = None;
1834                    }
1835                    Err(e) => {
1836                        self.browse_nexus.collections.clear();
1837                        self.browse_nexus.error = Some(e);
1838                    }
1839                }
1840            }
1841            Message::BrowseInstallMod { game_domain, mod_id } => {
1842                self.browse_nexus.install_status =
1843                    Some(format!("Installing mod {mod_id}…"));
1844                return Task::perform(
1845                    async move {
1846                        run_browse_install(game_domain, mod_id).await
1847                    },
1848                    Message::BrowseInstallResult,
1849                );
1850            }
1851            Message::BrowseInstallResult(result) => {
1852                match result {
1853                    Ok(msg) => {
1854                        self.browse_nexus.install_status = Some(msg.clone());
1855                        self.status_message = msg;
1856                        self.reload_profile();
1857                    }
1858                    Err(e) => {
1859                        self.browse_nexus.install_status = Some(format!("Install failed: {e}"));
1860                        self.status_message = format!("Install failed: {e}");
1861                    }
1862                }
1863            }
1864
1865            // ── Wabbajack ────────────────────────────────────────
1866            Message::OpenWabbajackFile => {
1867                return Task::perform(
1868                    async {
1869                        rfd::AsyncFileDialog::new()
1870                            .set_title("Select .wabbajack File")
1871                            .add_filter("Wabbajack", &["wabbajack"])
1872                            .pick_file()
1873                            .await
1874                            .map(|h| h.path().to_path_buf())
1875                    },
1876                    |path| match path {
1877                        Some(p) => Message::WabbajackFileSelected(p),
1878                        None => Message::Noop,
1879                    },
1880                );
1881            }
1882            Message::WabbajackFileSelected(path) => {
1883                let manifest = (|| -> Option<modde_core::WabbajackManifest> {
1884                    let file = std::fs::File::open(&path).ok()?;
1885                    let mut archive = zip::ZipArchive::new(file).ok()?;
1886                    let name = if archive.file_names().any(|n| n == "modlist.json") { "modlist.json" } else { "modlist" };
1887                    let mut entry = archive.by_name(name).ok()?;
1888                    let mut buf = String::new();
1889                    std::io::Read::read_to_string(&mut entry, &mut buf).ok()?;
1890                    serde_json::from_str(&buf).ok()
1891                })();
1892                self.wabbajack_manifest = manifest;
1893                self.active_view = View::WabbajackInstaller(WabbajackInstallerState {
1894                    file_path: Some(path.clone()),
1895                    progress: 0.0,
1896                    status: format!("Selected: {}", path.display()),
1897                    log_lines: vec![format!("File selected: {}", path.display())],
1898                });
1899                self.status_message = format!("Wabbajack file loaded: {}", path.display());
1900            }
1901            Message::WabbajackProgress(progress) => {
1902                if let View::WabbajackInstaller(ref mut state) = self.active_view {
1903                    state.progress = progress;
1904                    state.status = format!("{:.0}% complete", progress * 100.0);
1905                }
1906            }
1907            Message::WabbajackStartInstall => {
1908                if let View::WabbajackInstaller(ref mut state) = self.active_view {
1909                    if let Some(ref wj_path) = state.file_path {
1910                        state.status = "Starting installation...".to_string();
1911                        state.log_lines.push("Installation started".to_string());
1912                        state.progress = 0.0;
1913                        let path = wj_path.clone();
1914                        return Task::perform(
1915                            async move {
1916                                let file = std::fs::File::open(&path)?;
1917                                let mut archive = zip::ZipArchive::new(file)?;
1918                                let manifest: modde_core::WabbajackManifest = {
1919                                    let name = if archive.file_names().any(|n| n == "modlist.json") { "modlist.json" } else { "modlist" };
1920                                    let mut entry = archive.by_name(name)?;
1921                                    let mut buf = String::new();
1922                                    std::io::Read::read_to_string(&mut entry, &mut buf)?;
1923                                    serde_json::from_str(&buf)?
1924                                };
1925                                Ok::<String, anyhow::Error>(manifest.name)
1926                            },
1927                            |result: Result<String, anyhow::Error>| match result {
1928                                Ok(name) => Message::WabbajackLog(format!("Parsed manifest: {name}")),
1929                                Err(e) => Message::WabbajackLog(format!("Error: {e}")),
1930                            },
1931                        );
1932                    }
1933                }
1934                self.status_message = "No wabbajack file selected".to_string();
1935            }
1936            Message::WabbajackLog(line) => {
1937                if let View::WabbajackInstaller(ref mut state) = self.active_view {
1938                    state.log_lines.push(line.clone());
1939                    state.status = line;
1940                }
1941            }
1942
1943            // ── FOMOD ────────────────────────────────────────────
1944            Message::StartFOMOD { mod_path, dest_path } => {
1945                let config_path = mod_path.join("fomod").join("ModuleConfig.xml");
1946                let xml = match std::fs::read_to_string(&config_path) {
1947                    Ok(xml) => xml,
1948                    Err(e) => {
1949                        self.status_message = format!("Failed to read ModuleConfig.xml: {e}");
1950                        return Task::none();
1951                    }
1952                };
1953                let config = match fomod_oxide::ModuleConfig::parse(&xml) {
1954                    Ok(c) => c,
1955                    Err(e) => {
1956                        self.status_message = format!("Failed to parse ModuleConfig.xml: {e}");
1957                        return Task::none();
1958                    }
1959                };
1960                let installer = fomod_oxide::Installer::new(config);
1961                let mut state = FOMODWizardState::with_installer(installer);
1962                let defaults = state.default_selections();
1963                self.fomod_selections.clear();
1964                for (step_idx, group_idx, sel) in defaults {
1965                    state.select(step_idx, group_idx, sel.clone());
1966                    self.fomod_selections.insert((step_idx, group_idx), sel);
1967                }
1968                self.fomod_installer = Some(state);
1969                self.fomod_source_dir = Some(mod_path);
1970                self.fomod_dest_dir = Some(dest_path);
1971                self.fomod_wizard_pos = 0;
1972                self.fomod_can_undo = false;
1973                self.refresh_fomod_visible_steps();
1974                self.refresh_fomod_conflicts();
1975                self.active_view = View::FOMODWizard(FOMODWizardState::new());
1976                self.status_message = "FOMOD wizard started".to_string();
1977            }
1978            Message::FOMODChoice { step, group, option, selected } => {
1979                if let Some(ref mut installer) = self.fomod_installer {
1980                    installer.checkpoint();
1981                    self.fomod_can_undo = true;
1982                    let group_type = installer.group_type_at(step, group);
1983                    let entry = self.fomod_selections.entry((step, group)).or_default();
1984                    match group_type {
1985                        Some(fomod_oxide::config::GroupType::SelectExactlyOne)
1986                        | Some(fomod_oxide::config::GroupType::SelectAtMostOne) => {
1987                            if selected { *entry = vec![option]; } else { entry.retain(|&o| o != option); }
1988                        }
1989                        Some(fomod_oxide::config::GroupType::SelectAll) => {}
1990                        _ => {
1991                            if selected { if !entry.contains(&option) { entry.push(option); } } else { entry.retain(|&o| o != option); }
1992                        }
1993                    }
1994                    let current_sel = entry.clone();
1995                    installer.select(step, group, current_sel);
1996                    self.refresh_fomod_visible_steps();
1997                    self.refresh_fomod_conflicts();
1998                }
1999            }
2000            Message::FOMODNext => {
2001                if self.fomod_is_last_step() {
2002                    let result = (|| -> Result<(), String> {
2003                        let installer = self.fomod_installer.as_ref().ok_or("No active FOMOD installer")?;
2004                        let source = self.fomod_source_dir.as_ref().ok_or("No source directory")?;
2005                        let dest = self.fomod_dest_dir.as_ref().ok_or("No destination directory")?;
2006                        let plan = installer.resolve();
2007                        plan.execute(source, dest).map_err(|e| e.to_string())?;
2008                        Ok(())
2009                    })();
2010                    match &result {
2011                        Ok(()) => self.status_message = "FOMOD installation completed successfully".to_string(),
2012                        Err(e) => self.status_message = format!("FOMOD installation failed: {e}"),
2013                    }
2014                    self.reset_fomod();
2015                    self.active_view = View::ModList;
2016                    return Task::done(Message::FOMODInstallComplete(result));
2017                } else {
2018                    if let Some(ref mut installer) = self.fomod_installer {
2019                        installer.checkpoint();
2020                        self.fomod_can_undo = true;
2021                    }
2022                    self.fomod_wizard_pos += 1;
2023                }
2024            }
2025            Message::FOMODBack => {
2026                if self.fomod_wizard_pos > 0 { self.fomod_wizard_pos -= 1; }
2027            }
2028            Message::FOMODCancel => {
2029                self.reset_fomod();
2030                self.active_view = View::ModList;
2031                self.status_message = "FOMOD installation cancelled".to_string();
2032            }
2033            Message::FOMODUndo => {
2034                let rolled_back = self.fomod_installer.as_mut().map(|i| i.rollback()).unwrap_or(false);
2035                if rolled_back {
2036                    if let Some(ref installer) = self.fomod_installer {
2037                        self.fomod_selections = installer.selections();
2038                        self.fomod_can_undo = installer.history_len() > 0;
2039                    }
2040                    self.refresh_fomod_visible_steps();
2041                    self.refresh_fomod_conflicts();
2042                    self.status_message = "Undid last FOMOD selection".to_string();
2043                }
2044            }
2045            Message::FOMODInstallComplete(result) => match result {
2046                Ok(()) => self.status_message = "FOMOD installation complete!".to_string(),
2047                Err(e) => self.status_message = format!("FOMOD installation failed: {e}"),
2048            },
2049
2050            // ── Downloads ────────────────────────────────────────
2051            Message::DownloadProgress { id, bytes, total } => {
2052                let pct = if total > 0 { (bytes as f64 / total as f64) * 100.0 } else { 0.0 };
2053                self.status_message = format!("Downloading {id}: {pct:.0}%");
2054            }
2055            Message::DownloadComplete { id } => self.status_message = format!("Download complete: {id}"),
2056            Message::DownloadFailed { id, error } => self.status_message = format!("Download failed ({id}): {error}"),
2057
2058            // ── Settings ─────────────────────────────────────────
2059            Message::SetNexusApiKey(key) => {
2060                self.settings.nexus_api_key = key;
2061                self.status_message = "Nexus API key updated".to_string();
2062                self.save_settings();
2063            }
2064            Message::SetGamePath { game_id, path } => {
2065                self.settings.set_game_path(&game_id, path);
2066                self.status_message = format!("Game path set for {game_id}");
2067                self.save_settings();
2068            }
2069            Message::SetDownloadDir(path) => {
2070                self.status_message = format!("Download directory set to {}", path.display());
2071                self.settings.download_dir = Some(path);
2072                self.save_settings();
2073            }
2074            Message::BrowseGamePath => {
2075                return Task::perform(
2076                    async { rfd::AsyncFileDialog::new().set_title("Select Game Directory").pick_folder().await.map(|h| h.path().to_path_buf()) },
2077                    |path| match path { Some(p) => Message::SetGamePath { game_id: "default".to_string(), path: p }, None => Message::Noop },
2078                );
2079            }
2080            Message::BrowseDownloadDir => {
2081                return Task::perform(
2082                    async { rfd::AsyncFileDialog::new().set_title("Select Download Directory").pick_folder().await.map(|h| h.path().to_path_buf()) },
2083                    |path| match path { Some(p) => Message::SetDownloadDir(p), None => Message::Noop },
2084                );
2085            }
2086            Message::SetTheme(name) => {
2087                self.theme_name = name.clone();
2088                self.settings.theme = name;
2089                self.status_message = "Theme updated".to_string();
2090                self.save_settings();
2091            }
2092            Message::ValidateNexusKey => {
2093                self.nexus_status = Some(NexusAuthStatus::Checking);
2094                let api_key = self.settings.nexus_api_key.clone();
2095                return Task::perform(
2096                    async move {
2097                        tokio::task::spawn_blocking(move || -> Result<(String, bool), String> {
2098                            if api_key.is_empty() { return Err("No API key set".to_string()); }
2099                            let client = reqwest::blocking::Client::new();
2100                            let resp = client.get("https://api.nexusmods.com/v1/users/validate.json")
2101                                .header("apikey", &api_key).send().map_err(|e| e.to_string())?;
2102                            if !resp.status().is_success() { return Err(format!("HTTP {}", resp.status())); }
2103                            let body: serde_json::Value = resp.json().map_err(|e| e.to_string())?;
2104                            let name = body["name"].as_str().unwrap_or("Unknown").to_string();
2105                            let is_premium = body["is_premium"].as_bool().unwrap_or(false);
2106                            Ok((name, is_premium))
2107                        }).await.map_err(|e| e.to_string())?
2108                    },
2109                    Message::NexusKeyValidated,
2110                );
2111            }
2112            Message::NexusKeyValidated(result) => match result {
2113                Ok((username, is_premium)) => {
2114                    self.nexus_status = Some(NexusAuthStatus::Valid { username: username.clone(), is_premium });
2115                    self.status_message = format!("Nexus: logged in as {username}");
2116                }
2117                Err(e) => {
2118                    self.nexus_status = Some(NexusAuthStatus::Invalid(e.clone()));
2119                    self.status_message = format!("Nexus key invalid: {e}");
2120                }
2121            },
2122
2123            // ── Stock game ───────────────────────────────────────
2124            Message::CreateStockSnapshot => {
2125                self.status_message = "Creating stock game snapshot...".to_string();
2126                if let Some(ref profile) = self.loaded_profile {
2127                    let game_id = profile.game_id.clone();
2128                    return Task::perform(
2129                        async move {
2130                            tokio::task::spawn_blocking(move || -> Result<String, String> {
2131                                let game_plugin = modde_games::resolve_game_plugin(&game_id)
2132                                    .ok_or_else(|| format!("unsupported game: {game_id}"))?;
2133                                let install_path = game_plugin.detect_install()
2134                                    .ok_or_else(|| format!("could not detect install for {game_id}"))?;
2135                                let mgr = modde_core::stock::StockGameManager::new(modde_core::stock::StockGameManager::default_dir());
2136                                let rt = tokio::runtime::Handle::current();
2137                                rt.block_on(mgr.snapshot(&game_id, &install_path)).map_err(|e| e.to_string())?;
2138                                Ok(format!("Snapshot created for {game_id}"))
2139                            }).await.map_err(|e| e.to_string())?
2140                        },
2141                        Message::StockSnapshotCreated,
2142                    );
2143                } else {
2144                    self.status_message = "No active profile".to_string();
2145                }
2146            }
2147            Message::StockSnapshotCreated(result) => match result {
2148                Ok(msg) => { self.stock_snapshot_exists = true; self.status_message = msg; }
2149                Err(e) => self.status_message = format!("Snapshot failed: {e}"),
2150            },
2151            Message::VerifyStockSnapshot => {
2152                self.status_message = "Verifying stock snapshot...".to_string();
2153                if let Some(ref profile) = self.loaded_profile {
2154                    let game_id = profile.game_id.clone();
2155                    return Task::perform(
2156                        async move {
2157                            tokio::task::spawn_blocking(move || -> Result<String, String> {
2158                                let game_plugin = modde_games::resolve_game_plugin(&game_id)
2159                                    .ok_or_else(|| format!("unsupported game: {game_id}"))?;
2160                                let _install_path = game_plugin.detect_install()
2161                                    .ok_or_else(|| format!("could not detect install for {game_id}"))?;
2162                                let mgr = modde_core::stock::StockGameManager::new(modde_core::stock::StockGameManager::default_dir());
2163                                let rt = tokio::runtime::Handle::current();
2164                                match rt.block_on(mgr.verify(&game_id)) {
2165                                    Ok(true) => Ok("Stock snapshot verified: OK".to_string()),
2166                                    Ok(false) => Ok("Stock snapshot MODIFIED".to_string()),
2167                                    Err(e) => Err(e.to_string()),
2168                                }
2169                            }).await.map_err(|e| e.to_string())?
2170                        },
2171                        Message::StockVerifyResult,
2172                    );
2173                }
2174            }
2175            Message::StockVerifyResult(result) => match result {
2176                Ok(msg) => self.status_message = msg,
2177                Err(e) => self.status_message = format!("Verify failed: {e}"),
2178            },
2179
2180            // ── Experiments ──────────────────────────────────────
2181            Message::TryProfile => {
2182                if let (Some(profile), Some(profile_name)) = (&self.loaded_profile, &self.active_profile) {
2183                    let game_id = profile.game_id.clone();
2184                    let name = profile_name.clone();
2185                    match ProfileManager::open() {
2186                        Ok(pm) => {
2187                            let save_dir = modde_games::resolve_game_plugin(&game_id).and_then(|g| g.save_directory());
2188                            match pm.try_profile(&name, &game_id, save_dir.as_deref()) {
2189                                Ok(()) => { self.experiment_depth += 1; self.status_message = format!("Experiment started (depth {})", self.experiment_depth); }
2190                                Err(e) => self.status_message = format!("Try failed: {e}"),
2191                            }
2192                        }
2193                        Err(e) => self.status_message = format!("Error: {e}"),
2194                    }
2195                }
2196            }
2197            Message::RollbackExperiment => {
2198                if let Some(ref profile) = self.loaded_profile {
2199                    let game_id = profile.game_id.clone();
2200                    match ProfileManager::open() {
2201                        Ok(pm) => {
2202                            let save_dir = modde_games::resolve_game_plugin(&game_id).and_then(|g| g.save_directory());
2203                            match pm.rollback(&game_id, save_dir.as_deref()) {
2204                                Ok(prev_name) => {
2205                                    self.active_profile = Some(prev_name.clone());
2206                                    self.reload_profile();
2207                                    self.status_message = format!("Rolled back to '{prev_name}'");
2208                                }
2209                                Err(e) => self.status_message = format!("Rollback failed: {e}"),
2210                            }
2211                        }
2212                        Err(e) => self.status_message = format!("Error: {e}"),
2213                    }
2214                }
2215            }
2216            Message::CommitExperiment => {
2217                if let Some(ref profile) = self.loaded_profile {
2218                    let game_id = profile.game_id.clone();
2219                    match ProfileManager::open() {
2220                        Ok(pm) => match pm.commit(&game_id) {
2221                            Ok(()) => { self.experiment_depth = 0; self.status_message = "Experiment committed".to_string(); }
2222                            Err(e) => self.status_message = format!("Commit failed: {e}"),
2223                        },
2224                        Err(e) => self.status_message = format!("Error: {e}"),
2225                    }
2226                }
2227            }
2228
2229            // ── Saves ────────────────────────────────────────────
2230            Message::LoadSaveHistory => {
2231                self.selected_save_details = None;
2232                if let Some(ref profile) = self.loaded_profile {
2233                    let game_id = profile.game_id.clone();
2234                    let profile_name = profile.name.clone();
2235                    match modde_core::save::SaveManager::history(&game_id, &profile_name, 20) {
2236                        Ok(history) => self.save_snapshots = history,
2237                        Err(e) => { self.save_snapshots = Vec::new(); self.status_message = format!("Could not load save history: {e}"); }
2238                    }
2239                }
2240            }
2241            Message::SelectSaveSnapshot(commit_id) => {
2242                if let Some(snap) = self.save_snapshots.iter().find(|s| s.id == commit_id) {
2243                    let compat = snap.fingerprint.as_ref()
2244                        .zip(self.current_fingerprint.as_ref())
2245                        .map(|(_, current)| snap.check_compatibility(current));
2246
2247                    let mut details = crate::views::save_details::SaveDetailsState::from_snapshot(snap, compat);
2248
2249                    // Load file list synchronously (fast git tree walk)
2250                    if let Some(ref profile) = self.loaded_profile {
2251                        match modde_core::save::SaveManager::snapshot_file_list(&profile.game_id, &commit_id) {
2252                            Ok(files) => details.file_paths = Some(files),
2253                            Err(_) => details.file_paths = Some(Vec::new()),
2254                        }
2255                    }
2256
2257                    self.selected_save_details = Some(details);
2258                }
2259            }
2260            Message::RestoreSaveSnapshot(commit_id) => {
2261                if let Some(ref profile) = self.loaded_profile {
2262                    let game_id = profile.game_id.clone();
2263                    let profile_name = profile.name.clone();
2264                    let save_dir = modde_games::resolve_game_plugin(&game_id).and_then(|g| g.save_directory());
2265                    if let Some(save_dir) = save_dir {
2266                        match modde_core::save::SaveManager::restore(&game_id, &profile_name, &commit_id, &save_dir) {
2267                            Ok(count) => self.status_message = format!("Restored {count} save file(s)"),
2268                            Err(e) => self.status_message = format!("Restore failed: {e}"),
2269                        }
2270                    } else {
2271                        self.status_message = "Cannot detect save directory for this game".to_string();
2272                    }
2273                }
2274            }
2275
2276            // ── Verification ─────────────────────────────────────
2277            Message::RunVerify => {
2278                self.verify = VerifyState::Running;
2279                self.status_message = "Running verification...".to_string();
2280                if let Some(ref profile) = self.loaded_profile {
2281                    let profile_name = profile.name.clone();
2282                    let staging_dir = ProfileManager::staging_dir(&profile_name);
2283                    return Task::perform(
2284                        async move {
2285                            tokio::task::spawn_blocking(move || -> VerifyResults {
2286                                let mut results = VerifyResults { missing_mods: SmallVec::new(), hash_mismatches: Vec::new(), broken_symlinks: SmallVec::new(), ok_count: 0 };
2287                                if !staging_dir.exists() { return results; }
2288                                fn walk(dir: &std::path::Path, results: &mut VerifyResults) {
2289                                    if let Ok(entries) = std::fs::read_dir(dir) {
2290                                        for entry in entries.flatten() {
2291                                            let path = entry.path();
2292                                            if path.is_dir() { walk(&path, results); }
2293                                            else if path.is_symlink() {
2294                                                match std::fs::read_link(&path) {
2295                                                    Ok(target) if target.exists() => results.ok_count += 1,
2296                                                    _ => results.broken_symlinks.push(path),
2297                                                }
2298                                            } else { results.ok_count += 1; }
2299                                        }
2300                                    }
2301                                }
2302                                walk(&staging_dir, &mut results);
2303                                results
2304                            }).await.unwrap_or(VerifyResults { missing_mods: SmallVec::new(), hash_mismatches: Vec::new(), broken_symlinks: SmallVec::new(), ok_count: 0 })
2305                        },
2306                        Message::VerifyComplete,
2307                    );
2308                }
2309                self.verify = VerifyState::Idle;
2310            }
2311            Message::VerifyComplete(results) => {
2312                let ok = results.ok_count;
2313                let broken = results.broken_symlinks.len();
2314                self.status_message = format!("Verify: {ok} OK, {broken} broken symlink(s)");
2315                self.verify = VerifyState::Complete(results);
2316                self.active_view = View::Verify;
2317            }
2318
2319            Message::DataTabFilterChanged(f) => {
2320                self.data_tab_state.filter = f;
2321            }
2322            Message::DataTabToggleConflicts(v) => {
2323                self.data_tab_state.show_conflicts_only = v;
2324            }
2325            Message::RunDiagnostics => {
2326                self.diagnostics_state = crate::views::diagnostics::DiagnosticsState::Running;
2327                self.status_message = "Running diagnostics...".to_string();
2328            }
2329            Message::RefreshTools => {
2330                self.status_message = "Refreshing tools...".to_string();
2331            }
2332            Message::ToggleTool { tool_id, enabled } => {
2333                if let Some(entry) = self.tool_state.entries.iter_mut().find(|e| e.tool_id == tool_id) {
2334                    entry.enabled = enabled;
2335                }
2336            }
2337            Message::ApplyTool(id) => {
2338                self.status_message = format!("Applying tool: {id}");
2339            }
2340            Message::RevertTool(id) => {
2341                self.status_message = format!("Reverting tool: {id}");
2342            }
2343            // Downloads
2344            Message::PauseDownload(_id) => {
2345                self.status_message = "Download paused".to_string();
2346            }
2347            Message::ResumeDownload(_id) => {
2348                self.status_message = "Download resumed".to_string();
2349            }
2350            Message::CancelDownload(_id) => {
2351                self.status_message = "Download cancelled".to_string();
2352            }
2353
2354            // Overwrite management
2355            Message::ClearOverwrite => {
2356                if let Some(profile) = &self.loaded_profile {
2357                    let _ = std::fs::remove_dir_all(&profile.overrides);
2358                    let _ = std::fs::create_dir_all(&profile.overrides);
2359                    self.status_message = "Overrides cleared".to_string();
2360                }
2361            }
2362            Message::MoveOverwriteToMod(mod_name) => {
2363                if let Some(profile) = &self.loaded_profile {
2364                    let store = modde_core::paths::store_dir();
2365                    let dest = store.join(&mod_name);
2366                    if profile.overrides.exists() {
2367                        let _ = std::fs::create_dir_all(&dest);
2368                        if let Ok(files) = modde_core::fs::walk_files_relative(&profile.overrides) {
2369                            for (rel, src) in &files {
2370                                let dst = dest.join(rel);
2371                                if let Some(parent) = dst.parent() {
2372                                    let _ = std::fs::create_dir_all(parent);
2373                                }
2374                                let _ = std::fs::rename(src, &dst);
2375                            }
2376                        }
2377                        self.status_message = format!("Moved overrides to mod '{mod_name}'");
2378                    }
2379                }
2380            }
2381
2382            Message::Noop => {}
2383        }
2384        Task::none()
2385    }
2386
2387    // ─── View ────────────────────────────────────────────────────
2388
2389    fn view(&self) -> Element<'_, Message> {
2390        // Show mod details in sidebar on all views except Saves;
2391        // show save details only on Saves view.
2392        let (mod_details_for_sidebar, save_details_for_sidebar) = if matches!(self.active_view, View::Saves) {
2393            (None, self.selected_save_details.as_ref())
2394        } else {
2395            (self.selected_mod_details.as_ref(), None)
2396        };
2397
2398        let sidebar = crate::views::sidebar::view(
2399            &self.active_view,
2400            &self.profiles,
2401            &self.active_profile,
2402            self.experiment_depth,
2403            &self.new_profile_name,
2404            &self.selected_game,
2405            mod_details_for_sidebar,
2406            save_details_for_sidebar,
2407        );
2408
2409        let mods = self.loaded_profile.as_ref().map(|p| p.mods.as_slice()).unwrap_or(&[]);
2410        let settings_state = self.settings_state();
2411
2412        let content: Element<Message> = match &self.active_view {
2413            View::ModList => crate::views::mod_list::view_filtered(
2414                mods,
2415                &self.mod_filter,
2416                self.selected_mod_index,
2417                self.filter_mode,
2418                &self.filter_criteria,
2419                &self.collapsed_categories,
2420                &self.mod_categories,
2421                self.compact_mod_list,
2422                self.loaded_profile
2423                    .as_ref()
2424                    .is_some_and(|p| p.load_order_lock.is_some()),
2425            ),
2426            View::Collections => crate::views::collections::view(&self.collection_search, &self.collections, &self.active_downloads),
2427            View::BrowseNexus => {
2428                // Resolve the game domain here so the view can render
2429                // an empty state when no profile/game is loaded yet.
2430                // Pass by value so the Element's lifetime isn't tied
2431                // to this local string.
2432                let domain = self.current_game_nexus_domain();
2433                crate::views::browse_nexus::view(&self.browse_nexus, domain)
2434            }
2435            View::FOMODWizard(_) => crate::views::fomod_wizard::view(self),
2436            View::Settings => crate::views::settings::view(settings_state),
2437            View::WabbajackInstaller(state) => crate::views::wabbajack::view(state, &self.wabbajack_manifest),
2438            View::Saves => crate::views::saves::view(
2439                &self.save_snapshots,
2440                self.loaded_profile.as_ref().map(|p| p.name.as_str()),
2441                self.current_fingerprint.as_ref(),
2442                self.selected_save_details.as_ref().map(|d| d.commit_id.as_str()),
2443            ),
2444            View::Verify => crate::views::verify::view(&self.verify),
2445            View::Downloads => container(text("Downloads view").size(14)).padding(20).width(Length::Fill).into(),
2446            View::DataTab => crate::views::data_tab::view(&self.data_tab_state, &self.data_tab_conflicts),
2447            View::Diagnostics => crate::views::diagnostics::view(&self.diagnostics_state),
2448            View::Tools => crate::views::tools::view(&self.tool_state),
2449        };
2450
2451        // ── Custom title bar ──
2452        let game_names: Vec<String> = self
2453            .available_games
2454            .iter()
2455            .map(|(_, name)| name.clone())
2456            .collect();
2457        let selected_game_display = self.selected_game.as_ref().and_then(|id| {
2458            self.available_games
2459                .iter()
2460                .find(|(gid, _)| gid == id)
2461                .map(|(_, name)| name.clone())
2462        });
2463        let available_games = self.available_games.clone();
2464        let game_picker = pick_list(game_names, selected_game_display, move |name: String| {
2465            let game_id = available_games
2466                .iter()
2467                .find(|(_, n)| *n == name)
2468                .map(|(id, _)| id.clone())
2469                .unwrap_or(name);
2470            Message::SelectGame(game_id)
2471        })
2472        .placeholder("Select a game")
2473        .width(Length::Fixed(200.0));
2474
2475        let title_label = text("modde").size(14);
2476
2477        let window_controls = row![
2478            button(text("\u{2212}").size(12))
2479                .on_press(Message::WindowMinimize)
2480                .style(button::secondary)
2481                .padding([2, 10]),
2482            button(text("\u{25A1}").size(12))
2483                .on_press(Message::WindowToggleMaximize)
2484                .style(button::secondary)
2485                .padding([2, 10]),
2486            button(text("\u{2715}").size(12))
2487                .on_press(Message::WindowClose)
2488                .style(button::danger)
2489                .padding([2, 10]),
2490        ]
2491        .spacing(2);
2492
2493        let title_bar_content = row![
2494            game_picker,
2495            iced::widget::Space::new().width(Length::Fill),
2496            title_label,
2497            iced::widget::Space::new().width(Length::Fill),
2498            window_controls,
2499        ]
2500        .align_y(iced::Alignment::Center)
2501        .spacing(8);
2502
2503        let title_bar = mouse_area(
2504            container(title_bar_content)
2505                .padding([4, 8])
2506                .width(Length::Fill)
2507                .style(container::rounded_box),
2508        )
2509        .on_press(Message::TitleBarDrag);
2510
2511        let status_bar = container(text(&self.status_message).size(12)).padding(5);
2512
2513        let main_layout = column![
2514            title_bar,
2515            row![sidebar, content].spacing(0).height(Length::Fill),
2516            status_bar,
2517        ]
2518        .spacing(0);
2519
2520        let base: Element<Message> = container(main_layout)
2521            .width(Length::Fill)
2522            .height(Length::Fill)
2523            .into();
2524
2525        base
2526    }
2527
2528    fn theme(&self) -> Theme {
2529        match self.theme_name.as_str() {
2530            "Light" => Theme::Light,
2531            "Dracula" => Theme::Dracula,
2532            "Nord" => Theme::Nord,
2533            "Gruvbox Dark" => Theme::GruvboxDark,
2534            "Catppuccin Mocha" => Theme::CatppuccinMocha,
2535            _ => Theme::Dark,
2536        }
2537    }
2538
2539    /// Route widget-ignored keyboard events through the shortcuts
2540    /// module. `keyboard::listen()` yields only events that no focused
2541    /// widget consumed — so text inputs keep their keys while global
2542    /// shortcuts fire on unhandled presses. The closure must be
2543    /// non-capturing (a `Subscription::filter_map` constraint), so it
2544    /// calls only free functions and cannot read `&self`.
2545    fn subscription(&self) -> Subscription<Message> {
2546        keyboard::listen().filter_map(|event| match event {
2547            keyboard::Event::KeyPressed { key, modifiers, .. } => {
2548                let action = crate::shortcuts::match_shortcut(&key, modifiers)?;
2549                shortcut_action_to_message(action)
2550            }
2551            _ => None,
2552        })
2553    }
2554}
2555
2556/// Map a matched shortcut action string to the corresponding
2557/// `Message`. Returns `None` for actions whose handlers aren't wired
2558/// yet — those shortcuts still register in `all_shortcuts()` for
2559/// Max thumbnail dimensions — matches the sidebar image slot
2560/// (~166 px wide, 96 px tall).  We decode the fetched bytes, scale
2561/// down with a Lanczos3 filter, and hand the smaller RGBA buffer to
2562/// iced so it doesn't keep a multi-megapixel texture around.
2563const THUMB_MAX_W: u32 = 340;
2564const THUMB_MAX_H: u32 = 192;
2565
2566fn resize_thumbnail_bytes(raw: &[u8]) -> iced::widget::image::Handle {
2567    let Ok(img) = image::load_from_memory(raw) else {
2568        // If decoding fails, fall back to letting iced try the raw bytes.
2569        return iced::widget::image::Handle::from_bytes(raw.to_vec());
2570    };
2571
2572    let resized = img.resize(THUMB_MAX_W, THUMB_MAX_H, image::imageops::FilterType::Lanczos3);
2573    let rgba = resized.to_rgba8();
2574    let (w, h) = rgba.dimensions();
2575    iced::widget::image::Handle::from_rgba(w, h, rgba.into_raw())
2576}
2577
2578/// help-text purposes but produce no messages until their handlers
2579/// exist (most need state-aware lookups or new `Message` variants).
2580fn shortcut_action_to_message(action: &str) -> Option<Message> {
2581    match action {
2582        "deploy" => Some(Message::Deploy),
2583        _ => None,
2584    }
2585}
2586
2587/// Run the iced application.
2588pub fn run() -> iced::Result {
2589    iced::application(Modde::new, Modde::update, Modde::view)
2590        .title(Modde::title)
2591        .theme(Modde::theme)
2592        .subscription(Modde::subscription)
2593        .decorations(false)
2594        .run()
2595}
2596
2597#[cfg(test)]
2598mod tests {
2599    use super::*;
2600    use std::path::PathBuf;
2601
2602    fn test_app() -> Modde {
2603        Modde {
2604            active_view: View::ModList,
2605            active_profile: None,
2606            profiles: Vec::new(),
2607            status_message: "Ready".to_string(),
2608            settings: AppSettings::default(),
2609            collection_search: String::new(),
2610            collections: Vec::new(),
2611            fomod_installer: None,
2612            fomod_visible_step_indices: SmallVec::new(),
2613            fomod_wizard_pos: 0,
2614            fomod_source_dir: None,
2615            fomod_dest_dir: None,
2616            fomod_conflicts: SmallVec::new(),
2617            fomod_can_undo: false,
2618            fomod_selections: HashMap::new(),
2619            selected_mod_index: None,
2620            selected_mod_details: None,
2621            mod_filter: String::new(),
2622            theme_name: "Dark".to_string(),
2623            wabbajack_manifest: None,
2624            active_downloads: Vec::new(),
2625            loaded_profile: None,
2626            save_snapshots: Vec::new(),
2627            current_fingerprint: None,
2628            selected_save_details: None,
2629            experiment_depth: 0,
2630            nexus_status: None,
2631            verify: VerifyState::Idle,
2632            new_profile_name: String::new(),
2633            available_games: smallvec::smallvec![("skyrim-se".to_string(), "Skyrim SE".to_string())],
2634            selected_game: None,
2635            stock_snapshot_exists: false,
2636            window_id: window::Id::unique(),
2637            collapsed_categories: HashSet::new(),
2638            mod_categories: vec![(None, "Uncategorized".to_string())],
2639            data_tab_state: Default::default(),
2640            data_tab_conflicts: Vec::new(),
2641            diagnostics_state: Default::default(),
2642            tool_state: Default::default(),
2643            browse_nexus: Default::default(),
2644            filter_mode: FilterMode::default(),
2645            filter_criteria: vec![
2646                FilterCriterion::new(FilterKind::Enabled),
2647                FilterCriterion::new(FilterKind::HasNotes),
2648                FilterCriterion::new(FilterKind::HasNexusId),
2649            ],
2650            compact_mod_list: false,
2651        }
2652    }
2653
2654    #[test]
2655    fn test_initial_state() {
2656        let app = test_app();
2657        assert!(matches!(app.active_view, View::ModList));
2658        assert!(app.active_profile.is_none());
2659        assert_eq!(app.status_message, "Ready");
2660        assert_eq!(app.theme_name, "Dark");
2661        assert!(app.fomod_installer.is_none());
2662        assert_eq!(app.experiment_depth, 0);
2663        assert!(matches!(app.verify, VerifyState::Idle));
2664    }
2665
2666    #[test]
2667    fn test_title() {
2668        let app = test_app();
2669        assert_eq!(app.title(), "modde");
2670    }
2671
2672    #[test]
2673    fn test_switch_view_settings() {
2674        let mut app = test_app();
2675        let _ = app.update(Message::SwitchView(View::Settings));
2676        assert!(matches!(app.active_view, View::Settings));
2677    }
2678
2679    #[test]
2680    fn test_switch_view_saves() {
2681        let mut app = test_app();
2682        let _ = app.update(Message::SwitchView(View::Saves));
2683        assert!(matches!(app.active_view, View::Saves));
2684    }
2685
2686    #[test]
2687    fn test_switch_view_verify() {
2688        let mut app = test_app();
2689        let _ = app.update(Message::SwitchView(View::Verify));
2690        assert!(matches!(app.active_view, View::Verify));
2691    }
2692
2693    #[test]
2694    fn test_switch_profile() {
2695        let mut app = test_app();
2696        let _ = app.update(Message::SwitchProfile("test-profile".to_string()));
2697        assert_eq!(app.active_profile.as_deref(), Some("test-profile"));
2698        assert_eq!(app.status_message, "Profile switched");
2699    }
2700
2701    #[test]
2702    fn test_filter_changed() {
2703        let mut app = test_app();
2704        let _ = app.update(Message::FilterChanged("skyui".to_string()));
2705        assert_eq!(app.mod_filter, "skyui");
2706    }
2707
2708    #[test]
2709    fn test_select_mod() {
2710        let mut app = test_app();
2711        let _ = app.update(Message::SelectMod(3));
2712        assert_eq!(app.selected_mod_index, Some(3));
2713    }
2714
2715    #[test]
2716    fn test_deploy_complete_ok() {
2717        let mut app = test_app();
2718        let _ = app.update(Message::DeployComplete(Ok("Deployed 5 mods".to_string())));
2719        assert!(app.status_message.contains("Deployed"));
2720    }
2721
2722    #[test]
2723    fn test_deploy_complete_err() {
2724        let mut app = test_app();
2725        let _ = app.update(Message::DeployComplete(Err("game not found".to_string())));
2726        assert!(app.status_message.contains("Deploy failed"));
2727    }
2728
2729    #[test]
2730    fn test_set_nexus_api_key() {
2731        let mut app = test_app();
2732        let _ = app.update(Message::SetNexusApiKey("abc123".to_string()));
2733        assert_eq!(app.settings.nexus_api_key, "abc123");
2734    }
2735
2736    #[test]
2737    fn test_set_theme() {
2738        let mut app = test_app();
2739        let _ = app.update(Message::SetTheme("Nord".to_string()));
2740        assert_eq!(app.theme_name, "Nord");
2741        assert_eq!(app.settings.theme, "Nord");
2742    }
2743
2744    #[test]
2745    fn test_theme_returns_correct_variant() {
2746        let mut app = test_app();
2747        assert_eq!(app.theme(), Theme::Dark);
2748        app.theme_name = "Light".to_string();
2749        assert_eq!(app.theme(), Theme::Light);
2750        app.theme_name = "Nord".to_string();
2751        assert_eq!(app.theme(), Theme::Nord);
2752        app.theme_name = "Dracula".to_string();
2753        assert_eq!(app.theme(), Theme::Dracula);
2754        app.theme_name = "Gruvbox Dark".to_string();
2755        assert_eq!(app.theme(), Theme::GruvboxDark);
2756        app.theme_name = "Catppuccin Mocha".to_string();
2757        assert_eq!(app.theme(), Theme::CatppuccinMocha);
2758    }
2759
2760    #[test]
2761    fn test_new_profile_name_changed() {
2762        let mut app = test_app();
2763        let _ = app.update(Message::NewProfileNameChanged("my-profile".to_string()));
2764        assert_eq!(app.new_profile_name, "my-profile");
2765    }
2766
2767    #[test]
2768    fn test_select_game() {
2769        let mut app = test_app();
2770        let _ = app.update(Message::SelectGame("cyberpunk2077".to_string()));
2771        assert_eq!(app.selected_game, Some("cyberpunk2077".to_string()));
2772    }
2773
2774    #[test]
2775    fn test_verify_complete() {
2776        let mut app = test_app();
2777        let results = VerifyResults { missing_mods: SmallVec::new(), hash_mismatches: vec![], broken_symlinks: smallvec::smallvec![PathBuf::from("/broken")], ok_count: 42 };
2778        let _ = app.update(Message::VerifyComplete(results));
2779        assert!(matches!(app.active_view, View::Verify));
2780        assert!(matches!(app.verify, VerifyState::Complete(_)));
2781        assert!(app.status_message.contains("42 OK"));
2782    }
2783
2784    #[test]
2785    fn test_noop() {
2786        let mut app = test_app();
2787        let old = app.status_message.clone();
2788        let _ = app.update(Message::Noop);
2789        assert_eq!(app.status_message, old);
2790    }
2791
2792    #[test]
2793    fn test_shortcut_action_to_message_maps_deploy() {
2794        assert!(matches!(
2795            shortcut_action_to_message("deploy"),
2796            Some(Message::Deploy)
2797        ));
2798    }
2799
2800    #[test]
2801    fn test_shortcut_action_to_message_drops_unmapped() {
2802        assert!(shortcut_action_to_message("refresh").is_none());
2803        assert!(shortcut_action_to_message("nonexistent").is_none());
2804    }
2805
2806
2807    #[test]
2808    fn test_fomod_cancel() {
2809        let mut app = test_app();
2810        app.fomod_installer = Some(FOMODWizardState::new());
2811        app.active_view = View::FOMODWizard(FOMODWizardState::new());
2812        let _ = app.update(Message::FOMODCancel);
2813        assert!(matches!(app.active_view, View::ModList));
2814        assert!(app.status_message.contains("cancelled"));
2815    }
2816
2817    #[test]
2818    fn test_fomod_back() {
2819        let mut app = test_app();
2820        app.fomod_wizard_pos = 2;
2821        let _ = app.update(Message::FOMODBack);
2822        assert_eq!(app.fomod_wizard_pos, 1);
2823    }
2824
2825    #[test]
2826    fn test_reset_fomod() {
2827        let mut app = test_app();
2828        app.fomod_installer = Some(FOMODWizardState::new());
2829        app.fomod_source_dir = Some(PathBuf::from("/src"));
2830        app.fomod_wizard_pos = 1;
2831        app.fomod_can_undo = true;
2832        app.reset_fomod();
2833        assert!(app.fomod_installer.is_none());
2834        assert_eq!(app.fomod_wizard_pos, 0);
2835        assert!(!app.fomod_can_undo);
2836    }
2837
2838    // ── UI handler lock refusal tests (DB-isolated) ──────────────
2839    //
2840    // These exercise the DB-touching branches of `Message::ReorderMod`
2841    // / `LockMod` / `UnlockMod`.
2842    //
2843    // DB isolation is a process-wide `OnceLock<TempDir>` via
2844    // `modde_core::paths::set_data_dir` — the same pattern used by
2845    // `crates/modde-core/tests/load_order_lock_tests.rs`. All tests in
2846    // this module share one tempdir; collision avoidance is by unique
2847    // profile names (so there's no mutable-shared-state risk). See
2848    // `/home/can/.claude/plans/greedy-shimmying-pine.md` for the full
2849    // plan.
2850
2851    use modde_core::profile::{LoadOrderLock, LockReason, ProfileManager, ProfileSource};
2852    use std::sync::{Mutex, MutexGuard, OnceLock};
2853
2854    static ISOLATED_DATA_DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
2855
2856    /// Process-wide test mutex. All lock-refusal tests share one
2857    /// file-backed SQLite DB (via the `ISOLATED_DATA_DIR` OnceLock) and
2858    /// open a fresh `ProfileManager` connection per test, which runs
2859    /// [`ModdeDb::migrate`](../../../../modde-core/src/db.rs) on every
2860    /// open. The V2 migration is **not idempotent** under parallel
2861    /// writes (`ALTER TABLE ... ADD COLUMN` with no guard race on first
2862    /// open) — so we serialize tests that touch the isolated DB.
2863    ///
2864    /// modde-core's own lock tests don't hit this because they use
2865    /// `ModdeDb::open_memory()` (one fresh in-memory DB per test). We
2866    /// can't do that here because the UI handlers call
2867    /// `ProfileManager::open()` internally — no injection point for an
2868    /// in-memory DB.
2869    static DB_LOCK: Mutex<()> = Mutex::new(());
2870
2871    /// Acquire the serial lock. Discards poisoning (a prior panicking
2872    /// test shouldn't block the rest of the suite).
2873    fn db_lock() -> MutexGuard<'static, ()> {
2874        DB_LOCK.lock().unwrap_or_else(|p| p.into_inner())
2875    }
2876
2877    /// Redirect `modde_core::paths::modde_data_dir` to a per-process
2878    /// tempdir so lock-refusal tests don't touch `~/.local/share/modde`.
2879    /// `OnceLock::get_or_init` makes this safe (idempotent) on every call.
2880    fn isolated_data_dir() {
2881        ISOLATED_DATA_DIR.get_or_init(|| {
2882            let dir = tempfile::tempdir().expect("create isolated modde data dir");
2883            modde_core::paths::set_data_dir(dir.path().to_path_buf());
2884            dir
2885        });
2886    }
2887
2888    /// Build an `EnabledMod` for the refusal-test fixtures. `lock` controls
2889    /// the per-mod pin — `None` for a normal entry, `Some(reason)` to pin.
2890    fn seed_mod(id: &str, lock: Option<LockReason>) -> modde_core::profile::EnabledMod {
2891        modde_core::profile::EnabledMod {
2892            mod_id: id.to_string(),
2893            display_name: Some(id.to_string()),
2894            enabled: true,
2895            lock,
2896            ..Default::default()
2897        }
2898    }
2899
2900    /// Write a profile directly to the isolated DB. Profile names must be
2901    /// unique across all tests in this module (they share the OnceLock
2902    /// tempdir). The fake `game_id = "test-game"` is deliberate — it makes
2903    /// `modde_games::resolve_game_plugin` return `None`, which in turn
2904    /// makes `reload_profile`'s fingerprint block short-circuit, avoiding
2905    /// a walk of the non-existent staging dir.
2906    fn seed_profile(
2907        name: &str,
2908        mods: Vec<modde_core::profile::EnabledMod>,
2909        lock: Option<LoadOrderLock>,
2910    ) {
2911        isolated_data_dir();
2912        let pm = ProfileManager::open().expect("open isolated DB");
2913        let profile = modde_core::profile::Profile {
2914            id: None,
2915            name: name.to_string(),
2916            game_id: modde_core::GameId::from("test-game"),
2917            source: ProfileSource::Manual,
2918            mods,
2919            overrides: PathBuf::from("/tmp/overrides"),
2920            load_order_rules: SmallVec::new(),
2921            load_order_lock: lock,
2922        };
2923        pm.create(&profile).expect("seed profile");
2924    }
2925
2926    /// Build a `Modde` with `active_profile` set to the seeded profile
2927    /// and `loaded_profile` populated via `reload_profile` (reading from
2928    /// the isolated DB).
2929    fn loaded_test_app(name: &str) -> Modde {
2930        let mut app = test_app();
2931        app.active_profile = Some(name.to_string());
2932        app.reload_profile();
2933        app
2934    }
2935
2936    /// Read the profile back from the isolated DB. Assertions should use
2937    /// this (not `app.loaded_profile`) to verify *persisted* state.
2938    fn reload_seeded(name: &str) -> modde_core::profile::Profile {
2939        let pm = ProfileManager::open().expect("open isolated DB");
2940        pm.load(name, Some("test-game"))
2941            .expect("load seeded profile")
2942    }
2943
2944    /// Shorthand: return the `mod_id`s of a profile in current order.
2945    fn mod_ids(profile: &modde_core::profile::Profile) -> Vec<&str> {
2946        profile.mods.iter().map(|m| m.mod_id.as_str()).collect()
2947    }
2948
2949    // ─── ReorderMod refusal / allow paths ────────────────────────
2950
2951    #[test]
2952    fn reorder_refused_when_profile_wabbajack_locked() {
2953        let _guard = db_lock();
2954        seed_profile(
2955            "reorder_refuse_wabbajack",
2956            vec![seed_mod("a", None), seed_mod("b", None), seed_mod("c", None)],
2957            Some(LoadOrderLock::now(LockReason::Wabbajack {
2958                manifest_hash: "deadbeef".to_string(),
2959            })),
2960        );
2961        let mut app = loaded_test_app("reorder_refuse_wabbajack");
2962        let _ = app.update(Message::ReorderMod {
2963            mod_id: "a".to_string(),
2964            direction: ReorderDirection::Down,
2965        });
2966        let persisted = reload_seeded("reorder_refuse_wabbajack");
2967        assert_eq!(
2968            mod_ids(&persisted),
2969            vec!["a", "b", "c"],
2970            "order must be unchanged when profile is Wabbajack-locked"
2971        );
2972        assert!(
2973            app.status_message.contains("locked by Wabbajack"),
2974            "status message should name the lock reason, got: {}",
2975            app.status_message
2976        );
2977    }
2978
2979    #[test]
2980    fn reorder_refused_when_target_mod_pinned() {
2981        let _guard = db_lock();
2982        seed_profile(
2983            "reorder_refuse_target_pinned",
2984            vec![
2985                seed_mod("a", None),
2986                seed_mod("b", Some(LockReason::Manual { note: None })),
2987                seed_mod("c", None),
2988            ],
2989            None,
2990        );
2991        let mut app = loaded_test_app("reorder_refuse_target_pinned");
2992        let _ = app.update(Message::ReorderMod {
2993            mod_id: "b".to_string(),
2994            direction: ReorderDirection::Up,
2995        });
2996        let persisted = reload_seeded("reorder_refuse_target_pinned");
2997        assert_eq!(mod_ids(&persisted), vec!["a", "b", "c"]);
2998        assert!(
2999            app.status_message.contains("pinned"),
3000            "status message should mention the pin, got: {}",
3001            app.status_message
3002        );
3003    }
3004
3005    #[test]
3006    fn reorder_refused_when_swap_partner_pinned() {
3007        let _guard = db_lock();
3008        seed_profile(
3009            "reorder_refuse_partner_pinned",
3010            vec![
3011                seed_mod("a", None),
3012                seed_mod("b", None),
3013                seed_mod("c", Some(LockReason::Manual { note: None })),
3014            ],
3015            None,
3016        );
3017        let mut app = loaded_test_app("reorder_refuse_partner_pinned");
3018        // Move b down → swap partner is c (pinned) → should refuse.
3019        let _ = app.update(Message::ReorderMod {
3020            mod_id: "b".to_string(),
3021            direction: ReorderDirection::Down,
3022        });
3023        let persisted = reload_seeded("reorder_refuse_partner_pinned");
3024        assert_eq!(mod_ids(&persisted), vec!["a", "b", "c"]);
3025        assert!(
3026            app.status_message
3027                .contains("Cannot move past a pinned mod"),
3028            "status message should explain the adjacent pin, got: {}",
3029            app.status_message
3030        );
3031    }
3032
3033    #[test]
3034    fn reorder_allowed_when_unlocked_moves_up() {
3035        let _guard = db_lock();
3036        seed_profile(
3037            "reorder_allow_up",
3038            vec![seed_mod("a", None), seed_mod("b", None), seed_mod("c", None)],
3039            None,
3040        );
3041        let mut app = loaded_test_app("reorder_allow_up");
3042        // Move b up → swap with a → [b, a, c].
3043        let _ = app.update(Message::ReorderMod {
3044            mod_id: "b".to_string(),
3045            direction: ReorderDirection::Up,
3046        });
3047        let persisted = reload_seeded("reorder_allow_up");
3048        assert_eq!(mod_ids(&persisted), vec!["b", "a", "c"]);
3049        assert!(
3050            app.status_message.contains("up"),
3051            "status should confirm the upward move, got: {}",
3052            app.status_message
3053        );
3054    }
3055
3056    #[test]
3057    fn reorder_allowed_when_unlocked_moves_down() {
3058        let _guard = db_lock();
3059        seed_profile(
3060            "reorder_allow_down",
3061            vec![seed_mod("a", None), seed_mod("b", None), seed_mod("c", None)],
3062            None,
3063        );
3064        let mut app = loaded_test_app("reorder_allow_down");
3065        // Move a down → swap with b → [b, a, c].
3066        let _ = app.update(Message::ReorderMod {
3067            mod_id: "a".to_string(),
3068            direction: ReorderDirection::Down,
3069        });
3070        let persisted = reload_seeded("reorder_allow_down");
3071        assert_eq!(mod_ids(&persisted), vec!["b", "a", "c"]);
3072        assert!(
3073            app.status_message.contains("down"),
3074            "status should confirm the downward move, got: {}",
3075            app.status_message
3076        );
3077    }
3078
3079    #[test]
3080    fn reorder_noop_at_top_edge() {
3081        let _guard = db_lock();
3082        seed_profile(
3083            "reorder_noop_edge",
3084            vec![seed_mod("a", None), seed_mod("b", None), seed_mod("c", None)],
3085            None,
3086        );
3087        let mut app = loaded_test_app("reorder_noop_edge");
3088        let status_before = app.status_message.clone();
3089        let _ = app.update(Message::ReorderMod {
3090            mod_id: "a".to_string(),
3091            direction: ReorderDirection::Up,
3092        });
3093        let persisted = reload_seeded("reorder_noop_edge");
3094        assert_eq!(
3095            mod_ids(&persisted),
3096            vec!["a", "b", "c"],
3097            "order unchanged at top boundary"
3098        );
3099        // The handler silently short-circuits on `AtBoundary` — status
3100        // message is not rewritten. Contract documented at
3101        // `Message::ReorderMod` handler.
3102        assert_eq!(
3103            app.status_message, status_before,
3104            "AtBoundary should not mutate the status message"
3105        );
3106    }
3107
3108
3109    // ─── LockMod / UnlockMod (per-mod pins) ──────────────────────
3110
3111    #[test]
3112    fn lock_mod_sets_per_mod_lock() {
3113        let _guard = db_lock();
3114        seed_profile(
3115            "lock_mod_sets_pin",
3116            vec![seed_mod("a", None), seed_mod("b", None), seed_mod("c", None)],
3117            None,
3118        );
3119        let mut app = loaded_test_app("lock_mod_sets_pin");
3120        let _ = app.update(Message::LockMod {
3121            mod_id: "b".to_string(),
3122        });
3123        let persisted = reload_seeded("lock_mod_sets_pin");
3124        assert!(
3125            matches!(
3126                persisted.mods[1].lock,
3127                Some(LockReason::Manual { note: None })
3128            ),
3129            "mod 'b' should be pinned with Manual reason, got {:?}",
3130            persisted.mods[1].lock
3131        );
3132        // Other mods untouched.
3133        assert!(persisted.mods[0].lock.is_none());
3134        assert!(persisted.mods[2].lock.is_none());
3135    }
3136
3137    #[test]
3138    fn unlock_mod_clears_per_mod_lock() {
3139        let _guard = db_lock();
3140        seed_profile(
3141            "unlock_mod_clears_pin",
3142            vec![
3143                seed_mod("a", None),
3144                seed_mod("b", Some(LockReason::Manual { note: None })),
3145                seed_mod("c", None),
3146            ],
3147            None,
3148        );
3149        let mut app = loaded_test_app("unlock_mod_clears_pin");
3150        let _ = app.update(Message::UnlockMod {
3151            mod_id: "b".to_string(),
3152        });
3153        let persisted = reload_seeded("unlock_mod_clears_pin");
3154        assert!(
3155            persisted.mods[1].lock.is_none(),
3156            "mod 'b' pin should be cleared"
3157        );
3158    }
3159}