use std::{
fs, io,
path::{Path, PathBuf},
};
use redb::{Database, ReadableDatabase, TableDefinition};
use crate::{SortMode, Theme};
const STATE_TABLE: TableDefinition<&str, &str> = TableDefinition::new("state");
const KEY_THEME: &str = "theme";
const KEY_LAST_DIR: &str = "last_dir";
const KEY_LAST_DIR_RIGHT: &str = "last_dir_right";
const KEY_SORT_MODE: &str = "sort_mode";
const KEY_SHOW_HIDDEN: &str = "show_hidden";
const KEY_SINGLE_PANE: &str = "single_pane";
const KEY_CD_ON_EXIT: &str = "cd_on_exit";
const KEY_EDITOR: &str = "editor";
const KEY_ACTIVE_PANE: &str = "active_pane";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct AppState {
pub theme: Option<String>,
pub last_dir: Option<PathBuf>,
pub last_dir_right: Option<PathBuf>,
pub sort_mode: Option<SortMode>,
pub show_hidden: Option<bool>,
pub single_pane: Option<bool>,
pub cd_on_exit: Option<bool>,
pub editor: Option<String>,
pub active_pane: Option<String>,
}
fn config_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(base.join("tfe"))
}
pub fn state_path() -> Option<PathBuf> {
config_dir().map(|d| d.join("state.redb"))
}
fn sort_mode_to_key(mode: SortMode) -> &'static str {
match mode {
SortMode::Name => "name",
SortMode::SizeDesc => "size_desc",
SortMode::Extension => "extension",
}
}
fn sort_mode_from_key(s: &str) -> Option<SortMode> {
match s {
"name" => Some(SortMode::Name),
"size_desc" => Some(SortMode::SizeDesc),
"extension" => Some(SortMode::Extension),
_ => None,
}
}
fn get_str(db: &Database, key: &str) -> Option<String> {
let txn = db.begin_read().ok()?;
let table = txn.open_table(STATE_TABLE).ok()?;
let guard = table.get(key).ok()??;
Some(guard.value().to_string())
}
fn get_dir(db: &Database, key: &str) -> Option<PathBuf> {
let raw = get_str(db, key)?;
if raw.is_empty() {
return None;
}
let p = PathBuf::from(raw);
if p.is_dir() {
Some(p)
} else {
None
}
}
fn get_bool(db: &Database, key: &str) -> Option<bool> {
get_str(db, key)?.parse::<bool>().ok()
}
pub(crate) fn load_state_from_db(db: &Database) -> AppState {
AppState {
theme: get_str(db, KEY_THEME),
last_dir: get_dir(db, KEY_LAST_DIR),
last_dir_right: get_dir(db, KEY_LAST_DIR_RIGHT),
sort_mode: get_str(db, KEY_SORT_MODE).and_then(|s| sort_mode_from_key(&s)),
show_hidden: get_bool(db, KEY_SHOW_HIDDEN),
single_pane: get_bool(db, KEY_SINGLE_PANE),
cd_on_exit: get_bool(db, KEY_CD_ON_EXIT),
editor: get_str(db, KEY_EDITOR),
active_pane: get_str(db, KEY_ACTIVE_PANE),
}
}
pub(crate) fn save_state_to_db(db: &Database, state: &AppState) -> Result<(), redb::Error> {
let txn = db.begin_write()?;
{
let mut table = txn.open_table(STATE_TABLE)?;
macro_rules! put {
($key:expr, $val:expr) => {
match $val {
Some(ref v) => {
table.insert($key, v.as_str())?;
}
None => {
let _ = table.remove($key);
}
}
};
}
put!(KEY_THEME, &state.theme);
put!(
KEY_LAST_DIR,
&state.last_dir.as_ref().map(|p| p.display().to_string())
);
put!(
KEY_LAST_DIR_RIGHT,
&state
.last_dir_right
.as_ref()
.map(|p| p.display().to_string())
);
put!(
KEY_SORT_MODE,
&state.sort_mode.map(|m| sort_mode_to_key(m).to_string())
);
put!(KEY_SHOW_HIDDEN, &state.show_hidden.map(|b| b.to_string()));
put!(KEY_SINGLE_PANE, &state.single_pane.map(|b| b.to_string()));
put!(KEY_CD_ON_EXIT, &state.cd_on_exit.map(|b| b.to_string()));
put!(KEY_EDITOR, &state.editor);
put!(KEY_ACTIVE_PANE, &state.active_pane);
}
txn.commit()?;
Ok(())
}
pub(crate) fn load_state_from(path: &Path) -> AppState {
if !path.exists() {
return AppState::default();
}
let Ok(db) = Database::open(path) else {
return AppState::default();
};
load_state_from_db(&db)
}
pub(crate) fn save_state_to(path: &Path, state: &AppState) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let db = Database::create(path).map_err(|e| io::Error::other(e.to_string()))?;
save_state_to_db(&db, state).map_err(|e| io::Error::other(e.to_string()))?;
Ok(())
}
pub fn load_state() -> AppState {
if let Some(path) = state_path() {
return load_state_from(&path);
}
AppState::default()
}
pub fn save_state(state: &AppState) {
if let Some(path) = state_path() {
let _ = save_state_to(&path, state);
}
}
pub fn resolve_theme_idx(name: &str, themes: &[(&str, &str, Theme)]) -> usize {
let key = name.to_lowercase().replace('-', " ");
for (i, (n, _, _)) in themes.iter().enumerate() {
if n.to_lowercase().replace('-', " ") == key {
return i;
}
}
eprintln!(
"tfe: unknown theme {:?} — falling back to default. \
Run `tfe --list-themes` to see available options.",
name
);
0
}
#[cfg(test)]
#[path = "persistence_tests.rs"]
mod tests;