diff --git a/CHANGELOG.md b/CHANGELOG.md index eb31687..e16cc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 the relevant file location for the current menu or submenu item (save folder, backup root, or ini Config\Windows path) +- **Human-readable backup labels** — archive filenames are now displayed + as `Full Backup — ` and `Pre-restore — ` 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 - **"Inspect save files" menu entry** — metadata is now visible inline in diff --git a/README.md b/README.md index 69d624c..53b85fa 100644 --- a/README.md +++ b/README.md @@ -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). +## 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 — ` | Manually via **Create full backup** in the main menu. | +| `Pre-restore — ` | 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 — `. +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 - Runs in your user context — no admin privileges required diff --git a/skills/diligence/SKILL.md b/skills/diligence/SKILL.md index 70bc9e4..2902c6c 100644 --- a/skills/diligence/SKILL.md +++ b/skills/diligence/SKILL.md @@ -7,10 +7,13 @@ description: Verification checklist for code changes — always run before claim Before reporting any task as complete, verify: -1. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions. -2. **Compilation** — `cargo check --workspace` must pass with no errors. -3. **Git status** — no uncommitted changes unless intentionally deferred. -4. **Remote parity** — `git log --oneline -1` matches `origin/master`. -5. **Claim specificity** — report exactly what was done and what was verified, not what was assumed. +1. **Format** — `cargo fmt --all -- --check` produces no output (pass). +2. **Clippy** — `cargo clippy --workspace -- -D warnings` passes with zero errors. +3. **Doc coverage** — run `python3 tests/_check.py` to confirm zero undocumented functions. +4. **Compilation** — `cargo check --workspace` must pass with no errors. +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. diff --git a/src/main.rs b/src/main.rs index fb56e46..fd284ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,6 +143,9 @@ fn refresh_stats(tui_state: &mut tui::AppState, save_folder: Option<&Path>) { tui_state.live_save_count = live; tui_state.backup_count = bak; tui_state.has_ini_backup = ini; + tui_state.context_path = Some( + crate::config::get_backup_root().display().to_string(), + ); } // ── main loop ────────────────────────────────────────────────────────────── @@ -239,6 +242,20 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { let mut menu_state = ListState::default().with_selected(Some(0)); 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| { let cols = f.area().width; let rows = f.area().height; @@ -696,6 +713,8 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> Vec::new() }; + // Recover picker: browsing .bak files in the live save folder → show save path + app.tui_state.context_path = None; terminal.draw(|f| { tui::draw_picker_split( f, @@ -911,7 +930,7 @@ fn action_restore_backup(terminal: &mut Terminal, app: &mut App) .iter() .map(|p| { let name = p.file_name().unwrap().to_string_lossy(); - format!(" {name}") + format!(" {}", format_backup_label(&name)) }) .collect(); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); @@ -919,6 +938,10 @@ fn action_restore_backup(terminal: &mut Terminal, app: &mut App) let mut state = ListState::default().with_selected(Some(0)); 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| { tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); })?; @@ -1021,7 +1044,20 @@ fn run_ini_submenu(terminal: &mut Terminal, app: &mut App) -> Res const INI_SKIP: &[usize] = &[3]; 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 { + // 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| { tui::draw_sub_menu( f, @@ -1142,7 +1178,7 @@ fn ini_restore_action( .iter() .map(|p| { let name = p.file_name().unwrap().to_string_lossy(); - format!(" {name}") + format!(" {}", format_backup_label(&name)) }) .collect(); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); @@ -1150,6 +1186,10 @@ fn ini_restore_action( let mut state = ListState::default().with_selected(Some(0)); 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| { tui::draw_picker(f, &app.tui_state, &item_refs, &descs, &mut state); })?; @@ -1584,6 +1624,40 @@ fn confirm_modal( } } +/// 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. fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() diff --git a/src/tui.rs b/src/tui.rs index 05ab08b..3175eb0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -34,6 +34,9 @@ pub struct AppState { pub backup_count: usize, /// Whether a .ini backup exists 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, /// Version string for the header pub version: String, /// Last operation result (for the status bar) @@ -63,6 +66,7 @@ impl Default for AppState { live_save_count: 0, backup_count: 0, has_ini_backup: false, + context_path: None, version: String::new(), status_message: None, 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]); - let path_line = if let Some(ref path) = app.save_path { - let max_w = chunks[1].width.saturating_sub(2) as usize; - let display = truncate_path_tail(path, 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) + // Header path priority: + // 1. context_path = Some("path") → show that path + // 2. context_path = Some("") → show nothing (blank/disclaimer/exit) + // 3. context_path = None → fall back to save_path + let max_w = chunks[1].width.saturating_sub(2) as usize; + let path_line = match &app.context_path { + Some(p) if p.is_empty() => { + // Show nothing — blank line, disclaimer, or exit + Paragraph::new(Span::raw("")) + } + 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(header_block, area);