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