v0.4.1: persistent config, split-layout picker, backup location, security docs

This commit is contained in:
2026-06-09 11:39:51 +02:00
parent e2ed45c444
commit 73147b6be1
16 changed files with 804 additions and 111 deletions
+118 -12
View File
@@ -1,15 +1,42 @@
//! Minimal path utilities — no persistent config.
//! Path utilities and persistent app configuration.
//!
//! Save folder and Config/Windows paths are now session-only, entered
//! via the `Set save folder` menu. The disclaimer acceptance is tracked
//! via a 0-byte sentinel file (`NotAlterra_LICENSE_ACCEPTED`) next to the
//! binary instead of a config file.
//! The save-folder path and backup root are persisted to `app.ini` under the
//! platform config directory so they survive between sessions. The disclaimer
//! acceptance is tracked via a sentinel file in the same directory.
//!
//! The backup root defaults to `~/NotAlterra` and can be changed via the
//! `Set backup location` menu item.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// Path to the disclaimer sentinel file alongside the binary.
/// Custom backup root directory, set via the `Set backup location` menu.
/// When `None`, falls back to `exe_dir().join("backups")`.
static BACKUP_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Set a custom backup root directory. Passes ownership.
pub fn set_backup_root(path: PathBuf) {
if let Ok(mut root) = BACKUP_ROOT.lock() {
*root = Some(path);
}
}
/// Return the current backup root, or the default user-profile path.
pub fn get_backup_root() -> PathBuf {
BACKUP_ROOT
.lock()
.ok()
.and_then(|r| r.clone())
.unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(exe_dir)
})
}
/// Path to the disclaimer sentinel file in the config directory.
pub fn sentinel_path() -> PathBuf {
exe_dir().join("NotAlterra_LICENSE_ACCEPTED")
config_base_dir().join("NOTALTERRA_LICENSE_ACCEPTED")
}
/// Return `true` if the disclaimer sentinel exists.
@@ -39,22 +66,101 @@ pub fn cleanup_stale_config() -> bool {
}
}
/// Path to the `backups/saves/` directory (tar.gz archives).
/// Path to the `saves/` directory under the backup root (tar.gz archives).
/// Auto-creates the directory on first call.
pub fn backups_saves_dir() -> PathBuf {
let p = exe_dir().join("backups").join("saves");
let p = get_backup_root().join("backups").join("saves");
std::fs::create_dir_all(&p).ok();
p
}
/// Path to the `backups/config/` directory (.ini tar.gz archives).
/// Auto-creates the directory on first call.
/// Path to the `ue5/` subdirectory under the backup root for UE5 Config `.ini`
/// backup archives. Auto-creates the directory on first call.
pub fn backups_config_dir() -> PathBuf {
let p = exe_dir().join("backups").join("config");
let p = get_backup_root().join("backups").join("ue5");
std::fs::create_dir_all(&p).ok();
p
}
/// Return `~/NotAlterra` as the user's data directory for save/ue5 backups.
/// Falls back to `exe_dir()` if home is not available.
fn home_notalterra_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join("NotAlterra"))
.unwrap_or_else(exe_dir)
}
// ── persistent app config ────────────────────────────────────────────────────
/// Fixed base directory for `app.ini` and the sentinel file, under the
/// standard platform config location. Separate from backup data so that
/// `~/NotAlterra` remains the user's visible backup data directory.
fn config_base_dir() -> PathBuf {
dirs::data_local_dir()
.map(|d| d.join("NotAlterra").join("config"))
.unwrap_or_else(exe_dir)
}
/// Path to the persistent `app.ini` configuration file.
pub fn app_ini_path() -> PathBuf {
let p = config_base_dir().join("app.ini");
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
p
}
/// Session-lifetime configuration loaded from and persisted to `app.ini`.
#[derive(Debug, Default)]
pub struct AppConfig {
pub save_folder: Option<String>,
pub backup_root: Option<String>,
}
/// Load `app.ini` from the fixed config directory.
/// Returns default (empty) values if the file does not exist or cannot be read.
pub fn load_app_config() -> AppConfig {
let path = app_ini_path();
if !path.exists() {
return AppConfig::default();
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return AppConfig::default(),
};
let mut cfg = AppConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let val = value.trim();
match key {
"save_folder" => cfg.save_folder = Some(val.to_string()),
"backup_root" => cfg.backup_root = Some(val.to_string()),
_ => {}
}
}
}
cfg
}
/// Write the current session paths to `app.ini` in the fixed config directory.
pub fn save_app_config(save_folder: Option<&str>, backup_root: Option<&str>) {
let path = app_ini_path();
let mut content = String::from(
"# NotAlterra configuration\n\
# This file is auto-generated. Edit while the tool is not running.\n\n",
);
if let Some(s) = save_folder {
content.push_str(&format!("save_folder = {s}\n"));
}
if let Some(r) = backup_root {
content.push_str(&format!("backup_root = {r}\n"));
}
let _ = std::fs::write(&path, content);
}
/// Ensure a directory exists, creating all parents as needed.
pub fn ensure_dir(path: PathBuf) {
let _ = std::fs::create_dir_all(&path);
+6 -15
View File
@@ -24,18 +24,6 @@ pub fn game_running() -> bool {
// Close Subnautica 2 manually before using NotAlterra.
false
}
#[allow(dead_code)]
/// Check if Subnautica 2 is running via tasklist (Windows). Dormant.
fn _game_running_windows() -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq Subnautica2.exe", "/NH"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains("Subnautica2.exe"),
Err(_) => false,
}
}
/// Check if Subnautica 2 is currently running (Linux).
///
/// Always returns `false` — process detection via `pgrep` is disabled to
@@ -100,10 +88,13 @@ pub fn migrate_old_log() -> bool {
true
}
/// Path to `transaction.log` inside the `logs/` directory. All timestamped
/// actions are appended here for audit trail purposes.
/// Path to `transaction.log` inside the `logs/` directory under the
/// platform config root. All timestamped actions are appended here for
/// audit trail purposes.
pub fn log_path() -> PathBuf {
let p = exe_dir().join("logs").join("transaction.log");
let p = dirs::data_local_dir()
.map(|d| d.join("NotAlterra").join("logs").join("transaction.log"))
.unwrap_or_else(|| exe_dir().join("logs").join("transaction.log"));
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).ok();
}
+207 -32
View File
@@ -95,11 +95,6 @@ impl App {
})
}
/// Returns the backup root directory alongside the binary.
fn _backup_root(&self) -> PathBuf {
exe_dir().join("NotAlterra_Backups")
}
/// Set the status bar message with optional style.
fn set_status(&mut self, msg: &str, style: tui::StatusStyle) {
self.tui_state.status_message = Some(msg.to_string());
@@ -196,6 +191,22 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
}
}
// Load persistent app config — may override quick_discover
let app_cfg = crate::config::load_app_config();
if let Some(sf) = app_cfg.save_folder {
let p = PathBuf::from(&sf);
if p.exists() {
app.save_folder = Some(p);
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
}
}
if let Some(br) = app_cfg.backup_root {
let p = PathBuf::from(&br);
if p.exists() {
crate::config::set_backup_root(p);
}
}
// Disclaimer flow
if !crate::config::disclaimer_accepted() {
match run_disclaimer(terminal, &mut app)? {
@@ -218,34 +229,43 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
tui::draw_main_menu(f, &mut menu_state, &app.tui_state);
})?;
let max_idx = 7usize;
const SKIP: &[usize] = &[2, 6, 8];
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; }
match key.code {
KeyCode::Up => {
let i = menu_state.selected().unwrap_or(0);
menu_state.select(Some(i.saturating_sub(1)));
let mut i = menu_state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !SKIP.contains(&i) || i == 0 { break; }
}
menu_state.select(Some(i));
}
KeyCode::Down => {
let i = menu_state.selected().unwrap_or(0);
menu_state.select(Some((i + 1).min(max_idx)));
let mut i = menu_state.selected().unwrap_or(0);
loop {
i = (i + 1).min(max_idx);
if !SKIP.contains(&i) || i == max_idx { break; }
}
menu_state.select(Some(i));
}
KeyCode::Enter => {
let idx = menu_state.selected().unwrap_or(0);
match idx {
0 => action_set_save_folder(terminal, &mut app)?,
1 => action_recover_bak(terminal, &mut app)?,
2 => action_create_backup(terminal, &mut app)?,
3 => action_restore_backup(terminal, &mut app)?,
4 => action_inspect_saves(terminal, &mut app)?,
5 => run_ini_submenu(terminal, &mut app)?,
6 => {
3 => action_set_backup_location(terminal, &mut app)?,
4 => action_create_backup(terminal, &mut app)?,
5 => action_restore_backup(terminal, &mut app)?,
7 => run_ini_submenu(terminal, &mut app)?,
9 => {
if let Some(false) = run_disclaimer(terminal, &mut app)? {
return Ok(());
}
}
7 => return Ok(()), // Exit
10 => return Ok(()), // Exit
_ => {}
}
app.clear_status();
@@ -335,6 +355,10 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
if let Some(path) = candidate {
app.save_folder = Some(path.clone());
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
crate::config::save_app_config(
Some(&sanitized),
Some(&crate::config::get_backup_root().to_string_lossy()),
);
let msg = format!("Save folder set to {}", path.display());
app.set_status(&msg, tui::StatusStyle::Success);
input_state.confirmed = true;
@@ -392,6 +416,83 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
}
}
/// Open the input dialog for the user to set a custom backup location.
/// Pre-fills with the default `home_dir/NotAlterra` path for easy editing.
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:",
);
input_state.input = default.to_string_lossy().to_string();
input_state.cursor = input_state.input.len();
let mut ok_selected = true;
loop {
terminal.draw(|f| {
tui::draw_input_dialog(f, &app.tui_state, &input_state, ok_selected);
})?;
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
match crossterm::event::read()? {
Event::Key(key) => {
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()
.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());
crate::config::save_app_config(
save_str.as_deref(),
Some(&sanitized),
);
let msg = format!("Backup location set to {}", path.display());
app.set_status(&msg, tui::StatusStyle::Success);
input_state.confirmed = true;
return Ok(());
}
input_state.cancelled = true;
return Ok(());
}
KeyCode::Char(c) if ok_selected => {
input_state.insert(c);
}
KeyCode::Backspace if ok_selected => {
input_state.backspace();
}
KeyCode::Delete if ok_selected => {
input_state.delete();
}
KeyCode::Left if ok_selected => {
input_state.cursor_left();
}
KeyCode::Right if ok_selected => {
input_state.cursor_right();
}
KeyCode::Tab => {
ok_selected = !ok_selected;
}
KeyCode::Esc => {
input_state.cancelled = true;
return Ok(());
}
_ => {}
}
}
Event::Paste(s) if ok_selected => {
input_state.insert_str(&s);
}
_ => {}
}
}
}
}
/// Recover a .sav from its .bak backup with a rollback safety net.
fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
@@ -424,8 +525,8 @@ 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} {:<16} {:<14} {:<8} {:>6} {}",
"Slot", "Description", "Game Type", "Playtime", "Size", "Date");
let header = format!(" {:<8} {:<24} {}",
"Slot", "Description", "Date");
let mut items: Vec<String> = vec![header, String::new()];
items.extend(bak_summaries
.iter()
@@ -440,15 +541,10 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
name.to_string()
};
let date = s.mtime.as_deref().unwrap_or("?");
let save_type = if s.is_online { "Multiplayer" } else { "Single Player" };
let playtime = format_playtime(s.playtime_seconds);
format!(
" {:<8} {:<16} {:<14} {:<8} {:>6} {}",
" {:<8} {:<24} {}",
label_col,
name_col,
save_type,
playtime,
format_size(s.size),
date,
)
})
@@ -467,19 +563,86 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect();
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
// Lazily-loaded full metadata cache — loaded on first highlight
let mut full_metas: Vec<Option<crate::gvas::FullMetadata>> = vec![None; bak_summaries.len()];
/// Load metadata for index `idx` if not already cached.
fn ensure_meta(
idx: usize,
bak: &[ops::BakFileSummary],
cache: &mut Vec<Option<crate::gvas::FullMetadata>>,
) {
if idx < cache.len() && cache[idx].is_none() {
cache[idx] = crate::gvas::extract_full_metadata(&bak[idx].path).ok();
}
}
/// Build right-pane display lines from cached metadata.
fn build_meta_lines<'a>(
meta: Option<&'a crate::gvas::FullMetadata>,
summary: &ops::BakFileSummary,
) -> Vec<Line<'a>> {
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
let Some(m) = meta else {
return Vec::new();
};
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()),
("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())),
];
let max_label: usize = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(6);
fields
.into_iter()
.map(|(k, v)| {
let padded_label = format!("{:<max_label$}", k);
Line::from(vec![
Span::styled(padded_label, Style::default().fg(Color::White)),
Span::raw(" "),
Span::styled(v, dim),
])
})
.collect()
}
loop {
let i = state.selected().unwrap_or(2).max(2);
state.select(Some(i));
let selected_info = filenames.get(i.saturating_sub(2)).map(|s| s.as_str());
let sel_idx = i.saturating_sub(2);
let selected_info = filenames.get(sel_idx).map(|s| s.as_str());
let meta_header = selected_info.map(|f| format!("Details for {}", f));
// Lazy-load metadata on highlight
if sel_idx < bak_summaries.len() {
ensure_meta(sel_idx, &bak_summaries, &mut full_metas);
}
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()
};
terminal.draw(|f| {
tui::draw_picker_with_info(
tui::draw_picker_split(
f,
&app.tui_state,
&item_refs,
&desc_refs,
&mut state,
selected_info,
meta_header.as_deref(),
&meta_lines,
);
})?;
if let Some(key) = poll_key(250)? {
@@ -715,15 +878,19 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
"Backup .ini files",
"Restore .ini files from backup",
"Delete .ini files (requires backup)",
"",
"Back",
];
let descs: Vec<&str> = vec![
"Copy all .ini files from Config/Windows to NotAlterra_Backups",
"Restore .ini files from a previous backup",
"Remove .ini files — game regenerates defaults (backup required first)",
"",
"Return to main menu",
];
let mut state = ListState::default().with_selected(Some(0));
const INI_SKIP: &[usize] = &[3];
let ini_max = 4usize;
loop {
terminal.draw(|f| {
@@ -732,12 +899,20 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
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)));
let mut i = state.selected().unwrap_or(1);
loop {
i = i.saturating_sub(1);
if !INI_SKIP.contains(&i) || i == 0 { break; }
}
state.select(Some(i));
}
KeyCode::Down => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(3)));
let mut i = state.selected().unwrap_or(0);
loop {
i = (i + 1).min(ini_max);
if !INI_SKIP.contains(&i) || i == ini_max { break; }
}
state.select(Some(i));
}
KeyCode::Enter => {
let idx = state.selected().unwrap_or(0);
@@ -745,7 +920,7 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
0 => ini_backup_action(terminal, app, &ini_path)?,
1 => ini_restore_action(terminal, app, &ini_path)?,
2 => ini_delete_action(terminal, app, &ini_path)?,
3 => break,
4 => break,
_ => {}
}
}
+164 -8
View File
@@ -78,22 +78,28 @@ impl Default for AppState {
/// Draw the main menu.
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
let items: Vec<&str> = vec![
" Set save folder",
" Set Subnautica 2 location",
" Recover save file",
"",
" Set backup location",
" Create full backup",
" Restore full backup",
" Inspect save files",
"",
" Manage UE5 Config (.ini) files",
"",
" View disclaimer",
" Exit",
];
let descs: Vec<&str> = vec![
"Enter your save folder path manually",
"Enter your Subnautica 2 save folder path (paste supported)",
"Restore a save file from a backup",
"",
"Choose where backup archives are stored (default: next to the binary)",
"Copy the savegame files to NotAlterra_Backups",
"Restore a full backup from NotAlterra_Backups",
"View detailed GVAS metadata for each save file",
"",
"Backup, restore, or delete .ini files in Config/Windows",
"",
"Re-read the disclaimer and terms of use",
"Close NotAlterra",
];
@@ -337,7 +343,7 @@ pub fn draw_picker_with_info(
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let prompt = "↑/↓ navigate Enter select Esc cancel";
let prompt = "↑/↓ navigate | Enter select | Esc cancel";
draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, selected_info);
draw_status_bar(f, chunks[3], app);
}
@@ -447,11 +453,10 @@ fn draw_select_list(
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
.highlight_symbol(" ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, list_area, state);
@@ -586,6 +591,157 @@ fn draw_select_list_with_info(
}
}
// ── pip-list renderer (for split-layout file picker) ─────────────────────
/// 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; }
let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == 0 {
// Header row — match right pane header color
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if i >= 2 {
// Data rows — match right pane value color
dim_val
} else {
Style::default()
};
ListItem::new(Span::raw(*item)).style(style)
})
.collect();
let list = List::new(list_items)
.highlight_style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, area, state);
}
// ── right-pane metadata panel ────────────────────────────────────────────
/// 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; }
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])
} 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 },
);
y += 2;
if meta_lines.is_empty() {
// Placeholder
let msg = if filename.is_empty() {
Span::styled("select a file", dim)
} else {
Span::styled("select to load metadata", dim)
};
f.render_widget(
Paragraph::new(msg),
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
);
return;
}
// Content lines
let max_lines = area.height.saturating_sub(2) as usize;
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 },
);
}
}
// ── split-layout picker entry point ──────────────────────────────────────
/// Draw the file picker with a horizontal split: pip-style file list on the
/// left, live metadata preview on the right. Used by the .bak recover flow.
pub fn draw_picker_split(
f: &mut Frame,
app: &AppState,
items: &[&str],
_descs: &[&str],
state: &mut ListState,
selected_info: Option<&str>,
meta_lines: &[Line],
) {
let chunks = standard_layout(f.area(), items.len());
draw_header(f, chunks[0], app);
let menu_area = chunks[2];
let prompt = "↑/↓ navigate | Enter select | Esc cancel";
// Reserve bottom row of menu area for the prompt
let prompt_y = menu_area.y + menu_area.height.saturating_sub(1);
let content_area = Rect {
height: menu_area.height.saturating_sub(1),
..menu_area
};
// Split content into left (60%) and right (40%)
let halves = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(content_area);
// Left: pip list (full height of content area)
draw_select_list_pip(f, halves[0], items, state);
// Right: metadata pane
draw_right_pane(f, halves[1], selected_info.unwrap_or(""), meta_lines);
// Prompt at bottom-right of the full menu area
let prompt_len = prompt.len() as u16;
if menu_area.width > prompt_len + 2 {
f.render_widget(
Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Right),
Rect {
x: menu_area.x,
y: prompt_y,
width: menu_area.width.saturating_sub(2),
height: 1,
},
);
}
draw_status_bar(f, chunks[3], app);
}
/// Render the status bar at the bottom of the screen.
fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
draw_whale_separator(f, area, app);