Skip to main content

launcher/pages/troubleshoot/
ui.rs

1use std::collections::BTreeMap;
2
3use crate::pages::troubleshoot::tasks::{
4    LocalProfileInfo, SnapshotRequest, start_create_snapshot, start_refresh_profiles,
5    start_restore_snapshot, start_upload_snapshot,
6};
7
8use crate::pages::troubleshoot::types::TroubleshootPageState;
9use eframe::egui;
10use supreme_fs::GameStorage;
11
12#[cfg(feature = "local")]
13use crate::pages::troubleshoot::tasks::{run_cleaner_task, start_path_scan};
14
15#[allow(clippy::too_many_lines)]
16pub fn troubleshoot_page(
17    ui: &mut egui::Ui,
18    state: &mut TroubleshootPageState,
19    storage: &GameStorage,
20) {
21    state.update(storage);
22
23    let has_token = std::env::var("SSBZ_API_TOKEN").is_ok();
24
25    // Snapshot Management Section
26
27    ui.horizontal(|ui| {
28        ui.heading("Snapshots");
29        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
30            if ui
31                .add_enabled(
32                    !state.is_running && !state.profile_refresh_active,
33                    egui::Button::new("๐Ÿ”„ Refresh List"),
34                )
35                .clicked()
36            {
37                state.has_attempted_profile_refresh = true;
38                start_refresh_profiles(ui.ctx().clone(), state, storage);
39            }
40
41            if state.profile_refresh_active {
42                ui.add(egui::Spinner::new());
43            }
44
45            // Auto-refresh if empty (and not running or refreshing)
46            if state.available_profiles.is_empty()
47                && !state.is_running
48                && !state.profile_refresh_active
49                && !state.has_attempted_profile_refresh
50            {
51                state.has_attempted_profile_refresh = true;
52                start_refresh_profiles(ui.ctx().clone(), state, storage);
53            }
54        });
55    });
56
57    ui.label("Manage game file snapshots to easily restore or debug configurations.");
58    ui.add_space(10.0);
59
60    let box_height = 140.0;
61
62    ui.columns(2, |cols| {
63        // --- Left Column: List ---
64        cols[0].vertical(|ui| {
65            ui.label(egui::RichText::new("Available Snapshots:").strong());
66            ui.add_space(2.0);
67            egui::Frame::group(ui.style())
68                .fill(ui.visuals().extreme_bg_color)
69                .show(ui, |ui| {
70                    ui.set_min_height(box_height);
71                    ui.set_max_height(box_height);
72                    ui.set_width(ui.available_width());
73                    egui::ScrollArea::vertical()
74                        .id_salt("snapshot_list_scroll")
75                        .max_height(box_height)
76                        .min_scrolled_height(box_height)
77                        .auto_shrink([false, true])
78                        .show(ui, |ui| {
79                            if state.available_profiles.is_empty() {
80                                ui.label("No snapshots found.");
81                            } else {
82                                let mut grouped: BTreeMap<String, Vec<&LocalProfileInfo>> =
83                                    BTreeMap::new();
84                                for profile in &state.available_profiles {
85                                    let name = profile
86                                        .name
87                                        .as_deref()
88                                        .unwrap_or(&profile.filename)
89                                        .to_string();
90                                    grouped.entry(name).or_default().push(profile);
91                                }
92
93                                for (name, mut profiles) in grouped {
94                                    profiles.sort_by(|a, b| b.version.cmp(&a.version));
95
96                                    // Find which filename should be active for this group
97                                    let active_filename = state
98                                        .group_versions
99                                        .get(&name)
100                                        .cloned()
101                                        .unwrap_or_else(|| profiles[0].filename.clone());
102
103                                    // Ensure it exists in the current profiles (it might have been deleted)
104                                    let active_profile = profiles
105                                        .iter()
106                                        .find(|p| p.filename == active_filename)
107                                        .copied()
108                                        .unwrap_or(profiles[0]);
109
110                                    ui.horizontal(|ui| {
111                                        let mut label = egui::RichText::new(name.clone());
112                                        if !active_profile.is_valid {
113                                            label = label.color(egui::Color32::RED);
114                                        }
115
116                                        let is_selected = Some(&active_profile.filename)
117                                            == state.selected_profile.as_ref();
118                                        if ui.selectable_label(is_selected, label).clicked() {
119                                            state.selected_profile =
120                                                Some(active_profile.filename.clone());
121                                            state.group_versions.insert(
122                                                name.clone(),
123                                                active_profile.filename.clone(),
124                                            );
125                                        }
126
127                                        ui.with_layout(
128                                            egui::Layout::right_to_left(egui::Align::Center),
129                                            |ui| {
130                                                // Dropdown for versions if multiple versions exist
131                                                if profiles.len() > 1 {
132                                                    egui::ComboBox::from_id_salt(format!(
133                                                        "combo_{name}"
134                                                    ))
135                                                    .selected_text(
136                                                        active_profile
137                                                            .version
138                                                            .as_deref()
139                                                            .unwrap_or_default(),
140                                                    )
141                                                    .show_ui(ui, |ui| {
142                                                        for p in profiles {
143                                                            let version_text = p
144                                                                .version
145                                                                .as_deref()
146                                                                .unwrap_or_default();
147                                                            if ui
148                                                                .selectable_value(
149                                                                    &mut state.selected_profile,
150                                                                    Some(p.filename.clone()),
151                                                                    version_text,
152                                                                )
153                                                                .clicked()
154                                                            {
155                                                                state.group_versions.insert(
156                                                                    name.clone(),
157                                                                    p.filename.clone(),
158                                                                );
159                                                            }
160                                                        }
161                                                    });
162                                                } else if let Some(version) =
163                                                    &active_profile.version
164                                                {
165                                                    ui.label(egui::RichText::new(version).weak());
166                                                }
167
168                                                if active_profile.is_remote {
169                                                    ui.label("โ˜")
170                                                        .on_hover_text("Available on cloud");
171                                                }
172
173                                                if !active_profile.is_valid {
174                                                    ui.label("โš ").on_hover_text(
175                                                        "Invalid or tampered snapshot",
176                                                    );
177                                                }
178                                            },
179                                        );
180                                    });
181                                }
182                            }
183                        });
184                });
185        });
186
187        // --- Right Column: Description ---
188        cols[1].vertical(|ui| {
189            ui.label(egui::RichText::new("Description:").strong());
190            ui.add_space(2.0);
191            egui::Frame::group(ui.style())
192                .fill(ui.visuals().extreme_bg_color)
193                .show(ui, |ui| {
194                    ui.set_min_height(box_height);
195                    ui.set_max_height(box_height);
196                    ui.set_width(ui.available_width());
197
198                    if let Some(filename) = &state.selected_profile {
199                        if let Some(profile) = state
200                            .available_profiles
201                            .iter()
202                            .find(|p| &p.filename == filename)
203                        {
204                            ui.label(
205                                profile
206                                    .description
207                                    .as_deref()
208                                    .unwrap_or("No description provided."),
209                            );
210                        } else {
211                            ui.label(egui::RichText::new("No description provided.").weak());
212                        }
213                    } else {
214                        ui.label(egui::RichText::new("Select a snapshot to see details.").weak());
215                    }
216                });
217        });
218    });
219
220    // --- Bottom Section: Create Button (Left) and Actions (Right) ---
221    ui.columns(2, |cols| {
222        // Left: Create Button
223        cols[0].vertical(|ui| {
224            ui.add_space(8.0);
225            if ui
226                .add_enabled(
227                    !state.is_running,
228                    egui::Button::new("โž• Create New Snapshot"),
229                )
230                .on_hover_text("Open a dialog to create a new game file snapshot.")
231                .clicked()
232            {
233                // Reset fields and show popup
234                state.snapshot_name = "snapshot".to_string();
235                state.snapshot_description.clear();
236                state.snapshot_version.clear();
237                state.snapshot_compression = 0;
238                state.snapshot_directory = None;
239                state.show_create_popup = true;
240            }
241        });
242
243        // Right: Restore & Upload Buttons
244        cols[1].vertical(|ui| {
245            ui.add_space(8.0);
246            ui.horizontal(|ui| {
247                let has_selection = state.selected_profile.is_some();
248
249                let has_game_exe = storage.game_path().join("Supreme.exe").exists();
250                let selected_is_remote = state
251                    .selected_profile
252                    .as_ref()
253                    .and_then(|f| state.available_profiles.iter().find(|p| &p.filename == f))
254                    .is_some_and(|p| p.is_remote);
255
256                let restore_enabled =
257                    !state.is_running && has_selection && (!selected_is_remote || has_game_exe);
258
259                if ui
260                    .add_enabled(restore_enabled, egui::Button::new("โ™ป Restore"))
261                    .on_hover_text(if selected_is_remote && !has_game_exe {
262                        "Restoration unavailable when the file Supreme.exe is missing."
263                    } else if !has_selection {
264                        "Select a snapshot to restore."
265                    } else if state.is_running {
266                        "Cannot restore while an operation is running."
267                    } else {
268                        "Restore the selected snapshot."
269                    })
270                    .clicked()
271                {
272                    state.show_restore_popup = true;
273                }
274
275                if ui
276                    .add_enabled(
277                        !state.is_running && has_selection && (has_token || !storage.is_online()),
278                        egui::Button::new("โ˜ Upload"),
279                    )
280                    .on_hover_text(if has_token || !storage.is_online() {
281                        "Upload snapshot and required files to the server."
282                    } else {
283                        "Cloud upload requires SSBZ_API_TOKEN to be set."
284                    })
285                    .clicked()
286                    && let Some(filename) = state.selected_profile.clone()
287                {
288                    start_upload_snapshot(ui.ctx().clone(), state, storage, filename);
289                }
290            });
291        });
292    });
293
294    // --- Create Snapshot Popup ---
295    if state.show_create_popup {
296        let mut close_popup = false;
297        egui::Window::new("Create Snapshot")
298            .collapsible(false)
299            .resizable(false)
300            .show(ui.ctx(), |ui| {
301                ui.vertical(|ui| {
302                    ui.label("Give your snapshot a name and description.");
303                    ui.add_space(8.0);
304
305                    egui::Grid::new("popup_creation_grid")
306                        .num_columns(2)
307                        .spacing([12.0, 8.0])
308                        .show(ui, |ui| {
309                            ui.label("Name:");
310                            ui.add(
311                                egui::TextEdit::singleline(&mut state.snapshot_name)
312                                    .hint_text("Required"),
313                            );
314                            ui.end_row();
315
316                            ui.label("Path:");
317                            ui.horizontal(|ui| {
318                                let path_str = state
319                                    .snapshot_directory
320                                    .as_ref()
321                                    .map(|p| p.to_string_lossy().to_string())
322                                    .unwrap_or_else(|| {
323                                        storage.game_path().to_string_lossy().to_string()
324                                    });
325                                ui.label(egui::RichText::new(path_str).small());
326                                if ui.button("๐Ÿ“ Pick...").clicked()
327                                    && let Some(path) = rfd::FileDialog::new()
328                                        .set_directory(storage.game_path())
329                                        .pick_folder()
330                                {
331                                    state.snapshot_directory = Some(path);
332                                }
333                            });
334                            ui.end_row();
335
336                            ui.label("Description:");
337                            ui.add(
338                                egui::TextEdit::multiline(&mut state.snapshot_description)
339                                    .hint_text("Optional details...")
340                                    .desired_rows(3)
341                                    .lock_focus(true),
342                            );
343                            ui.end_row();
344
345                            ui.label("Version:");
346                            ui.add(
347                                egui::TextEdit::singleline(&mut state.snapshot_version)
348                                    .hint_text("Optional version string..."),
349                            );
350                            ui.end_row();
351
352                            ui.label("Compression Level:");
353                            ui.add(
354                                egui::Slider::new(&mut state.snapshot_compression, 0..=8)
355                                    .text("0=None, 8=Max"),
356                            );
357                            ui.end_row();
358                        });
359
360                    ui.add_space(12.0);
361
362                    ui.horizontal(|ui| {
363                        if ui.button("Cancel").clicked() {
364                            close_popup = true;
365                        }
366
367                        if ui
368                            .add_enabled(
369                                !state.snapshot_name.trim().is_empty(),
370                                egui::Button::new("๐Ÿ“ท Create"),
371                            )
372                            .clicked()
373                        {
374                            start_create_snapshot(
375                                ui.ctx().clone(),
376                                state,
377                                storage,
378                                SnapshotRequest {
379                                    name: state.snapshot_name.clone(),
380                                    description: if state.snapshot_description.is_empty() {
381                                        None
382                                    } else {
383                                        Some(state.snapshot_description.clone())
384                                    },
385                                    version: if state.snapshot_version.is_empty() {
386                                        None
387                                    } else {
388                                        Some(state.snapshot_version.clone())
389                                    },
390                                    custom_directory: state.snapshot_directory.clone(),
391                                    compression_level: state.snapshot_compression,
392                                },
393                            );
394                            close_popup = true;
395                        }
396                    });
397                });
398            });
399
400        if close_popup {
401            state.show_create_popup = false;
402        }
403    }
404
405    // --- Restore Snapshot Popup ---
406    if state.show_restore_popup {
407        if let Some(filename) = state.selected_profile.clone() {
408            let snapshot_name = state
409                .available_profiles
410                .iter()
411                .find(|p| p.filename == filename)
412                .map(|p| {
413                    format!(
414                        "{} {}",
415                        p.name.as_deref().unwrap_or(&filename),
416                        p.version.as_deref().unwrap_or("")
417                    )
418                })
419                .unwrap_or(filename.clone());
420
421            let mut close_popup = false;
422            egui::Window::new("Confirm Restoration")
423                .collapsible(false)
424                .resizable(false)
425                .show(ui.ctx(), |ui| {
426                    ui.vertical(|ui| {
427                        ui.label(
428                            egui::RichText::new("โš  Warning: Snapshot restoration can fail.")
429                                .color(ui.visuals().warn_fg_color)
430                                .strong(),
431                        );
432                        ui.label("It is highly recommended to create a snapshot of your current game files before restoring.");
433                        ui.add_space(4.0);
434                        ui.label("Please report any failure along with the error you encountered.");
435                        ui.add_space(12.0);
436
437                        ui.horizontal(|ui| {
438                            if ui.button("Cancel").clicked() {
439                                close_popup = true;
440                            }
441
442                            if ui
443                                .button(format!("Restore {snapshot_name}"))
444                                .clicked()
445                            {
446                                start_restore_snapshot(ui.ctx().clone(), state, storage, filename.clone());
447                                close_popup = true;
448                            }
449                        });
450                    });
451                });
452
453            if close_popup {
454                state.show_restore_popup = false;
455            }
456        } else {
457            state.show_restore_popup = false;
458        }
459    }
460
461    ui.add_space(10.0);
462
463    // Path Validation Section
464    #[cfg(feature = "local")]
465    ui.group(|ui| {
466        ui.heading("Path Validation");
467        ui.label("Scan game files for broken path references.");
468
469        if ui.button("๐Ÿ” Scan for Broken Paths").clicked() && !state.is_running {
470            start_path_scan(ui.ctx().clone(), state, storage);
471        }
472
473        // Check if diagnostics report exists
474        let diagnostics_path = storage.game_path().join(".supreme/path_diagnostics.json");
475        state.path_diagnostics_available = diagnostics_path.exists();
476
477        if state.path_diagnostics_available {
478            ui.label(format!(
479                "๐Ÿ“„ Report available at: {}",
480                diagnostics_path.display()
481            ));
482        }
483    });
484
485    ui.add_space(10.0);
486
487    // Actions Section
488    #[cfg(feature = "local")]
489    ui.group(|ui| {
490        ui.heading("Actions");
491        ui.label("Batch operations for game maintenance.");
492
493        if ui
494            .button("๐Ÿ”ง Normalize case")
495            .on_hover_text("Make file and folder names case consistent.")
496            .clicked()
497            && !state.is_running
498        {
499            run_cleaner_task(
500                ui.ctx().clone(),
501                state,
502                storage,
503                "Normalize case",
504                supreme_cleaner::normalize_case,
505            );
506        }
507
508        if ui
509            .button("๐Ÿ”ง Fix object references")
510            .on_hover_text("Repair broken object paths and removal invalid entries.")
511            .clicked()
512            && !state.is_running
513        {
514            run_cleaner_task(
515                ui.ctx().clone(),
516                state,
517                storage,
518                "Fix object references",
519                supreme_cleaner::fix_object_references,
520            );
521        }
522
523        if ui
524            .button("๐Ÿงน Free-up textures")
525            .on_hover_text("Keep the best texture for each object.")
526            .clicked()
527            && !state.is_running
528        {
529            run_cleaner_task(
530                ui.ctx().clone(),
531                state,
532                storage,
533                "Free-up textures",
534                supreme_cleaner::free_up_textures,
535            );
536        }
537
538        if ui
539            .button("๐Ÿ”ง Patch game binaries")
540            .on_hover_text("Patch hardcoded paths in DLLs.")
541            .clicked()
542            && !state.is_running
543        {
544            run_cleaner_task(
545                ui.ctx().clone(),
546                state,
547                storage,
548                "Patch game binaries",
549                supreme_cleaner::patch_game_binaries,
550            );
551        }
552
553        if ui
554            .button("๐Ÿ“ฆ Consolidate object resources")
555            .on_hover_text("Ensure all object resources are local to their configuration file.")
556            .clicked()
557            && !state.is_running
558        {
559            run_cleaner_task(
560                ui.ctx().clone(),
561                state,
562                storage,
563                "Consolidate object resources",
564                supreme_cleaner::consolidate_resources,
565            );
566        }
567
568        if ui
569            .button("๐Ÿ—‘ Clean unused objects")
570            .on_hover_text("Remove object directories not referenced by object_classes.txt.")
571            .clicked()
572            && !state.is_running
573        {
574            run_cleaner_task(
575                ui.ctx().clone(),
576                state,
577                storage,
578                "Clean unused objects",
579                supreme_cleaner::clean_unused_objects,
580            );
581        }
582    });
583}