fix clippy warnings and cargo fmt issues

This commit is contained in:
2026-06-09 11:43:33 +02:00
parent 73147b6be1
commit 7730524ee2
8 changed files with 1276 additions and 500 deletions
+16 -8
View File
@@ -20,8 +20,7 @@ fn main() {
entries.sort_by(|a, b| {
let ma = a.metadata().ok().and_then(|m| m.modified().ok());
let mb = b.metadata().ok().and_then(|m| m.modified().ok());
mb.cmp(&ma)
.then_with(|| a.file_name().cmp(&b.file_name()))
mb.cmp(&ma).then_with(|| a.file_name().cmp(&b.file_name()))
});
println!(
@@ -41,14 +40,15 @@ fn main() {
.and_then(|m| m.modified().ok())
.and_then(|t| {
let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
chrono::Local
.timestamp_opt(secs as i64, 0)
.single()
chrono::Local.timestamp_opt(secs as i64, 0).single()
})
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string());
let meta = notalterra::gvas::extract_metadata(&path).ok();
let slot = meta.as_ref().and_then(|m| m.slot_name.clone()).unwrap_or_else(|| {
let slot = meta
.as_ref()
.and_then(|m| m.slot_name.clone())
.unwrap_or_else(|| {
notalterra::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into())
});
let display = meta
@@ -59,9 +59,17 @@ fn main() {
let label_num = slot.strip_prefix("savegame_").unwrap_or(&slot);
let first = seen.insert(slot.clone());
let label = if first { format!("Slot {label_num}") } else { String::new() };
let label = if first {
format!("Slot {label_num}")
} else {
String::new()
};
let typ = if is_online { "Multiplayer" } else { "Single Player" };
let typ = if is_online {
"Multiplayer"
} else {
"Single Player"
};
let sz = if size < 1024 {
format!("{size} B")
} else if size < 1024 * 1024 {
-2
View File
@@ -173,5 +173,3 @@ pub fn exe_dir() -> PathBuf {
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
}
+42 -22
View File
@@ -19,10 +19,19 @@ pub struct DiscoveredFolder {
/// The same patterns work on both platforms because UE5 keeps the same
/// directory layout regardless of OS.
const KNOWN_PATTERNS: &[(&str, &str)] = &[
("Steam (LocalLow)", "AppData/LocalLow/Unknown Worlds/Subnautica2"),
("Steam (LocalLow, alt)", "AppData/LocalLow/Unknown Worlds/Subnautica 2"),
(
"Steam (LocalLow)",
"AppData/LocalLow/Unknown Worlds/Subnautica2",
),
(
"Steam (LocalLow, alt)",
"AppData/LocalLow/Unknown Worlds/Subnautica 2",
),
("AppData Local", "AppData/Local/Subnautica2/Saved/SaveGames"),
("AppData Local (alt)", "AppData/Local/Subnautica 2/Saved/SaveGames"),
(
"AppData Local (alt)",
"AppData/Local/Subnautica 2/Saved/SaveGames",
),
("Xbox / Game Pass", "AppData/Local/Packages"), // partial — needs wildcard below
("Saved Games", "Saved Games/Subnautica2"),
("Saved Games (alt)", "Saved Games/Subnautica 2"),
@@ -91,8 +100,11 @@ pub fn discover_save_folders() -> Vec<DiscoveredFolder> {
if let Some(home) = dirs::home_dir() {
for (label, rel) in KNOWN_PATTERNS {
let candidate = home.join(rel);
if candidate.exists() && candidate.is_dir()
&& has_save_files(&candidate) && seen.insert(candidate.clone()) {
if candidate.exists()
&& candidate.is_dir()
&& has_save_files(&candidate)
&& seen.insert(candidate.clone())
{
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
@@ -140,11 +152,9 @@ fn has_save_files(dir: &Path) -> bool {
let check = |ext: &str| -> bool {
fs::read_dir(dir)
.map(|entries| {
entries.flatten().any(|e| {
e.file_name()
.to_string_lossy()
.ends_with(ext)
})
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().ends_with(ext))
})
.unwrap_or(false)
};
@@ -167,7 +177,10 @@ fn scan_other_users(
let user_path = user_dir.path();
for (label, rel) in KNOWN_PATTERNS {
let candidate = user_path.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
if candidate.exists()
&& has_save_files(&candidate)
&& seen.insert(candidate.clone())
{
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
@@ -195,7 +208,10 @@ fn scan_other_users(
let user_path = user_dir.path();
for (label, rel) in KNOWN_PATTERNS {
let candidate = user_path.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
if candidate.exists()
&& has_save_files(&candidate)
&& seen.insert(candidate.clone())
{
found.push(DiscoveredFolder {
label: label.to_string(),
path: candidate,
@@ -217,7 +233,10 @@ fn scan_other_users(
let pfx = app_entry.path().join("pfx/drive_c/users/steamuser");
for (label, rel) in KNOWN_PATTERNS {
let candidate = pfx.join(rel);
if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) {
if candidate.exists()
&& has_save_files(&candidate)
&& seen.insert(candidate.clone())
{
found.push(DiscoveredFolder {
label: format!("Steam Deck — {label}"),
path: candidate,
@@ -260,11 +279,7 @@ fn scan_common_install_dirs(
found: &mut Vec<DiscoveredFolder>,
seen: &mut std::collections::HashSet<PathBuf>,
) {
let roots: &[&str] = &[
"/opt",
"/usr/local/games",
"/usr/share/games",
];
let roots: &[&str] = &["/opt", "/usr/local/games", "/usr/share/games"];
for rt in roots {
let p = Path::new(rt);
if !p.exists() {
@@ -295,7 +310,10 @@ fn walk_for_subnautica(
while let Some(dir) = queue.pop_front() {
// Limit depth: don't recurse more than 5 levels from root
let depth = dir.components().count().saturating_sub(root.components().count());
let depth = dir
.components()
.count()
.saturating_sub(root.components().count());
if depth > 5 {
continue;
}
@@ -307,10 +325,12 @@ fn walk_for_subnautica(
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
if name.contains("subnautica")
&& has_save_files(&path) && seen.insert(path.clone()) {
if name.contains("subnautica") && has_save_files(&path) && seen.insert(path.clone()) {
found.push(DiscoveredFolder {
label: label.to_string(),
path: path.clone(),
+1 -6
View File
@@ -117,12 +117,7 @@ const MAX_LOG_LINES: usize = 10_000;
/// Format: `YYYY-MM-DD HH:MM:SS | ACTION | detail | result`
/// Auto-rotates if the log exceeds 10,000 lines — the oldest lines are
/// discarded, keeping only the most recent 10,000.
pub fn log_action(
action: &str,
detail: &str,
result: &str,
log_path: &Path,
) -> Result<()> {
pub fn log_action(action: &str, detail: &str, result: &str, log_path: &Path) -> Result<()> {
let stamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let line = format!("{stamp} | {action:<8} | {detail} | {result}\n");
+158 -43
View File
@@ -1,5 +1,9 @@
//! UE4/UE5 GVAS save-file binary parser.
//!
//! The GVAS serialization format is defined by the public Unreal Engine 5
//! source code — the SaveGame system and its binary layout are part of the
//! engine's open API.
//!
//! Ported from `legacy/extract_save_name.py`. Extracts `SlotName` and
//! `DisplayName` properties via manual binary walking, plus corruption
//! detection by cross-referencing metadata against the canonical filename
@@ -182,17 +186,42 @@ fn extract_bool_property(data: &[u8], prop_name: &str) -> Option<bool> {
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(20) && attempts < 100 {
let found = data[offset..].windows(target.len()).position(|w| w == target);
let found = match found { Some(p) => offset + p, None => return None };
if found < 4 { offset = found + 1; attempts += 1; continue; }
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
let name_len_field = read_u32(data, found - 4);
if name_len_field != Some(target.len() + 1) { offset = found + 1; attempts += 1; continue; }
if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; }
if name_len_field != Some(target.len() + 1) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let after_name = found + target.len() + 1;
let (next_name, next_offset) = read_fname(data, after_name);
if next_name.as_deref() != Some("BoolProperty") { offset = found + 1; attempts += 1; continue; }
if next_name.as_deref() != Some("BoolProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset >= data.len() { offset = found + 1; attempts += 1; continue; }
if val_offset >= data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return Some(data[val_offset] != 0);
}
None
@@ -203,7 +232,9 @@ fn scan_double_near(data: &[u8], marker: &[u8]) -> Option<f64> {
let pos = data.windows(marker.len()).position(|w| w == marker)?;
let _end = (pos + 60).min(data.len());
for off in 8..50 {
if pos + off + 8 > data.len() { break; }
if pos + off + 8 > data.len() {
break;
}
let val = f64::from_le_bytes(data[pos + off..pos + off + 8].try_into().ok()?);
if val > 60.0 && val < 10_000_000.0 {
return Some(val);
@@ -218,17 +249,44 @@ fn extract_double_property(data: &[u8], prop_name: &str) -> Option<f64> {
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(30) && attempts < 100 {
let found = data[offset..].windows(target.len()).position(|w| w == target);
let found = match found { Some(p) => offset + p, None => return None };
if found < 4 { offset = found + 1; attempts += 1; continue; }
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
let expected: usize = target.len() + 1;
if read_u32(data, found - 4) != Some(expected) { offset = found + 1; attempts += 1; continue; }
if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; }
if read_u32(data, found - 4) != Some(expected) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
if next_name.as_deref() != Some("DoubleProperty") { offset = found + 1; attempts += 1; continue; }
if next_name.as_deref() != Some("DoubleProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset + 8 > data.len() { offset = found + 1; attempts += 1; continue; }
return Some(f64::from_le_bytes(data[val_offset..val_offset+8].try_into().ok()?));
if val_offset + 8 > data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return Some(f64::from_le_bytes(
data[val_offset..val_offset + 8].try_into().ok()?,
));
}
None
}
@@ -239,15 +297,40 @@ fn extract_int_property(data: &[u8], prop_name: &str) -> Option<u32> {
let mut offset = 0usize;
let mut attempts = 0u32;
while offset < data.len().saturating_sub(20) && attempts < 100 {
let found = data[offset..].windows(target.len()).position(|w| w == target);
let found = match found { Some(p) => offset + p, None => return None };
if found < 4 { offset = found + 1; attempts += 1; continue; }
if read_u32(data, found - 4) != Some(target.len() + 1) { offset = found + 1; attempts += 1; continue; }
if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; }
let found = data[offset..]
.windows(target.len())
.position(|w| w == target);
let found = match found {
Some(p) => offset + p,
None => return None,
};
if found < 4 {
offset = found + 1;
attempts += 1;
continue;
}
if read_u32(data, found - 4) != Some(target.len() + 1) {
offset = found + 1;
attempts += 1;
continue;
}
if found + target.len() >= data.len() || data[found + target.len()] != 0 {
offset = found + 1;
attempts += 1;
continue;
}
let (next_name, next_offset) = read_fname(data, found + target.len() + 1);
if next_name.as_deref() != Some("IntProperty") { offset = found + 1; attempts += 1; continue; }
if next_name.as_deref() != Some("IntProperty") {
offset = found + 1;
attempts += 1;
continue;
}
let val_offset = next_offset + 9;
if val_offset + 4 > data.len() { offset = found + 1; attempts += 1; continue; }
if val_offset + 4 > data.len() {
offset = found + 1;
attempts += 1;
continue;
}
return read_u32(data, val_offset).map(|v| v as u32);
}
None
@@ -274,8 +357,7 @@ pub struct FullMetadata {
/// Parse a `.sav` or `.bak` file and return all known GVAS metadata.
pub fn extract_full_metadata(path: &Path) -> Result<FullMetadata> {
let data = fs::read(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(FullMetadata {
slot_name: extract_str_property(&data, "SlotName").ok(),
display_name: extract_str_property(&data, "DisplayName").ok(),
@@ -318,11 +400,17 @@ pub fn extract_metadata_from_bytes(data: &[u8]) -> Result<SaveMetadata> {
let mut errors = Vec::new();
let slot_name = match extract_str_property(data, "SlotName") {
Ok(v) => Some(v),
Err(e) => { errors.push(e); None }
Err(e) => {
errors.push(e);
None
}
};
let display_name = match extract_str_property(data, "DisplayName") {
Ok(v) => Some(v),
Err(e) => { errors.push(e); None }
Err(e) => {
errors.push(e);
None
}
};
Ok(SaveMetadata {
slot_name,
@@ -334,8 +422,7 @@ pub fn extract_metadata_from_bytes(data: &[u8]) -> Result<SaveMetadata> {
}
pub fn extract_metadata(path: &Path) -> Result<SaveMetadata> {
let data = fs::read(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let mut errors = Vec::new();
let slot_name = match extract_str_property(&data, "SlotName") {
@@ -431,10 +518,7 @@ mod tests {
#[test]
fn test_corruption_check() {
// Canonical live file
assert_eq!(
corruption_check("savegame_0.sav", Some("savegame_0")),
None
);
assert_eq!(corruption_check("savegame_0.sav", Some("savegame_0")), None);
// Versioned .sav is non-canonical
assert_eq!(
corruption_check("savegame_0_9.sav", Some("savegame_0")),
@@ -456,7 +540,9 @@ mod tests {
fn dump_all_samples() {
use chrono::TimeZone;
let dir = std::path::Path::new("samples");
let Ok(entries) = std::fs::read_dir(dir) else { return };
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
@@ -474,27 +560,54 @@ mod tests {
let mb = b.metadata().ok().and_then(|m| m.modified().ok());
sa.cmp(&sb).then_with(|| mb.cmp(&ma))
});
println!("\n{:<8} {:<26} {:<6} {:>7} {:<19} {:<28}", "", "Display Name", "Type", "Size", "Date", "File");
println!(
"\n{:<8} {:<26} {:<6} {:>7} {:<19} {:<28}",
"", "Display Name", "Type", "Size", "Date", "File"
);
println!("{}", "-".repeat(115));
let mut seen = std::collections::HashSet::new();
for entry in &files {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let mtime = entry.metadata().ok().and_then(|m| m.modified().ok())
.and_then(|t| { let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); chrono::Local.timestamp_opt(s as i64,0).single() })
let mtime = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| {
let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
chrono::Local.timestamp_opt(s as i64, 0).single()
})
.map(|dt| dt.format("%Y-%b-%d %H:%M").to_string());
let meta = extract_metadata(&path).ok();
let slot = meta.as_ref().and_then(|m| m.slot_name.clone())
let slot = meta
.as_ref()
.and_then(|m| m.slot_name.clone())
.unwrap_or_else(|| derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()));
let display = meta.as_ref().and_then(|m| m.display_name.clone()).unwrap_or_else(|| "(unnamed)".into());
let display = meta
.as_ref()
.and_then(|m| m.display_name.clone())
.unwrap_or_else(|| "(unnamed)".into());
let online = meta.map(|m| m.is_online).unwrap_or(false);
let num = slot.strip_prefix("savegame_").unwrap_or(&slot);
let first = seen.insert(slot.clone());
let label = if first { format!("Slot {num}") } else { String::new() };
let label = if first {
format!("Slot {num}")
} else {
String::new()
};
let typ = if online { "Online" } else { "Local" };
let sz = if size < 1024 { format!("{size} B") } else if size < 1_048_576 { format!("{:.0} KB", size as f64 / 1024.0) } else { format!("{:.1} MB", size as f64 / 1_048_576.0) };
println!("{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}", mtime.as_deref().unwrap_or("?"));
let sz = if size < 1024 {
format!("{size} B")
} else if size < 1_048_576 {
format!("{:.0} KB", size as f64 / 1024.0)
} else {
format!("{:.1} MB", size as f64 / 1_048_576.0)
};
println!(
"{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}",
mtime.as_deref().unwrap_or("?")
);
}
println!();
}
@@ -503,7 +616,9 @@ mod tests {
/// Test helper — dump full GVAS metadata for a sample file.
fn print_full_meta() {
let p = Path::new("samples/savegame_1.sav");
if !p.exists() { return; }
if !p.exists() {
return;
}
let m = extract_full_metadata(p).unwrap();
println!("slot: {:?}", m.slot_name);
println!("display: {:?}", m.display_name);
+404 -117
View File
@@ -7,8 +7,6 @@
//!
//! MIT License. Not affiliated with Unknown Worlds Entertainment or KRAFTON.
mod config;
mod discovery;
mod guard;
@@ -19,7 +17,10 @@ mod tui;
use anyhow::Result;
use chrono::TimeZone;
use crossterm::{
event::{self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEventKind},
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
@@ -57,7 +58,12 @@ fn main() -> Result<()> {
// ── setup terminal ─────────────────────────────────────────────────
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?;
execute!(
stdout,
EnterAlternateScreen,
EnableBracketedPaste,
EnableMouseCapture
)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
@@ -87,7 +93,10 @@ struct App {
impl App {
fn new() -> Result<Self> {
let log_path = guard::log_path();
let tui_state = tui::AppState { version: VERSION.to_string(), ..Default::default() };
let tui_state = tui::AppState {
version: VERSION.to_string(),
..Default::default()
};
Ok(Self {
log_path,
save_folder: None,
@@ -143,7 +152,10 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let mut app = App::new()?;
// Reminder — the user should close the game before using the tool
ok_dialog(terminal, &app, "Before You Begin",
ok_dialog(
terminal,
&app,
"Before You Begin",
"Please close Subnautica 2 before using NotAlterra.\n\
\n\
The game holds file locks on your save files while active.\n\
@@ -160,7 +172,12 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
// Migrate old transaction.log into logs/ directory
if guard::migrate_old_log() {
guard::log_action("MIGRATE", "old transaction.log moved to logs/", "OK", &app.log_path)?;
guard::log_action(
"MIGRATE",
"old transaction.log moved to logs/",
"OK",
&app.log_path,
)?;
migrated_something = true;
}
@@ -180,7 +197,10 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
// Notify the user about completed migrations
if migrated_something {
app.set_status("Data migrated from previous version. Old files remain — delete manually if desired.", tui::StatusStyle::Info);
app.set_status(
"Data migrated from previous version. Old files remain — delete manually if desired.",
tui::StatusStyle::Info,
);
}
// Quick check of common save locations (current user only, no profile scans)
@@ -233,13 +253,17 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let max_idx = 10usize;
if event::poll(std::time::Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Release { continue; }
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Up => {
let mut i = menu_state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !SKIP.contains(&i) || i == 0 { break; }
if !SKIP.contains(&i) || i == 0 {
break;
}
}
menu_state.select(Some(i));
}
@@ -247,7 +271,9 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let mut i = menu_state.selected().unwrap_or(0);
loop {
i = (i + 1).min(max_idx);
if !SKIP.contains(&i) || i == max_idx { break; }
if !SKIP.contains(&i) || i == max_idx {
break;
}
}
menu_state.select(Some(i));
}
@@ -295,7 +321,9 @@ fn run_disclaimer<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resu
})?;
if let Some(key) = poll_key(250)? {
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => selected_yes = !selected_yes,
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => {
selected_yes = !selected_yes
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
guard::log_action("LICENSE", "accepted", "OK", &app.log_path)?;
crate::config::accept_disclaimer()?;
@@ -323,15 +351,13 @@ fn run_disclaimer<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resu
}
}
// ── menu actions ───────────────────────────────────────────────────────────
/// Open the input dialog for the user to type a save-folder path.
/// Validates the path exists and contains .sav files before accepting it.
fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let mut input_state = tui::InputDialogState::new(
"Enter the path to your Subnautica 2 SaveGames folder:",
);
let mut input_state =
tui::InputDialogState::new("Enter the path to your Subnautica 2 SaveGames folder:");
let mut ok_selected = true;
loop {
@@ -342,13 +368,17 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
match crossterm::event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Release { continue; }
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Enter => {
if ok_selected && !input_state.input.is_empty() {
// Sanitize: strip control characters to prevent
// config.ini injection and log forgery.
let sanitized: String = input_state.input.chars()
let sanitized: String = input_state
.input
.chars()
.filter(|c| !c.is_control())
.collect();
let candidate = discovery::validate_custom_path(&sanitized);
@@ -365,13 +395,16 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
return Ok(());
} else {
// Invalid path — show error and let them retry
ok_dialog(terminal, app, "Invalid Path",
ok_dialog(
terminal,
app,
"Invalid Path",
"The path you entered does not exist or\n\
does not contain any .sav save files.\n\
\n\
Please enter the full path to your\n\
SaveGames folder (e.g.\n\
/home/user/.../SaveGames)."
/home/user/.../SaveGames).",
)?;
input_state.reset();
ok_selected = true;
@@ -421,10 +454,8 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
fn action_set_backup_location<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let default = dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(|| crate::config::exe_dir());
let mut input_state = tui::InputDialogState::new(
"Enter the path for storing backup archives:",
);
.unwrap_or_else(crate::config::exe_dir);
let mut input_state = tui::InputDialogState::new("Enter the path for storing backup archives:");
input_state.input = default.to_string_lossy().to_string();
input_state.cursor = input_state.input.len();
let mut ok_selected = true;
@@ -437,16 +468,23 @@ fn action_set_backup_location<B: Backend>(terminal: &mut Terminal<B>, app: &mut
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
match crossterm::event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Release { continue; }
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Enter => {
if ok_selected && !input_state.input.is_empty() {
let sanitized: String = input_state.input.chars()
let sanitized: String = input_state
.input
.chars()
.filter(|c| !c.is_control())
.collect();
let path = std::path::PathBuf::from(&sanitized);
crate::config::set_backup_root(path.clone());
let save_str = app.save_folder.as_ref().map(|p| p.to_string_lossy().to_string());
let save_str = app
.save_folder
.as_ref()
.map(|p| p.to_string_lossy().to_string());
crate::config::save_app_config(
save_str.as_deref(),
Some(&sanitized),
@@ -525,15 +563,19 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
// Build multi-column display with slot grouping.
// First entry in each slot gets a numbered label matching the savegame slot.
let mut labelled: std::collections::HashSet<String> = std::collections::HashSet::new();
let header = format!(" {:<8} {:<24} {}",
"Slot", "Description", "Date");
let header = format!(" {:<8} {:<24} {}", "Slot", "Description", "Date");
let mut items: Vec<String> = vec![header, String::new()];
items.extend(bak_summaries
items.extend(
bak_summaries
.iter()
.map(|s| {
let num = slot_number(&s.slot);
let first = labelled.insert(s.slot.clone());
let label_col = if first { format!("Slot {num}") } else { String::new() };
let label_col = if first {
format!("Slot {num}")
} else {
String::new()
};
let name = s.display_name.as_deref().unwrap_or("(unnamed)");
let name_col = if name.len() > 24 {
format!("{}", &name[..23])
@@ -541,21 +583,14 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
name.to_string()
};
let date = s.mtime.as_deref().unwrap_or("?");
format!(
" {:<8} {:<24} {}",
label_col,
name_col,
date,
)
format!(" {:<8} {:<24} {}", label_col, name_col, date,)
})
.collect::<Vec<String>>());
.collect::<Vec<String>>(),
);
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
// Filenames for the info bar, and descriptions
let filenames: Vec<String> = bak_summaries
.iter()
.map(|s| s.filename.clone())
.collect();
let filenames: Vec<String> = bak_summaries.iter().map(|s| s.filename.clone()).collect();
let descs: Vec<String> = bak_summaries
.iter()
.map(|_| "Restore this backup to its canonical .sav file".to_string())
@@ -570,7 +605,7 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
fn ensure_meta(
idx: usize,
bak: &[ops::BakFileSummary],
cache: &mut Vec<Option<crate::gvas::FullMetadata>>,
cache: &mut [Option<crate::gvas::FullMetadata>],
) {
if idx < cache.len() && cache[idx].is_none() {
cache[idx] = crate::gvas::extract_full_metadata(&bak[idx].path).ok();
@@ -591,14 +626,43 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
let pt = m.playtime_seconds.or(summary.playtime_seconds);
let playtime = format_playtime(pt);
let fields: Vec<(&str, String)> = vec![
("Slot", m.slot_name.as_deref().unwrap_or(&summary.slot).to_string()),
("Name", m.display_name.as_deref().unwrap_or("(unnamed)").to_string()),
(
"Slot",
m.slot_name.as_deref().unwrap_or(&summary.slot).to_string(),
),
(
"Name",
m.display_name.as_deref().unwrap_or("(unnamed)").to_string(),
),
("Playtime", playtime),
("Game Type", m.game_mode.as_deref().unwrap_or("?").to_string()),
("Mode", if m.is_online { "Multiplayer".into() } else { "Single Player".into() }),
("Was Multi", if m.was_multiplayer { "Yes".into() } else { "No".into() }),
("Branch", m.build_branch.as_deref().unwrap_or("?").to_string()),
("Build", m.build_number.map_or("?".into(), |n| n.to_string())),
(
"Game Type",
m.game_mode.as_deref().unwrap_or("?").to_string(),
),
(
"Mode",
if m.is_online {
"Multiplayer".into()
} else {
"Single Player".into()
},
),
(
"Was Multi",
if m.was_multiplayer {
"Yes".into()
} else {
"No".into()
},
),
(
"Branch",
m.build_branch.as_deref().unwrap_or("?").to_string(),
),
(
"Build",
m.build_number.map_or("?".into(), |n| n.to_string()),
),
];
let max_label: usize = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(6);
fields
@@ -628,8 +692,6 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
let meta = full_metas.get(sel_idx).and_then(|m| m.as_ref());
let meta_lines = if let Some(m) = meta {
build_meta_lines(Some(m), &bak_summaries[sel_idx])
} else if sel_idx < bak_summaries.len() {
Vec::new()
} else {
Vec::new()
};
@@ -657,7 +719,9 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
}
KeyCode::Enter => {
let sel = state.selected().unwrap_or(2);
if sel < 2 { continue; } // header + blank
if sel < 2 {
continue;
} // header + blank
let idx = sel.saturating_sub(2);
let chosen = &bak_summaries[idx];
let target = derive_target_sav(&chosen.filename);
@@ -676,15 +740,27 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
let disp = meta.and_then(|m| m.display_name);
(sz, mt, online, disp)
})
} else { None };
} else {
None
};
// Mode change warning
let mode_entry = target_meta.as_ref().and_then(|(_, _, live, _)| {
if *live != chosen.is_online {
let from = if *live { "Multiplayer" } else { "Single Player" };
let to = if chosen.is_online { "Multiplayer" } else { "Single Player" };
let from = if *live {
"Multiplayer"
} else {
"Single Player"
};
let to = if chosen.is_online {
"Multiplayer"
} else {
"Single Player"
};
Some(("⚠ Mode change", format!("{from}{to}")))
} else { None }
} else {
None
}
});
// Name change warning
let name_entry = target_meta.as_ref().and_then(|(_, _, _, live_name)| {
@@ -698,15 +774,30 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
});
// Build details
let src_line = format!("{} {} {}", chosen.filename, format_size(chosen.size), chosen.mtime.as_deref().unwrap_or("?"));
let src_line = format!(
"{} {} {}",
chosen.filename,
format_size(chosen.size),
chosen.mtime.as_deref().unwrap_or("?")
);
let mut details = vec![
("Slot", chosen.slot.as_str()),
("Name", chosen.display_name.as_deref().unwrap_or("(unnamed)")),
(
"Name",
chosen.display_name.as_deref().unwrap_or("(unnamed)"),
),
("Backup", src_line.as_str()),
];
let tgt_line: String;
if let Some((sz, mt, _, _)) = &target_meta {
tgt_line = format!("{} {} {}", target, format_size(*sz), mt.map(|d| d.format("%Y-%b-%d %H:%M").to_string()).as_deref().unwrap_or("?"));
tgt_line = format!(
"{} {} {}",
target,
format_size(*sz),
mt.map(|d| d.format("%Y-%b-%d %H:%M").to_string())
.as_deref()
.unwrap_or("?")
);
details.push(("Replace", tgt_line.as_str()));
} else {
details.push(("Create", target.as_str()));
@@ -778,7 +869,11 @@ fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
match ops::create_full_backup(&save_folder) {
Ok(result) => {
app.set_spinner(false);
let verified = if result.verified { "verified" } else { "unverified" };
let verified = if result.verified {
"verified"
} else {
"unverified"
};
let msg = format!(
"{} files, {} — backup {}",
result.files_copied,
@@ -791,7 +886,12 @@ fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
Err(e) => {
app.set_spinner(false);
let msg = format!("Backup failed: {e}");
guard::log_action("MANUAL_BAK", &guard::sanitize_path(&save_folder.display().to_string()), &format!("FAILED: {e}"), &app.log_path)?;
guard::log_action(
"MANUAL_BAK",
&guard::sanitize_path(&save_folder.display().to_string()),
&format!("FAILED: {e}"),
&app.log_path,
)?;
ok_dialog(terminal, app, "Backup Failed", &msg)?;
}
}
@@ -841,18 +941,47 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
if !has_existing_backup(app) {
restore_details.push(("⚠ No backup", "create a full backup first"));
}
let accepted = confirm_modal(terminal, app, "Confirm Restore", &restore_details)?;
let accepted =
confirm_modal(terminal, app, "Confirm Restore", &restore_details)?;
if accepted {
guard::log_action("AUTO_BAK", &format!("pre-restore → {}", guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?;
guard::log_action(
"AUTO_BAK",
&format!(
"pre-restore → {}",
guard::sanitize_path(&save_folder.display().to_string())
),
"OK",
&app.log_path,
)?;
match ops::restore_full_backup(chosen, &save_folder) {
Ok(n) => {
app.set_status(&format!("{n} save files restored."), tui::StatusStyle::Success);
guard::log_action("RESTORE", &format!("{} {}", name, guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?;
app.set_status(
&format!("{n} save files restored."),
tui::StatusStyle::Success,
);
guard::log_action(
"RESTORE",
&format!(
"{}{}",
name,
guard::sanitize_path(&save_folder.display().to_string())
),
"OK",
&app.log_path,
)?;
}
Err(e) => {
app.set_status(&format!("Restore failed: {e}"), tui::StatusStyle::Error);
guard::log_action("RESTORE", &name, &format!("FAILED: {e}"), &app.log_path)?;
app.set_status(
&format!("Restore failed: {e}"),
tui::StatusStyle::Error,
);
guard::log_action(
"RESTORE",
&name,
&format!("FAILED: {e}"),
&app.log_path,
)?;
}
}
}
@@ -894,7 +1023,14 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
loop {
terminal.draw(|f| {
tui::draw_sub_menu(f, &app.tui_state, "Config (.ini) Management", &items, &descs, &mut state);
tui::draw_sub_menu(
f,
&app.tui_state,
"Config (.ini) Management",
&items,
&descs,
&mut state,
);
})?;
if let Some(key) = poll_key(250)? {
match key.code {
@@ -902,7 +1038,9 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
let mut i = state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !INI_SKIP.contains(&i) || i == 0 { break; }
if !INI_SKIP.contains(&i) || i == 0 {
break;
}
}
state.select(Some(i));
}
@@ -910,7 +1048,9 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
let mut i = state.selected().unwrap_or(0);
loop {
i = (i + 1).min(ini_max);
if !INI_SKIP.contains(&i) || i == ini_max { break; }
if !INI_SKIP.contains(&i) || i == ini_max {
break;
}
}
state.select(Some(i));
}
@@ -942,15 +1082,34 @@ fn ini_backup_action<B: Backend>(
) -> Result<()> {
match ops::backup_ini_files(ini_path) {
Ok(result) => {
let verified = if result.verified { "verified" } else { "unverified" };
let verified = if result.verified {
"verified"
} else {
"unverified"
};
app.set_status(
&format!("Config backup created: {} files ({})", result.files_copied, verified),
&format!(
"Config backup created: {} files ({})",
result.files_copied, verified
),
tui::StatusStyle::Success,
);
guard::log_action("CONFIG_BAK", &guard::sanitize_path(&result.dest_path.display().to_string()), "OK", &app.log_path)?;
guard::log_action(
"CONFIG_BAK",
&guard::sanitize_path(&result.dest_path.display().to_string()),
"OK",
&app.log_path,
)?;
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
let verified = if result.verified { "verified" } else { "unverified" };
let msg = format!("{} .ini file(s) backed up ({verified}).", result.files_copied);
let verified = if result.verified {
"verified"
} else {
"unverified"
};
let msg = format!(
"{} .ini file(s) backed up ({verified}).",
result.files_copied
);
ok_dialog(terminal, app, ".ini Backup Complete", &msg)?;
}
Err(e) => {
@@ -970,7 +1129,12 @@ fn ini_restore_action<B: Backend>(
) -> Result<()> {
let backups = ops::list_ini_backups();
if backups.is_empty() {
ok_dialog(terminal, app, "No .ini Backups", "No .ini backups found.\nUse 'Backup .ini files' first.")?;
ok_dialog(
terminal,
app,
"No .ini Backups",
"No .ini backups found.\nUse 'Backup .ini files' first.",
)?;
return Ok(());
}
@@ -1003,10 +1167,23 @@ fn ini_restore_action<B: Backend>(
let idx = state.selected().unwrap_or(0);
let chosen = &backups[idx];
guard::log_action("AUTO_BAK", &format!("ini pre-restore → {}", guard::sanitize_path(&ini_path.display().to_string())), "OK", &app.log_path)?;
guard::log_action(
"AUTO_BAK",
&format!(
"ini pre-restore → {}",
guard::sanitize_path(&ini_path.display().to_string())
),
"OK",
&app.log_path,
)?;
match ops::restore_ini_files(chosen, ini_path) {
Ok(n) => {
guard::log_action("CONFIG_RESTORE", &guard::sanitize_path(&chosen.display().to_string()), "OK", &app.log_path)?;
guard::log_action(
"CONFIG_RESTORE",
&guard::sanitize_path(&chosen.display().to_string()),
"OK",
&app.log_path,
)?;
let msg = format!("{n} .ini file(s) restored.");
ok_dialog(terminal, app, ".ini Restore Complete", &msg)?;
}
@@ -1035,21 +1212,28 @@ fn ini_delete_action<B: Backend>(
let config_dir = crate::config::backups_config_dir();
let has_backup = config_dir.exists()
&& std::fs::read_dir(&config_dir)
.map(|entries| entries.flatten().any(|e| {
.map(|entries| {
entries.flatten().any(|e| {
let file_name = e.file_name();
let name = file_name.to_string_lossy();
name.starts_with("ini_backup_")
&& e.path().is_dir()
&& std::fs::read_dir(e.path()).is_ok_and(|mut d| {
d.any(|f| f.ok().is_some_and(|f| {
d.any(|f| {
f.ok().is_some_and(|f| {
f.file_name().to_string_lossy().ends_with(".ini")
}))
})
}))
})
})
})
})
.unwrap_or(false);
if !has_backup {
ok_dialog(terminal, app, "No Backup Found",
ok_dialog(
terminal,
app,
"No Backup Found",
"No .ini backup directory found.\n\
\n\
Run \"Backup .ini files\" first to create a snapshot\n\
@@ -1060,8 +1244,15 @@ fn ini_delete_action<B: Backend>(
match ops::delete_ini_files(ini_path) {
Ok(n) => {
let msg = format!("Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch.");
guard::log_action("CONFIG_DEL", &guard::sanitize_path(&ini_path.display().to_string()), "OK", &app.log_path)?;
let msg = format!(
"Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch."
);
guard::log_action(
"CONFIG_DEL",
&guard::sanitize_path(&ini_path.display().to_string()),
"OK",
&app.log_path,
)?;
ok_dialog(terminal, app, ".ini Delete Complete", &msg)?;
}
Err(e) => {
@@ -1089,20 +1280,30 @@ fn action_inspect_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
files.sort_by_key(|e| e.file_name());
let mut labelled = std::collections::HashSet::new();
let mut items: Vec<String> = files.iter().map(|e| {
let mut items: Vec<String> = files
.iter()
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let slot = crate::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into());
let num = slot_number(&slot);
let first = labelled.insert(slot.clone());
let label = if first { format!("Slot {num}") } else { String::new() };
let label = if first {
format!("Slot {num}")
} else {
String::new()
};
let sz = e.metadata().map(|m| m.len()).unwrap_or(0);
format!(" {:<8} {:<28} {:>7}", label, name, format_size(sz))
}).collect();
})
.collect();
let header = format!(" {:<8} {:<28} {:>7}", "Slot", "Filename", "Size");
items.insert(0, header);
items.insert(1, String::new());
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let filenames: Vec<String> = files.iter().map(|e| e.file_name().to_string_lossy().to_string()).collect();
let filenames: Vec<String> = files
.iter()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let descs = vec!["Press Enter to view full GVAS metadata"; files.len()];
let desc_refs: Vec<&str> = descs.to_vec();
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
@@ -1112,15 +1313,30 @@ fn action_inspect_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
state.select(Some(i));
let selected_info = filenames.get(i.saturating_sub(2)).map(|s| s.as_str());
terminal.draw(|f| {
tui::draw_picker_with_info(f, &app.tui_state, &item_refs, &desc_refs, &mut state, selected_info);
tui::draw_picker_with_info(
f,
&app.tui_state,
&item_refs,
&desc_refs,
&mut state,
selected_info,
);
})?;
if let Some(key) = poll_key(250)? {
match key.code {
KeyCode::Up => { let i = state.selected().unwrap_or(0); state.select(Some(i.saturating_sub(1))); }
KeyCode::Down => { let i = state.selected().unwrap_or(0); state.select(Some((i+1).min(items.len().saturating_sub(1)))); }
KeyCode::Up => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(items.len().saturating_sub(1))));
}
KeyCode::Enter => {
let sel = state.selected().unwrap_or(2);
if sel < 2 { continue; }
if sel < 2 {
continue;
}
let idx = sel.saturating_sub(2);
let path = files[idx].path();
match crate::gvas::extract_full_metadata(&path) {
@@ -1129,24 +1345,77 @@ fn action_inspect_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
let val = Style::default().fg(Color::White);
let hl = Style::default().fg(Color::Cyan);
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(filenames[idx].clone(), hl.add_modifier(Modifier::BOLD))),
Line::from(Span::styled(
filenames[idx].clone(),
hl.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
let fields: Vec<(&str, &str, String)> = vec![
("SlotName", ":", meta.slot_name.as_deref().unwrap_or("?").into()),
("DisplayName", ":", meta.display_name.as_deref().unwrap_or("(unnamed)").into()),
("Game Type", ":", (if meta.is_online { "Multiplayer" } else { "Single Player" }).into()),
("Was Multi", ":", (if meta.was_multiplayer { "yes" } else { "no" }).into()),
("GameMode", ":", meta.game_mode.as_deref().unwrap_or("?").into()),
("Level", ":", meta.level_name.as_deref().unwrap_or("?").into()),
("Build", ":", meta.build_number.map_or("?".into(), |n| n.to_string())),
("Branch", ":", meta.build_branch.as_deref().unwrap_or("?").into()),
("Saves", ":", meta.saves_count.map_or("?".into(), |n| n.to_string())),
("Ver", ":", meta.latest_version.map_or("?".into(), |n| n.to_string())),
("DataVer", ":", meta.data_version.map_or("?".into(), |n| n.to_string())),
(
"SlotName",
":",
meta.slot_name.as_deref().unwrap_or("?").into(),
),
(
"DisplayName",
":",
meta.display_name.as_deref().unwrap_or("(unnamed)").into(),
),
(
"Game Type",
":",
(if meta.is_online {
"Multiplayer"
} else {
"Single Player"
})
.into(),
),
(
"Was Multi",
":",
(if meta.was_multiplayer { "yes" } else { "no" }).into(),
),
(
"GameMode",
":",
meta.game_mode.as_deref().unwrap_or("?").into(),
),
(
"Level",
":",
meta.level_name.as_deref().unwrap_or("?").into(),
),
(
"Build",
":",
meta.build_number.map_or("?".into(), |n| n.to_string()),
),
(
"Branch",
":",
meta.build_branch.as_deref().unwrap_or("?").into(),
),
(
"Saves",
":",
meta.saves_count.map_or("?".into(), |n| n.to_string()),
),
(
"Ver",
":",
meta.latest_version.map_or("?".into(), |n| n.to_string()),
),
(
"DataVer",
":",
meta.data_version.map_or("?".into(), |n| n.to_string()),
),
("Playtime", ":", format_playtime(meta.playtime_seconds)),
];
let max_label: usize = fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8);
let max_label: usize =
fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8);
for (key, sep, value) in fields {
let padded = format!("{:<max_label$}{sep} ", key);
lines.push(Line::from(vec![
@@ -1192,9 +1461,7 @@ fn ensure_save_folder<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) ->
return Ok(sf.clone());
}
}
anyhow::bail!(
"No save folder set. Use 'Set save folder' from the main menu first."
)
anyhow::bail!("No save folder set. Use 'Set save folder' from the main menu first.")
}
/// Derive the Config\Windows path from the save folder.
@@ -1219,7 +1486,12 @@ fn slot_number(slot: &str) -> String {
/// Display a dialog with styled content lines (colors, bold). Accepts
/// `Line` slices — use for metadata displays, help text, or any content
/// that needs inline formatting. Press Enter or Space to dismiss.
fn ok_dialog_styled<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, lines: &[Line]) -> Result<()> {
fn ok_dialog_styled<B: Backend>(
terminal: &mut Terminal<B>,
app: &App,
title: &str,
lines: &[Line],
) -> Result<()> {
loop {
terminal.draw(|f| tui::draw_ok_dialog_styled(f, &app.tui_state, title, lines))?;
if let Some(key) = poll_key(250)? {
@@ -1233,7 +1505,12 @@ fn ok_dialog_styled<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &s
/// Display a plain-text informational dialog with a single OK button.
/// `msg` supports newlines for multi-line messages. Press Enter or
/// Space to dismiss. For styled content, use `ok_dialog_styled`.
fn ok_dialog<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, msg: &str) -> Result<()> {
fn ok_dialog<B: Backend>(
terminal: &mut Terminal<B>,
app: &App,
title: &str,
msg: &str,
) -> Result<()> {
loop {
terminal.draw(|f| tui::draw_ok_dialog(f, &app.tui_state, title, msg))?;
if let Some(key) = poll_key(250)? {
@@ -1248,7 +1525,10 @@ fn ok_dialog<B: Backend>(terminal: &mut Terminal<B>, app: &App, title: &str, msg
/// Used to gate destructive operations behind a backup requirement.
fn has_existing_backup(_app: &App) -> bool {
let root = crate::config::backups_saves_dir();
root.exists() && std::fs::read_dir(&root).is_ok_and(|mut d| d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))))
root.exists()
&& std::fs::read_dir(&root).is_ok_and(|mut d| {
d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().ends_with(".tar.gz")))
})
}
/// Gate: warn the user if no full backup exists yet. Returns `false` if
@@ -1257,7 +1537,10 @@ fn require_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resu
if has_existing_backup(app) {
return Ok(true);
}
app.set_status("No backup found — create a full backup before destructive actions.", tui::StatusStyle::Error);
app.set_status(
"No backup found — create a full backup before destructive actions.",
tui::StatusStyle::Error,
);
wait_for_key(terminal, app)?;
Ok(false)
}
@@ -1330,11 +1613,15 @@ fn wait_for_key<B: Backend>(terminal: &mut Terminal<B>, app: &App) -> Result<()>
"Press any key to return to menu…",
Style::default().fg(Color::DarkGray),
);
let p = Paragraph::new(prompt_span)
.alignment(Alignment::Center);
let p = Paragraph::new(prompt_span).alignment(Alignment::Center);
f.render_widget(p, centered_bottom(f.area()));
// Whale at bottom
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
tui::draw_whale_separator(f, bar, &app.tui_state);
})?;
if let Event::Key(_) = event::read()? {
+125 -43
View File
@@ -35,18 +35,18 @@ pub struct RecoveryResult {
// ── .sav recovery from .bak ────────────────────────────────────────────────
pub fn recover_bak_to_sav(
save_folder: &Path,
bak_filename: &str,
) -> Result<RecoveryResult> {
pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result<RecoveryResult> {
let bak_path = save_folder.join(bak_filename);
if !bak_path.exists() {
anyhow::bail!("backup file not found: {}", bak_path.display());
}
let meta = fs::metadata(&bak_path)
.with_context(|| format!("cannot read {}", bak_path.display()))?;
let meta =
fs::metadata(&bak_path).with_context(|| format!("cannot read {}", bak_path.display()))?;
if meta.len() < 1024 {
anyhow::bail!("backup file too small ({} bytes) — aborting restore", meta.len());
anyhow::bail!(
"backup file too small ({} bytes) — aborting restore",
meta.len()
);
}
let slot = derive_slot_from_filename(bak_filename)
.ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?;
@@ -55,14 +55,27 @@ pub fn recover_bak_to_sav(
let mut old_saved_as = None;
if target_path.exists() {
let old_path = save_folder.join(format!("{target_name}.old"));
fs::rename(&target_path, &old_path)
.with_context(|| format!("cannot rename {}{}", target_path.display(), old_path.display()))?;
fs::rename(&target_path, &old_path).with_context(|| {
format!(
"cannot rename {}{}",
target_path.display(),
old_path.display()
)
})?;
old_saved_as = Some(format!("{target_name}.old"));
}
fs::copy(&bak_path, &target_path).with_context(|| {
format!("cannot copy {}{}", bak_path.display(), target_path.display())
format!(
"cannot copy {}{}",
bak_path.display(),
target_path.display()
)
})?;
Ok(RecoveryResult { source: bak_filename.to_string(), target: target_name, old_saved_as })
Ok(RecoveryResult {
source: bak_filename.to_string(),
target: target_name,
old_saved_as,
})
}
// ── tar.gz helpers ─────────────────────────────────────────────────────────
@@ -95,7 +108,6 @@ fn create_tar_gz(
})
.collect();
// Write a manifest entry first
let mut manifest = String::new();
for entry in &entries {
@@ -121,12 +133,14 @@ fn create_tar_gz(
let data = fs::read(&src_path)
.with_context(|| format!("failed to read {}", src_path.display()))?;
let mut header = tar::Header::new_gnu();
header.set_path(&name)
header
.set_path(&name)
.with_context(|| format!("failed to set path '{name}' in tar header"))?;
header.set_size(data.len() as u64);
header.set_mode(0o644); // owner read/write, group/other read
header.set_cksum();
tar_builder.append(&header, &data[..])
tar_builder
.append(&header, &data[..])
.with_context(|| format!("failed to append '{name}' to tar archive"))?;
count += 1;
total += size;
@@ -191,14 +205,24 @@ pub fn create_full_backup(save_folder: &Path) -> Result<BackupResult> {
let backup_dir = crate::config::backups_saves_dir();
let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?;
let verified = path.exists();
Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified })
Ok(BackupResult {
files_copied: count,
total_size: total,
dest_path: path,
verified,
})
}
pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result<usize> {
// Pre-restore safety: back up current saves
let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz"));
if let Err(_e) = create_tar_gz(save_folder, &crate::config::backups_saves_dir(), "savegame_", "pre_restore") {
if let Err(_e) = create_tar_gz(
save_folder,
&crate::config::backups_saves_dir(),
"savegame_",
"pre_restore",
) {
// pre-restore failure is non-fatal
}
let _ = pre_restore;
@@ -224,7 +248,12 @@ pub fn backup_ini_files(config_path: &Path) -> Result<BackupResult> {
let backup_dir = crate::config::backups_config_dir();
let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?;
let verified = path.exists();
Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified })
Ok(BackupResult {
files_copied: count,
total_size: total,
dest_path: path,
verified,
})
}
pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result<usize> {
@@ -269,7 +298,10 @@ pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
files.sort_by(|a, b| {
let ma = fs::metadata(a).ok();
let mb = fs::metadata(b).ok();
match (ma.and_then(|m| m.modified().ok()), mb.and_then(|m| m.modified().ok())) {
match (
ma.and_then(|m| m.modified().ok()),
mb.and_then(|m| m.modified().ok()),
) {
(Some(a), Some(b)) => b.cmp(&a),
_ => std::cmp::Ordering::Equal,
}
@@ -306,11 +338,11 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
let filename = entry.file_name().to_string_lossy().to_string();
let meta = fs::metadata(&path).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| {
let mtime = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| {
let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
Local.timestamp_opt(secs as i64, 0).single()
Local
.timestamp_opt(secs as i64, 0)
.single()
.map(|dt| dt.format("%Y-%b-%d %H:%M").to_string())
});
let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into());
@@ -318,7 +350,16 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
let display_name = meta.as_ref().and_then(|m| m.display_name.clone());
let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false);
let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds);
files.push(BakFileSummary { path, filename, slot, display_name, is_online, size, mtime, playtime_seconds });
files.push(BakFileSummary {
path,
filename,
slot,
display_name,
is_online,
size,
mtime,
playtime_seconds,
});
}
files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime)));
files
@@ -361,9 +402,11 @@ pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) {
let ini_has_backup = crate::config::backups_config_dir().exists()
&& fs::read_dir(crate::config::backups_config_dir())
.map(|entries| entries.flatten().any(|e| {
e.file_name().to_string_lossy().ends_with(".tar.gz")
}))
.map(|entries| {
entries
.flatten()
.any(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
})
.unwrap_or(false);
let _ = crate::config::backups_saves_dir(); // ensure dir exists
@@ -399,19 +442,25 @@ fn migrate_backups_from(old_root: PathBuf) -> Result<usize> {
if dir_name.starts_with("notalterra_copy_") {
// Migrate old save backups → backups/saves/
let has_saves = fs::read_dir(&path)
.map(|e| e.flatten().any(|f| {
f.file_name().to_string_lossy().starts_with("savegame_")
}))
.map(|e| {
e.flatten()
.any(|f| f.file_name().to_string_lossy().starts_with("savegame_"))
})
.unwrap_or(false);
if !has_saves {
continue;
}
let backup_dir = crate::config::backups_saves_dir();
match create_tar_gz(&path, &backup_dir, "savegame_", &format!("migrated_{dir_name}")) {
match create_tar_gz(
&path,
&backup_dir,
"savegame_",
&format!("migrated_{dir_name}"),
) {
Ok((_count, _size, archive_path)) if archive_path.exists() => {
migrated += 1;
}
Ok(_) => {},
Ok(_) => {}
Err(e) => {
eprintln!("migration warning: failed to archive {:?}: {}", path, e);
}
@@ -423,7 +472,7 @@ fn migrate_backups_from(old_root: PathBuf) -> Result<usize> {
Ok((_count, _size, archive_path)) if archive_path.exists() => {
migrated += 1;
}
Ok(_) => {},
Ok(_) => {}
Err(e) => {
eprintln!("migration warning: failed to archive {:?}: {}", path, e);
}
@@ -454,19 +503,31 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let old_root = tmp.path().join("NotAlterra_Backups");
fs::create_dir_all(&old_root).unwrap();
create_old_backup(&old_root, "notalterra_copy_2025-01-01_120000", &["savegame_0.sav"]);
create_old_backup(&old_root, "notalterra_copy_2025-01-02_120000", &["savegame_0.sav", "savegame_1.sav"]);
create_old_backup(
&old_root,
"notalterra_copy_2025-01-01_120000",
&["savegame_0.sav"],
);
create_old_backup(
&old_root,
"notalterra_copy_2025-01-02_120000",
&["savegame_0.sav", "savegame_1.sav"],
);
let count = migrate_backups_from(old_root.clone()).unwrap();
assert_eq!(count, 2, "two old backups should be migrated");
// Verify archives exist in the shared backup directory
let saves_dir = crate::config::backups_saves_dir();
let archives: Vec<_> = fs::read_dir(&saves_dir).unwrap()
let archives: Vec<_> = fs::read_dir(&saves_dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("migrated_"))
.collect();
assert!(archives.len() >= 2, "at least 2 migrated archives should exist");
assert!(
archives.len() >= 2,
"at least 2 migrated archives should exist"
);
}
#[test]
@@ -493,27 +554,45 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let old_root = tmp.path().join("NotAlterra_Backups");
fs::create_dir_all(&old_root).unwrap();
let _dir = create_old_backup(&old_root, "notalterra_copy_2026-01-01_120000", &["savegame_0.sav", "savegame_1.sav"]);
let _dir = create_old_backup(
&old_root,
"notalterra_copy_2026-01-01_120000",
&["savegame_0.sav", "savegame_1.sav"],
);
let count = migrate_backups_from(old_root.clone()).unwrap();
assert_eq!(count, 1);
// Find the migrated archive by matching the directory name
let saves_dir = crate::config::backups_saves_dir();
let archive: Option<PathBuf> = fs::read_dir(&saves_dir).unwrap()
let archive: Option<PathBuf> = fs::read_dir(&saves_dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains("migrated_notalterra_copy_2026-01-01"))
.filter(|e| {
e.file_name()
.to_string_lossy()
.contains("migrated_notalterra_copy_2026-01-01")
})
.map(|e| e.path())
.find(|_| true);
assert!(archive.is_some(), "migrated archive should exist for 2026-01-01");
assert!(
archive.is_some(),
"migrated archive should exist for 2026-01-01"
);
// Extract to a temp dir and verify content
let extract_dir = tmp.path().join("extracted");
fs::create_dir_all(&extract_dir).unwrap();
let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap();
assert_eq!(extracted, 2, "both save files should be restored");
assert_eq!(fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(), "content-0");
assert_eq!(fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(), "content-1");
assert_eq!(
fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(),
"content-0"
);
assert_eq!(
fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(),
"content-1"
);
}
#[test]
@@ -527,7 +606,10 @@ mod tests {
fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap();
let count = migrate_backups_from(old_root.clone()).unwrap();
assert_eq!(count, 1, "only the dir with save files and correct prefix should be migrated");
assert_eq!(
count, 1,
"only the dir with save files and correct prefix should be migrated"
);
}
#[test]
+401 -130
View File
@@ -117,7 +117,12 @@ pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
/// Draw the disclaimer popup with full warning text.
pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool) {
// Whale at bottom row
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
draw_whale_separator(f, bar, app);
let popup_w = 60.min(f.area().width.saturating_sub(4));
let popup_h = 18.min(f.area().height.saturating_sub(4));
@@ -133,24 +138,83 @@ pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool)
let inner = inner(area, 2, 1);
let lines = vec![
Line::from(Span::styled("DISCLAIMER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(
"DISCLAIMER",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled("This tool was created using an AI Agent. While", Style::default().fg(Color::White))),
Line::from(Span::styled("every effort has been made to ensure it works", Style::default().fg(Color::White))),
Line::from(Span::styled("correctly, you should review the code and test", Style::default().fg(Color::White))),
Line::from(Span::styled("on a backup before using it on live save files.", Style::default().fg(Color::White))),
Line::from(Span::styled(
"This tool was created using an AI Agent. While",
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
"every effort has been made to ensure it works",
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
"correctly, you should review the code and test",
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
"on a backup before using it on live save files.",
Style::default().fg(Color::White),
)),
Line::from(""),
Line::from(Span::styled("NotAlterra is not affiliated with Unknown Worlds", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled("Entertainment or KRAFTON. Use at your own risk.", Style::default().fg(Color::DarkGray))),
Line::from(Span::styled(
"NotAlterra is not affiliated with Unknown Worlds",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
"Entertainment or KRAFTON. Use at your own risk.",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled("The author is NOT responsible for any data loss.", Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(
"The author is NOT responsible for any data loss.",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
];
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), Rect { height: 11, ..inner });
f.render_widget(
Paragraph::new(lines).alignment(Alignment::Center),
Rect {
height: 11,
..inner
},
);
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green) };
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Red) };
let buttons = Line::from(vec![Span::styled("[ Accept ]", yes_style), Span::raw(" "), Span::styled("[ Decline ]", no_style)]);
f.render_widget(Paragraph::new(buttons).alignment(Alignment::Center), Rect { y: inner.y + 12, height: 1, ..inner });
let yes_style = if selected_yes {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let no_style = if !selected_yes {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
};
let buttons = Line::from(vec![
Span::styled("[ Accept ]", yes_style),
Span::raw(" "),
Span::styled("[ Decline ]", no_style),
]);
f.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
Rect {
y: inner.y + 12,
height: 1,
..inner
},
);
}
/// Draw a simple confirmation popup with [ Yes ] [ No ] buttons.
@@ -161,7 +225,12 @@ pub fn draw_confirm_popup(
details: &[(&str, &str)],
selected_yes: bool,
) {
let max_w = details.iter().map(|(k, v)| k.len() + v.len() + 4).max().unwrap_or(20).max(30) as u16;
let max_w = details
.iter()
.map(|(k, v)| k.len() + v.len() + 4)
.max()
.unwrap_or(20)
.max(30) as u16;
let popup_w = (max_w + 4).min(f.area().width.saturating_sub(4));
let popup_h = (details.len() as u16 + 6).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
@@ -177,29 +246,57 @@ pub fn draw_confirm_popup(
// Title
f.render_widget(
Paragraph::new(Span::styled(title, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))
Paragraph::new(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
Rect { height: 1, ..inner },
);
// Details
let detail_lines: Vec<Line> = details.iter().map(|(k, v)| {
let icon = if k.starts_with('⚠') { Color::Yellow } else { Color::Gray };
let detail_lines: Vec<Line> = details
.iter()
.map(|(k, v)| {
let icon = if k.starts_with('⚠') {
Color::Yellow
} else {
Color::Gray
};
Line::from(vec![
Span::styled(format!("{k}: "), Style::default().fg(icon)),
Span::styled(*v, Style::default()),
])
}).collect();
})
.collect();
f.render_widget(
Paragraph::new(detail_lines),
Rect { y: inner.y + 2, height: details.len() as u16, ..inner },
Rect {
y: inner.y + 2,
height: details.len() as u16,
..inner
},
);
// Yes / No buttons
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) }
else { Style::default().fg(Color::Green) };
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) }
else { Style::default().fg(Color::Red) };
let yes_style = if selected_yes {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let no_style = if !selected_yes {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
};
let buttons = Line::from(vec![
Span::styled("[ Yes ]", yes_style),
Span::raw(" "),
@@ -207,11 +304,20 @@ pub fn draw_confirm_popup(
]);
f.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner },
Rect {
y: inner.y + inner.height.saturating_sub(1),
height: 1,
..inner
},
);
// Whale
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
draw_whale_separator(f, bar, app);
}
@@ -219,22 +325,68 @@ pub fn draw_confirm_popup(
/// Auto-sizes to fit content. Title is displayed in cyan, message in gray,
/// whale separator at the bottom. Press Enter or Space to dismiss.
pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
let content_w = message.lines().map(|l| l.len()).max().unwrap_or(20).max(title.len()) as u16 + 10;
let content_w = message
.lines()
.map(|l| l.len())
.max()
.unwrap_or(20)
.max(title.len()) as u16
+ 10;
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
let popup_h = (message.lines().count() as u16 + 7).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
f.render_widget(
Paragraph::new(Span::styled(
title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
Rect { height: 1, ..inner },
);
let msg_h = message.lines().count() as u16;
f.render_widget(Paragraph::new(message.to_string()).style(Style::default().fg(Color::Gray)).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: msg_h });
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
f.render_widget(
Paragraph::new(message.to_string())
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Left),
Rect {
x: inner.x + 2,
y: inner.y + 2,
width: inner.width.saturating_sub(4),
height: msg_h,
},
);
let ok = Span::styled(
"[ OK ]",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(
Paragraph::new(ok).alignment(Alignment::Center),
Rect {
y: inner.y + inner.height.saturating_sub(2),
height: 1,
..inner
},
);
// Whale
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
draw_whale_separator(f, bar, app);
}
@@ -242,21 +394,67 @@ pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str)
/// (colors, bold) via [`Line`] slices. Use for metadata displays, help
/// text, or any content that needs per-span styling.
pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines: &[Line]) {
let content_w = lines.iter().map(|l| l.width() as u16).max().unwrap_or(20).max(title.len() as u16) + 10;
let content_w = lines
.iter()
.map(|l| l.width() as u16)
.max()
.unwrap_or(20)
.max(title.len() as u16)
+ 10;
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
let popup_h = (lines.len() as u16 + 7).min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(block, area);
let inner = inner(area, 2, 1);
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
f.render_widget(Paragraph::new(lines.to_vec()).style(Style::default()).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: lines.len() as u16 });
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
f.render_widget(
Paragraph::new(Span::styled(
title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
Rect { height: 1, ..inner },
);
f.render_widget(
Paragraph::new(lines.to_vec())
.style(Style::default())
.alignment(Alignment::Left),
Rect {
x: inner.x + 2,
y: inner.y + 2,
width: inner.width.saturating_sub(4),
height: lines.len() as u16,
},
);
let ok = Span::styled(
"[ OK ]",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(
Paragraph::new(ok).alignment(Alignment::Center),
Rect {
y: inner.y + inner.height.saturating_sub(2),
height: 1,
..inner
},
);
// Whale
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
draw_whale_separator(f, bar, app);
}
@@ -264,11 +462,21 @@ pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines:
/// Shrink a rectangle to the given absolute width and height, centered.
/// Return a rectangle centered in `r` by the given width and height percentages.
fn centered_rect_size(w: u16, h: u16, r: Rect) -> Rect {
let popup = Layout::default().direction(Direction::Vertical)
.constraints([Constraint::Length((r.height.saturating_sub(h))/2), Constraint::Length(h), Constraint::Length((r.height.saturating_sub(h))/2)])
let popup = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((r.height.saturating_sub(h)) / 2),
Constraint::Length(h),
Constraint::Length((r.height.saturating_sub(h)) / 2),
])
.split(r);
Layout::default().direction(Direction::Horizontal)
.constraints([Constraint::Length((r.width.saturating_sub(w))/2), Constraint::Length(w), Constraint::Length((r.width.saturating_sub(w))/2)])
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((r.width.saturating_sub(w)) / 2),
Constraint::Length(w),
Constraint::Length((r.width.saturating_sub(w)) / 2),
])
.split(popup[1])[1]
}
@@ -293,28 +501,27 @@ pub fn draw_sub_menu(
));
f.render_widget(title_p, chunks[1]);
draw_select_list(f, chunks[2], items, descs, "↑/↓ navigate Enter select Esc back", state);
draw_select_list(
f,
chunks[2],
items,
descs,
"↑/↓ navigate Enter select Esc back",
state,
);
draw_status_bar(f, chunks[3], app);
}
/// Draw a full-screen text display with a "press any key" prompt at the
/// bottom. Used for status messages during long operations (scanning,
/// backing up) and for displaying scan results.
pub fn draw_text_screen(
f: &mut Frame,
app: &AppState,
lines: &[Line],
prompt: &str,
) {
pub fn draw_text_screen(f: &mut Frame, app: &AppState, lines: &[Line], prompt: &str) {
let chunks = standard_layout(f.area(), lines.len());
draw_header(f, chunks[0], app);
f.render_widget(Paragraph::new(lines.to_vec()), chunks[2]);
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center);
f.render_widget(prompt_p, chunks[3]);
}
@@ -359,7 +566,8 @@ fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
Constraint::Min(1), // menu (fills remaining)
Constraint::Length(1), // status bar
])
.split(area).to_vec()
.split(area)
.to_vec()
}
/// Render the title bar with version information.
@@ -371,14 +579,16 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(20),
Constraint::Min(0),
])
.constraints([Constraint::Length(20), Constraint::Min(0)])
.split(inner(area, 1, 0));
let title_line = Line::from(vec![
Span::styled("NotAlterra", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(
"NotAlterra",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)),
]);
@@ -403,16 +613,35 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
/// Render the status dashboard beneath the header.
fn draw_status_dashboard(f: &mut Frame, area: Rect, app: &AppState) {
let live = Span::styled(
format!(" Save{}: {} ", if app.live_save_count == 1 { "" } else { "s" }, if app.save_path.is_some() { app.live_save_count.to_string() } else { "".into() }),
format!(
" Save{}: {} ",
if app.live_save_count == 1 { "" } else { "s" },
if app.save_path.is_some() {
app.live_save_count.to_string()
} else {
"".into()
}
),
Style::default().fg(Color::Green),
);
let bak = Span::styled(
format!(" Backup{}: {} ", if app.backup_count == 1 { "" } else { "s" }, app.backup_count),
format!(
" Backup{}: {} ",
if app.backup_count == 1 { "" } else { "s" },
app.backup_count
),
Style::default().fg(Color::Yellow),
);
let ini = Span::styled(
format!(" .ini backup: {} ", if app.has_ini_backup { "yes" } else { "no" }),
Style::default().fg(if app.has_ini_backup { Color::Green } else { Color::DarkGray }),
format!(
" .ini backup: {} ",
if app.has_ini_backup { "yes" } else { "no" }
),
Style::default().fg(if app.has_ini_backup {
Color::Green
} else {
Color::DarkGray
}),
);
let line = Line::from(vec![
@@ -444,10 +673,7 @@ fn draw_select_list(
let list_items: Vec<ListItem> = items
.iter()
.map(|item| {
ListItem::new(Span::raw(*item))
.style(Style::default())
})
.map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
.collect();
let list = List::new(list_items)
@@ -462,7 +688,10 @@ fn draw_select_list(
f.render_stateful_widget(list, list_area, state);
// Description line for the highlighted item
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
let desc_idx = state
.selected()
.unwrap_or(0)
.min(descs.len().saturating_sub(1));
let desc = descs.get(desc_idx).copied().unwrap_or("");
let desc_line = Paragraph::new(Span::styled(
format!(" {desc}"),
@@ -482,10 +711,7 @@ fn draw_select_list(
// Prompt at bottom-right
let prompt_len = prompt.len() as u16;
if area.width > prompt_len + 2 {
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Right);
f.render_widget(
prompt_p,
@@ -517,10 +743,7 @@ fn draw_select_list_with_info(
let list_items: Vec<ListItem> = items
.iter()
.map(|item| {
ListItem::new(Span::raw(*item))
.style(Style::default())
})
.map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
.collect();
let list = List::new(list_items)
@@ -537,7 +760,10 @@ fn draw_select_list_with_info(
// Description line for the highlighted item
let base_y = area.y + area.height.saturating_sub(1 + extra);
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
let desc_idx = state
.selected()
.unwrap_or(0)
.min(descs.len().saturating_sub(1));
let desc = descs.get(desc_idx).copied().unwrap_or("");
let desc_line = Paragraph::new(Span::styled(
format!(" {desc}"),
@@ -574,10 +800,7 @@ fn draw_select_list_with_info(
// Prompt at bottom-right
let prompt_len = prompt.len() as u16;
if area.width > prompt_len + 2 {
let prompt_p = Paragraph::new(Span::styled(
prompt,
Style::default().fg(Color::DarkGray),
))
let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Right);
f.render_widget(
prompt_p,
@@ -595,13 +818,10 @@ fn draw_select_list_with_info(
/// Render a compact pip-list without description line or prompt.
/// The pip (►) replaces the full-row background highlight.
fn draw_select_list_pip(
f: &mut Frame,
area: Rect,
items: &[&str],
state: &mut ListState,
) {
if area.height < 2 || area.width < 10 { return; }
fn draw_select_list_pip(f: &mut Frame, area: Rect, items: &[&str], state: &mut ListState) {
if area.height < 2 || area.width < 10 {
return;
}
let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
@@ -611,7 +831,9 @@ fn draw_select_list_pip(
.map(|(i, item)| {
let style = if i == 0 {
// Header row — match right pane header color
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if i >= 2 {
// Data rows — match right pane value color
dim_val
@@ -639,26 +861,36 @@ fn draw_select_list_pip(
/// Render the right-hand metadata pane in the split file picker.
/// Shows the filename header, a dim separator, then the provided content lines.
/// When `meta_lines` is empty, shows a placeholder message.
fn draw_right_pane(
f: &mut Frame,
area: Rect,
filename: &str,
meta_lines: &[Line],
) {
if area.height < 3 || area.width < 10 { return; }
fn draw_right_pane(f: &mut Frame, area: Rect, filename: &str, meta_lines: &[Line]) {
if area.height < 3 || area.width < 10 {
return;
}
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
// Filename header
let mut y = area.y;
let fname = if filename.len() as u16 > area.width.saturating_sub(2) {
format!("{}", &filename[..area.width.saturating_sub(3).max(1) as usize])
format!(
"{}",
&filename[..area.width.saturating_sub(3).max(1) as usize]
)
} else {
filename.to_string()
};
f.render_widget(
Paragraph::new(Span::styled(&fname, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
Paragraph::new(Span::styled(
&fname,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Rect {
x: area.x + 1,
y,
width: area.width.saturating_sub(2),
height: 1,
},
);
y += 2;
@@ -671,7 +903,12 @@ fn draw_right_pane(
};
f.render_widget(
Paragraph::new(msg),
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
Rect {
x: area.x + 1,
y,
width: area.width.saturating_sub(2),
height: 1,
},
);
return;
}
@@ -681,7 +918,12 @@ fn draw_right_pane(
for (i, line) in meta_lines.iter().enumerate().take(max_lines) {
f.render_widget(
Paragraph::new(line.clone()),
Rect { x: area.x + 1, y: y + i as u16, width: area.width.saturating_sub(2), height: 1 },
Rect {
x: area.x + 1,
y: y + i as u16,
width: area.width.saturating_sub(2),
height: 1,
},
);
}
}
@@ -752,7 +994,9 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
/// it disappears for ~5.4s (30 cooldown ticks) before reappearing on the
/// right. Two variants alternate every 400ms.
pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
if area.width < 4 { return; }
if area.width < 4 {
return;
}
let elapsed = app.whale_start.elapsed().as_millis() as u64;
let bar_w = area.width as u64;
let speed_ms: u64 = 180;
@@ -766,7 +1010,12 @@ pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
let whale = variants[(switch % variants.len() as u64) as usize];
f.render_widget(
Paragraph::new(Span::styled(whale, Style::default().fg(Color::Cyan))),
Rect { x: area.x + (x as u16).min(area.width.saturating_sub(4)), y: area.y, width: 4, height: 1 },
Rect {
x: area.x + (x as u16).min(area.width.saturating_sub(4)),
y: area.y,
width: 4,
height: 1,
},
);
}
}
@@ -864,8 +1113,8 @@ pub fn draw_input_dialog(
let prompt_w = state.prompt.len() as u16 + 4;
let input_display = &state.input;
let display_w = input_display.len() + 4; // rough, but good enough for sizing
let popup_w = (prompt_w.max(display_w as u16).max(40) + 4)
.min(f.area().width.saturating_sub(4));
let popup_w =
(prompt_w.max(display_w as u16).max(40) + 4).min(f.area().width.saturating_sub(4));
let popup_h = 10u16.min(f.area().height.saturating_sub(4));
let area = centered_rect_size(popup_w, popup_h, f.area());
f.render_widget(Clear, area);
@@ -882,7 +1131,9 @@ pub fn draw_input_dialog(
f.render_widget(
Paragraph::new(Span::styled(
"Set Save Folder",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Rect { height: 1, ..inner },
);
@@ -893,14 +1144,17 @@ pub fn draw_input_dialog(
&state.prompt,
Style::default().fg(Color::White),
)),
Rect { y: inner.y + 2, height: 1, width: inner.width, x: inner.x },
Rect {
y: inner.y + 2,
height: 1,
width: inner.width,
x: inner.x,
},
);
// Input line with cursor
let cursor_visible = (std::time::Instant::now().elapsed().as_millis() / 500).is_multiple_of(2);
let mut input_spans = vec![
Span::styled(" ", Style::default()),
];
let mut input_spans = vec![Span::styled(" ", Style::default())];
// Show the text up to cursor
let before = &state.input[..state.cursor.min(state.input.len())];
let after = if state.cursor < state.input.len() {
@@ -908,25 +1162,21 @@ pub fn draw_input_dialog(
} else {
None
};
input_spans.push(Span::styled(
before,
Style::default().fg(Color::White),
));
input_spans.push(Span::styled(before, Style::default().fg(Color::White)));
if cursor_visible && !state.confirmed && !state.cancelled {
input_spans.push(Span::styled(
"",
Style::default().fg(Color::Cyan),
));
input_spans.push(Span::styled("", Style::default().fg(Color::Cyan)));
}
if let Some(a) = after {
input_spans.push(Span::styled(
a,
Style::default().fg(Color::White),
));
input_spans.push(Span::styled(a, Style::default().fg(Color::White)));
}
f.render_widget(
Paragraph::new(Line::from(input_spans)),
Rect { y: inner.y + 3, height: 1, width: inner.width, x: inner.x },
Rect {
y: inner.y + 3,
height: 1,
width: inner.width,
x: inner.x,
},
);
// Instruction line
@@ -935,17 +1185,28 @@ pub fn draw_input_dialog(
"Type a path, then Tab to buttons Enter to confirm Esc to cancel",
Style::default().fg(Color::DarkGray),
)),
Rect { y: inner.y + 5, height: 1, width: inner.width, x: inner.x },
Rect {
y: inner.y + 5,
height: 1,
width: inner.width,
x: inner.x,
},
);
// OK / Cancel buttons
let ok_style = if ok_selected {
Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
let cancel_style = if !ok_selected {
Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
};
@@ -956,11 +1217,21 @@ pub fn draw_input_dialog(
]);
f.render_widget(
Paragraph::new(buttons).alignment(Alignment::Center),
Rect { y: inner.y + 6, height: 1, width: inner.width, x: inner.x },
Rect {
y: inner.y + 6,
height: 1,
width: inner.width,
x: inner.x,
},
);
// Whale
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
let bar = Rect {
x: 0,
y: f.area().height.saturating_sub(1),
width: f.area().width,
height: 1,
};
draw_whale_separator(f, bar, _app);
}