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#[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
32pub 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 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 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 pub collapsed_categories: HashSet<Option<i64>>,
75 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 pub browse_nexus: crate::views::browse_nexus::NexusBrowseState,
81 pub diagnostics_state: crate::views::diagnostics::DiagnosticsState,
82 pub tool_state: ToolState,
83 pub filter_mode: FilterMode,
85 pub filter_criteria: Vec<FilterCriterion>,
87 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#[derive(Debug, Clone, Default)]
106pub enum VerifyState {
107 #[default]
109 Idle,
110 Running,
112 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 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 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 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
280async 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 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 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 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
410pub use modde_core::profile::ReorderDirection;
415
416pub(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#[derive(Debug, Clone)]
432pub enum View {
433 ModList,
434 Collections,
435 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#[derive(Debug, Clone)]
679pub enum Message {
680 SwitchView(View),
682 SwitchProfile(String),
683 CreateProfile { name: String, game_id: String },
684 DeleteProfile(String),
685 ForkProfile { source: String, new_name: String },
686
687 NewProfileNameChanged(String),
689
690 SelectGame(String),
692
693 GotWindowId(Option<window::Id>),
695 TitleBarDrag,
696 WindowMinimize,
697 WindowToggleMaximize,
698 WindowClose,
699
700 ToggleMod { mod_id: String, enabled: bool },
702 FilterChanged(String),
703 AddMod,
704 AddModFromPath(PathBuf),
705 RemoveMod(usize),
706 SelectMod(usize),
707 ModDetailsLoaded {
711 nexus_mod_id: i64,
712 result: Result<modde_sources::nexus::api::NexusMod, String>,
713 },
714 ModGalleryLoaded {
716 nexus_mod_id: i64,
717 urls: Vec<String>,
718 },
719 ModThumbnailLoaded {
723 nexus_mod_id: i64,
724 gallery_index: usize,
725 bytes: Vec<u8>,
726 },
727 ModGalleryNext,
729 OpenModPage,
731 Deploy,
732 DeployComplete(Result<String, String>),
733
734 ReorderMod { mod_id: String, direction: ReorderDirection },
741 LockMod { mod_id: String },
743 UnlockMod { mod_id: String },
745
746
747 SearchCollections(String),
749 InstallCollection { slug: String, version: String },
750
751 BrowseTabSwitched(crate::views::browse_nexus::BrowseTab),
755 BrowseSearchChanged(String),
757 BrowseSearchSubmit,
760 BrowseModsLoaded(
762 Result<Vec<modde_sources::nexus::graphql::GqlModTile>, String>,
763 ),
764 BrowseCollectionsLoaded(
766 Result<Vec<modde_sources::nexus::graphql::GqlCollectionTile>, String>,
767 ),
768 BrowseInstallMod { game_domain: String, mod_id: u64 },
771 BrowseInstallResult(Result<String, String>),
774
775 OpenWabbajackFile,
777 WabbajackFileSelected(PathBuf),
778 WabbajackProgress(f32),
779 WabbajackStartInstall,
780 WabbajackLog(String),
781
782 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 DownloadProgress { id: String, bytes: u64, total: u64 },
798 DownloadComplete { id: String },
799 DownloadFailed { id: String, error: String },
800
801 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 CreateStockSnapshot,
813 StockSnapshotCreated(Result<String, String>),
814 VerifyStockSnapshot,
815 StockVerifyResult(Result<String, String>),
816
817 TryProfile,
819 RollbackExperiment,
820 CommitExperiment,
821
822 LoadSaveHistory,
824 RestoreSaveSnapshot(String),
825 SelectSaveSnapshot(String),
826
827 RunVerify,
829 VerifyComplete(VerifyResults),
830
831 ToggleSeparator(Option<i64>),
833
834 DataTabFilterChanged(String),
836 DataTabToggleConflicts(bool),
837
838 RunDiagnostics,
840
841 RefreshTools,
843 ToggleTool { tool_id: String, enabled: bool },
844 ApplyTool(String),
845 RevertTool(String),
846
847 PauseDownload(usize),
849 ResumeDownload(usize),
850 CancelDownload(usize),
851
852 ModEndorseToggle,
855 ModEndorseResult {
859 nexus_mod_id: i64,
860 new_status: String,
861 result: Result<(), String>,
862 },
863 ModTrackToggle,
865 ModTrackResult {
867 nexus_mod_id: i64,
868 new_tracked: bool,
869 result: Result<(), String>,
870 },
871 ModTrackedSetLoaded {
874 nexus_mod_id: i64,
875 is_tracked: bool,
876 },
877
878 ClearOverwrite,
880 MoveOverwriteToMod(String),
881
882 ToggleFilterMode,
884 CycleFilter(FilterKind),
885 ClearFilters,
886 ToggleCompactModList,
887
888 Noop,
890}
891
892impl 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 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 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 Message::SwitchView(view) => {
995 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 Message::NewProfileNameChanged(name) => self.new_profile_name = name,
1075
1076 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 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 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 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 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 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 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 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 self.status_message = format!("Opening: {url}");
1462 tracing::info!(url = %url, "opening mod page in browser");
1463 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn view(&self) -> Element<'_, Message> {
2390 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 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 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 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
2556const 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 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
2578fn shortcut_action_to_message(action: &str) -> Option<Message> {
2581 match action {
2582 "deploy" => Some(Message::Deploy),
2583 _ => None,
2584 }
2585}
2586
2587pub 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 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 static DB_LOCK: Mutex<()> = Mutex::new(());
2870
2871 fn db_lock() -> MutexGuard<'static, ()> {
2874 DB_LOCK.lock().unwrap_or_else(|p| p.into_inner())
2875 }
2876
2877 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 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 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 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 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 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 #[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 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 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 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 assert_eq!(
3103 app.status_message, status_before,
3104 "AtBoundary should not mutate the status message"
3105 );
3106 }
3107
3108
3109 #[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 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}