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