human-readable backup labels, Backup Types README section, clippy fixes

This commit is contained in:
2026-06-09 13:08:13 +02:00
parent e490460851
commit cead318a4b
5 changed files with 143 additions and 18 deletions
+5
View File
@@ -41,6 +41,11 @@ All notable changes to NotAlterra are documented in this file.
- **Context-aware header path** — the right side of the title bar shows - **Context-aware header path** — the right side of the title bar shows
the relevant file location for the current menu or submenu item (save the relevant file location for the current menu or submenu item (save
folder, backup root, or ini Config\Windows path) folder, backup root, or ini Config\Windows path)
- **Human-readable backup labels** — archive filenames are now displayed
as `Full Backup — <date>` and `Pre-restore — <date>` in the restore
picker instead of raw `.tar.gz` filenames
- **Backup Types section** in README — explains Full Backup vs
Pre-restore in plain language so users understand the safety flow
### Removed ### Removed
- **"Inspect save files" menu entry** — metadata is now visible inline in - **"Inspect save files" menu entry** — metadata is now visible inline in
+21
View File
@@ -158,6 +158,27 @@ in the main menu. The path is persisted in `app.ini`.
path (typical Proton locations are shown under "Where Files Live" above). path (typical Proton locations are shown under "Where Files Live" above).
## Backup Types
NotAlterra creates two kinds of save backups. The label in the restore
picker tells you which is which.
| Label | How it's created |
|---|---|
| `Full Backup — <date>` | Manually via **Create full backup** in the main menu. |
| `Pre-restore — <date>` | Automatically when you use **Restore full backup**. |
When you use **Restore full backup**, NotAlterra automatically takes a
safety snapshot of your current saves **before** it does anything. This is
called a **Pre-restore** backup.
If the restore doesn't go as expected, your old saves haven't disappeared —
they're right there in the restore picker labeled `Pre-restore — <date>`.
You can restore from that snapshot just like any other backup.
Pre-restore snapshots are never deleted automatically. Once you're happy
with the restore, you can delete them manually from the file system.
## Safety ## Safety
- Runs in your user context — no admin privileges required - Runs in your user context — no admin privileges required
+8 -5
View File
@@ -7,10 +7,13 @@ description: Verification checklist for code changes — always run before claim
Before reporting any task as complete, verify: Before reporting any task as complete, verify:
1. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions. 1. **Format**`cargo fmt --all -- --check` produces no output (pass).
2. **Compilation**`cargo check --workspace` must pass with no errors. 2. **Clippy**`cargo clippy --workspace -- -D warnings` passes with zero errors.
3. **Git status** — no uncommitted changes unless intentionally deferred. 3. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions.
4. **Remote parity**`git log --oneline -1` matches `origin/master`. 4. **Compilation**`cargo check --workspace` must pass with no errors.
5. **Claim specificity** — report exactly what was done and what was verified, not what was assumed. 5. **Tests**`cargo test --workspace` passes all tests.
6. **Git status** — no uncommitted changes unless intentionally deferred.
7. **Remote parity**`git log --oneline -1` matches `origin/master`.
8. **Claim specificity** — report exactly what was done and what was verified, not what was assumed.
Do not skip the check script. Do not assume prior passes still hold. Do not skip the check script. Do not assume prior passes still hold.
+76 -2
View File
@@ -143,6 +143,9 @@ fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) {
tui_state.live_save_count = live; tui_state.live_save_count = live;
tui_state.backup_count = bak; tui_state.backup_count = bak;
tui_state.has_ini_backup = ini; tui_state.has_ini_backup = ini;
tui_state.context_path = Some(
crate::config::get_backup_root().display().to_string(),
);
} }
// ── main loop ────────────────────────────────────────────────────────────── // ── main loop ──────────────────────────────────────────────────────────────
@@ -239,6 +242,20 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
let mut menu_state = ListState::default().with_selected(Some(0)); let mut menu_state = ListState::default().with_selected(Some(0));
loop { loop {
// Main menu: show path based on highlighted item
{
let sel = menu_state.selected().unwrap_or(0);
let br = crate::config::get_backup_root().to_string_lossy().to_string();
let ini_path_str = app.save_folder.as_ref().and_then(|sf| {
discovery::derive_ini_path(sf).map(|p| p.to_string_lossy().to_string())
});
app.tui_state.context_path = match sel {
0 | 1 => None, // save path (fallback)
3..=5 => Some(br), // backup root
7 => ini_path_str, // ini Config\Windows path
_ => Some(String::new()), // blank / disclaimer / exit → no path
};
}
terminal.draw(|f| { terminal.draw(|f| {
let cols = f.area().width; let cols = f.area().width;
let rows = f.area().height; let rows = f.area().height;
@@ -696,6 +713,8 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
Vec::new() Vec::new()
}; };
// Recover picker: browsing .bak files in the live save folder → show save path
app.tui_state.context_path = None;
terminal.draw(|f| { terminal.draw(|f| {
tui::draw_picker_split( tui::draw_picker_split(
f, f,
@@ -911,7 +930,7 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
.iter() .iter()
.map(|p| { .map(|p| {
let name = p.file_name().unwrap().to_string_lossy(); let name = p.file_name().unwrap().to_string_lossy();
format!(" {name}") format!(" {}", format_backup_label(&name))
}) })
.collect(); .collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
@@ -919,6 +938,10 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
let mut state = ListState::default().with_selected(Some(0)); let mut state = ListState::default().with_selected(Some(0));
loop { loop {
// Restore picker: show backup root in header
app.tui_state.context_path = Some(
crate::config::get_backup_root().to_string_lossy().to_string(),
);
terminal.draw(|f| { terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
})?; })?;
@@ -1021,7 +1044,20 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
const INI_SKIP: &[usize] = &[3]; const INI_SKIP: &[usize] = &[3];
let ini_max = 4usize; let ini_max = 4usize;
let ini_path_str = app.save_folder.as_ref().and_then(|sf| {
discovery::derive_ini_path(sf).map(|p| p.to_string_lossy().to_string())
});
loop { loop {
// Ini submenu: show path based on highlighted item
{
let sel = state.selected().unwrap_or(0);
let br = crate::config::get_backup_root().to_string_lossy().to_string();
app.tui_state.context_path = match sel {
0 | 1 => Some(br), // backup root (backup/restore)
2 => ini_path_str.clone(), // Config\Windows path (delete)
_ => Some(String::new()), // blank / back → nothing
};
}
terminal.draw(|f| { terminal.draw(|f| {
tui::draw_sub_menu( tui::draw_sub_menu(
f, f,
@@ -1142,7 +1178,7 @@ fn ini_restore_action<B: Backend>(
.iter() .iter()
.map(|p| { .map(|p| {
let name = p.file_name().unwrap().to_string_lossy(); let name = p.file_name().unwrap().to_string_lossy();
format!(" {name}") format!(" {}", format_backup_label(&name))
}) })
.collect(); .collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
@@ -1150,6 +1186,10 @@ fn ini_restore_action<B: Backend>(
let mut state = ListState::default().with_selected(Some(0)); let mut state = ListState::default().with_selected(Some(0));
loop { loop {
// Restore picker: show backup root in header
app.tui_state.context_path = Some(
crate::config::get_backup_root().to_string_lossy().to_string(),
);
terminal.draw(|f| { terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state);
})?; })?;
@@ -1584,6 +1624,40 @@ fn confirm_modal<B: Backend>(
} }
} }
/// Format a backup archive filename into a human-readable label.
/// `snapshot_2026-06-09_125430_001.tar.gz` → `Full Backup — 2026-Jun-09 12:54`
fn format_backup_label(filename: &str) -> String {
let stripped = filename.strip_suffix(".tar.gz").unwrap_or(filename);
let (prefix, rest) = match stripped.split_once('_') {
Some((p, r)) => (p, r),
None => return filename.to_string(),
};
let label = match prefix {
"snapshot" => "Full Backup",
"pre" if stripped.contains("pre_restore") => "Pre-restore",
"ini" => "INI Backup",
"migrated" => "Migrated",
_ => prefix,
};
// rest looks like: "2026-06-09_125430_001" or "notalterra_copy_..."
// Try to parse the timestamp portion (first YYYY-MM-DD_HHMMSS segment)
let date_str = if let Some(pos) = rest.find(|c: char| c.is_ascii_digit()) {
let slice = &rest[pos..];
if slice.len() >= 17 {
let date_part = &slice[..10]; // 2026-06-09
let time_part = &slice[11..17]; // 125430
let h = &time_part[..2];
let m = &time_part[2..4];
format!("{date_part} {h}:{m}")
} else {
rest.to_string()
}
} else {
rest.to_string()
};
format!("{label}{date_str}")
}
/// Create a rectangle centered in the parent area by percentage. /// Create a rectangle centered in the parent area by percentage.
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default() let popup_layout = Layout::default()
+33 -11
View File
@@ -34,6 +34,9 @@ pub struct AppState {
pub backup_count: usize, pub backup_count: usize,
/// Whether a .ini backup exists /// Whether a .ini backup exists
pub has_ini_backup: bool, pub has_ini_backup: bool,
/// Context-specific path shown on the right side of the header bar.
/// When `None`, falls back to `save_path`.
pub context_path: Option<String>,
/// Version string for the header /// Version string for the header
pub version: String, pub version: String,
/// Last operation result (for the status bar) /// Last operation result (for the status bar)
@@ -63,6 +66,7 @@ impl Default for AppState {
live_save_count: 0, live_save_count: 0,
backup_count: 0, backup_count: 0,
has_ini_backup: false, has_ini_backup: false,
context_path: None,
version: String::new(), version: String::new(),
status_message: None, status_message: None,
status_style: StatusStyle::Neutral, status_style: StatusStyle::Neutral,
@@ -594,17 +598,35 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
]); ]);
f.render_widget(Paragraph::new(title_line), chunks[0]); f.render_widget(Paragraph::new(title_line), chunks[0]);
let path_line = if let Some(ref path) = app.save_path { // Header path priority:
let max_w = chunks[1].width.saturating_sub(2) as usize; // 1. context_path = Some("path") → show that path
let display = truncate_path_tail(path, max_w); // 2. context_path = Some("") → show nothing (blank/disclaimer/exit)
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray))) // 3. context_path = None → fall back to save_path
.alignment(Alignment::Right) let max_w = chunks[1].width.saturating_sub(2) as usize;
} else { let path_line = match &app.context_path {
Paragraph::new(Span::styled( Some(p) if p.is_empty() => {
"no save folder selected", // Show nothing — blank line, disclaimer, or exit
Style::default().fg(Color::DarkGray), Paragraph::new(Span::raw(""))
)) }
.alignment(Alignment::Right) Some(p) => {
let display = truncate_path_tail(p, max_w);
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
.alignment(Alignment::Right)
}
None => {
// Fall back to save_path
if let Some(ref save) = app.save_path {
let display = truncate_path_tail(save, max_w);
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
.alignment(Alignment::Right)
} else {
Paragraph::new(Span::styled(
"no save folder selected",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right)
}
}
}; };
f.render_widget(path_line, chunks[1]); f.render_widget(path_line, chunks[1]);
f.render_widget(header_block, area); f.render_widget(header_block, area);