use std::{
fs, io,
path::{Path, PathBuf},
};
use tui_file_explorer::{SortMode, Theme};
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";
#[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>,
}
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(crate) fn state_path() -> Option<PathBuf> {
config_dir().map(|d| d.join("state"))
}
pub(crate) fn legacy_theme_path() -> Option<PathBuf> {
config_dir().map(|d| d.join("theme"))
}
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,
}
}
pub(crate) fn load_state_from(path: &Path) -> AppState {
let Ok(content) = fs::read_to_string(path) else {
return AppState::default();
};
let mut state = AppState::default();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let (key, value) = (key.trim(), value.trim());
match key {
KEY_THEME if !value.is_empty() => {
state.theme = Some(value.to_string());
}
KEY_LAST_DIR if !value.is_empty() => {
let p = PathBuf::from(value);
if p.is_dir() {
state.last_dir = Some(p);
}
}
KEY_LAST_DIR_RIGHT if !value.is_empty() => {
let p = PathBuf::from(value);
if p.is_dir() {
state.last_dir_right = Some(p);
}
}
KEY_SORT_MODE => {
state.sort_mode = sort_mode_from_key(value);
}
KEY_SHOW_HIDDEN => {
state.show_hidden = value.parse::<bool>().ok();
}
KEY_SINGLE_PANE => {
state.single_pane = value.parse::<bool>().ok();
}
KEY_CD_ON_EXIT => {
state.cd_on_exit = value.parse::<bool>().ok();
}
_ => {
}
}
}
state
}
pub(crate) fn save_state_to(path: &Path, state: &AppState) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = String::from("# tfe state — do not edit manually\n");
if let Some(ref theme) = state.theme {
out.push_str(&format!("{KEY_THEME}={theme}\n"));
}
if let Some(ref dir) = state.last_dir {
out.push_str(&format!("{KEY_LAST_DIR}={}\n", dir.display()));
}
if let Some(ref dir) = state.last_dir_right {
out.push_str(&format!("{KEY_LAST_DIR_RIGHT}={}\n", dir.display()));
}
if let Some(mode) = state.sort_mode {
out.push_str(&format!("{KEY_SORT_MODE}={}\n", sort_mode_to_key(mode)));
}
if let Some(hidden) = state.show_hidden {
out.push_str(&format!("{KEY_SHOW_HIDDEN}={hidden}\n"));
}
if let Some(single) = state.single_pane {
out.push_str(&format!("{KEY_SINGLE_PANE}={single}\n"));
}
if let Some(cd) = state.cd_on_exit {
out.push_str(&format!("{KEY_CD_ON_EXIT}={cd}\n"));
}
fs::write(path, out)
}
pub fn load_state() -> AppState {
if let Some(path) = state_path() {
if path.exists() {
return load_state_from(&path);
}
}
let mut state = AppState::default();
if let Some(legacy) = legacy_theme_path() {
if let Ok(raw) = fs::read_to_string(&legacy) {
let name = raw.trim().to_string();
if !name.is_empty() {
state.theme = Some(name);
}
}
}
state
}
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)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn tmp_state_path() -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("tfe").join("state");
(dir, path)
}
fn tmp_theme_path() -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("tfe").join("theme");
(dir, path)
}
#[test]
fn full_state_round_trips() {
let (_dir, path) = tmp_state_path();
let original = AppState {
theme: Some("grape".into()),
last_dir: Some(std::env::temp_dir()),
last_dir_right: Some(std::env::temp_dir()),
sort_mode: Some(SortMode::SizeDesc),
show_hidden: Some(true),
single_pane: Some(false),
cd_on_exit: Some(true),
};
save_state_to(&path, &original).unwrap();
let loaded = load_state_from(&path);
assert_eq!(loaded, original);
}
#[test]
fn partial_state_leaves_absent_fields_as_none() {
let (_dir, path) = tmp_state_path();
let partial = AppState {
theme: Some("nord".into()),
..Default::default()
};
save_state_to(&path, &partial).unwrap();
let loaded = load_state_from(&path);
assert_eq!(loaded.theme, Some("nord".into()));
assert!(loaded.last_dir.is_none());
assert!(loaded.sort_mode.is_none());
assert!(loaded.show_hidden.is_none());
assert!(loaded.single_pane.is_none());
assert!(loaded.cd_on_exit.is_none());
}
#[test]
fn cd_on_exit_true_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
cd_on_exit: Some(true),
..Default::default()
};
save_state_to(&path, &state).unwrap();
let loaded = load_state_from(&path);
assert_eq!(loaded.cd_on_exit, Some(true));
}
#[test]
fn cd_on_exit_false_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
cd_on_exit: Some(false),
..Default::default()
};
save_state_to(&path, &state).unwrap();
let loaded = load_state_from(&path);
assert_eq!(loaded.cd_on_exit, Some(false));
}
#[test]
fn missing_file_returns_default_state() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent").join("state");
assert_eq!(load_state_from(&path), AppState::default());
}
#[test]
fn empty_file_returns_default_state() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "").unwrap();
assert_eq!(load_state_from(&path), AppState::default());
}
#[test]
fn save_state_creates_parent_directories() {
let (_dir, path) = tmp_state_path();
assert!(
!path.parent().unwrap().exists(),
"parent should not exist yet"
);
save_state_to(&path, &AppState::default()).unwrap();
assert!(path.exists(), "state file should have been created");
}
#[test]
fn save_state_overwrites_previous_content() {
let (_dir, path) = tmp_state_path();
let first = AppState {
theme: Some("grape".into()),
..Default::default()
};
let second = AppState {
theme: Some("ocean".into()),
..Default::default()
};
save_state_to(&path, &first).unwrap();
save_state_to(&path, &second).unwrap();
assert_eq!(load_state_from(&path).theme, Some("ocean".into()));
}
#[test]
fn comment_lines_are_ignored() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "# tfe state\n# another comment\ntheme=dracula\n").unwrap();
assert_eq!(load_state_from(&path).theme, Some("dracula".into()));
}
#[test]
fn blank_lines_are_ignored() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "\n\ntheme=nord\n\nsort_mode=name\n\n").unwrap();
let state = load_state_from(&path);
assert_eq!(state.theme, Some("nord".into()));
assert_eq!(state.sort_mode, Some(SortMode::Name));
}
#[test]
fn unknown_keys_are_silently_ignored() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
"theme=nord\nfuture_feature=42\nanother_new_key=xyz\n",
)
.unwrap();
let state = load_state_from(&path);
assert_eq!(state.theme, Some("nord".into()));
}
#[test]
fn malformed_lines_without_equals_are_skipped() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "this_has_no_equals\ntheme=grape\njust_text\n").unwrap();
let state = load_state_from(&path);
assert_eq!(state.theme, Some("grape".into()));
}
#[test]
fn value_containing_equals_sign_is_preserved() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "theme=weird=name\n").unwrap();
let state = load_state_from(&path);
assert_eq!(state.theme, Some("weird=name".into()));
}
#[test]
fn surrounding_whitespace_in_values_is_trimmed() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "theme= dracula \nshow_hidden= true \n").unwrap();
let state = load_state_from(&path);
assert_eq!(state.theme, Some("dracula".into()));
assert_eq!(state.show_hidden, Some(true));
}
#[test]
fn all_sort_modes_round_trip() {
for mode in [SortMode::Name, SortMode::SizeDesc, SortMode::Extension] {
let (_dir, path) = tmp_state_path();
let state = AppState {
sort_mode: Some(mode),
..Default::default()
};
save_state_to(&path, &state).unwrap();
let loaded = load_state_from(&path);
assert_eq!(
loaded.sort_mode,
Some(mode),
"round-trip failed for {mode:?}"
);
}
}
#[test]
fn unknown_sort_mode_value_yields_none() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "sort_mode=bogus_value\n").unwrap();
assert!(load_state_from(&path).sort_mode.is_none());
}
#[test]
fn show_hidden_true_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
show_hidden: Some(true),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(load_state_from(&path).show_hidden, Some(true));
}
#[test]
fn show_hidden_false_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
show_hidden: Some(false),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(load_state_from(&path).show_hidden, Some(false));
}
#[test]
fn single_pane_true_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
single_pane: Some(true),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(load_state_from(&path).single_pane, Some(true));
}
#[test]
fn single_pane_false_round_trips() {
let (_dir, path) = tmp_state_path();
let state = AppState {
single_pane: Some(false),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(load_state_from(&path).single_pane, Some(false));
}
#[test]
fn invalid_bool_value_yields_none() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "show_hidden=yes\nsingle_pane=1\n").unwrap();
let state = load_state_from(&path);
assert!(state.show_hidden.is_none(), "\"yes\" is not a valid bool");
assert!(state.single_pane.is_none(), "\"1\" is not a valid bool");
}
#[test]
fn last_dir_round_trips_for_existing_directory() {
let (_dir, path) = tmp_state_path();
let existing = std::env::temp_dir(); let state = AppState {
last_dir: Some(existing.clone()),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(load_state_from(&path).last_dir, Some(existing));
}
#[test]
fn last_dir_for_nonexistent_path_loads_as_none() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "last_dir=/this/path/does/not/exist/tfe_test_xyz\n").unwrap();
assert!(
load_state_from(&path).last_dir.is_none(),
"stale last_dir should be silently discarded"
);
}
#[test]
fn last_dir_empty_value_loads_as_none() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "last_dir=\n").unwrap();
assert!(load_state_from(&path).last_dir.is_none());
}
#[test]
fn theme_names_with_spaces_and_hyphens_round_trip() {
let names = [
"default",
"grape",
"catppuccin-mocha",
"tokyo night",
"Nord",
];
for name in names {
let (_dir, path) = tmp_state_path();
let state = AppState {
theme: Some(name.into()),
..Default::default()
};
save_state_to(&path, &state).unwrap();
assert_eq!(
load_state_from(&path).theme,
Some(name.to_string()),
"round-trip failed for theme {name:?}"
);
}
}
#[test]
fn empty_theme_value_loads_as_none() {
let (_dir, path) = tmp_state_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "theme=\n").unwrap();
assert!(load_state_from(&path).theme.is_none());
}
#[test]
fn legacy_theme_file_content_is_readable() {
let (_dir, path) = tmp_theme_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, " nord\n").unwrap();
let raw = fs::read_to_string(&path).unwrap();
let trimmed = raw.trim().to_string();
assert_eq!(trimmed, "nord");
}
#[test]
fn legacy_theme_file_with_trailing_whitespace_is_trimmed() {
let (_dir, path) = tmp_theme_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "\t dracula \n\n").unwrap();
let raw = fs::read_to_string(&path).unwrap();
let trimmed = raw.trim().to_string();
assert_eq!(trimmed, "dracula");
assert!(!trimmed.is_empty());
}
#[test]
fn resolve_theme_idx_finds_default_theme_at_zero() {
let themes = Theme::all_presets();
assert_eq!(resolve_theme_idx("default", &themes), 0);
}
#[test]
fn resolve_theme_idx_finds_named_theme() {
let themes = Theme::all_presets();
let idx = resolve_theme_idx("grape", &themes);
assert_ne!(idx, 0, "grape must not collide with the default index");
assert_eq!(themes[idx].0.to_lowercase(), "grape");
}
#[test]
fn resolve_theme_idx_is_case_insensitive() {
let themes = Theme::all_presets();
let lower = resolve_theme_idx("grape", &themes);
let upper = resolve_theme_idx("GRAPE", &themes);
let mixed = resolve_theme_idx("Grape", &themes);
assert_eq!(lower, upper, "lower vs upper");
assert_eq!(lower, mixed, "lower vs mixed");
}
#[test]
fn resolve_theme_idx_normalises_hyphens_to_spaces() {
let themes = Theme::all_presets();
let spaced = resolve_theme_idx("catppuccin mocha", &themes);
let hyphen = resolve_theme_idx("catppuccin-mocha", &themes);
assert_eq!(spaced, hyphen);
}
#[test]
fn resolve_theme_idx_unknown_name_returns_zero() {
let themes = Theme::all_presets();
assert_eq!(resolve_theme_idx("this-theme-does-not-exist", &themes), 0);
}
#[test]
fn resolve_theme_idx_persisted_name_survives_round_trip() {
let themes = Theme::all_presets();
let (_dir, path) = tmp_state_path();
let original_idx = resolve_theme_idx("nord", &themes);
let original_name = themes[original_idx].0;
let state = AppState {
theme: Some(original_name.into()),
..Default::default()
};
save_state_to(&path, &state).unwrap();
let loaded_name = load_state_from(&path).theme.unwrap();
let loaded_idx = resolve_theme_idx(&loaded_name, &themes);
assert_eq!(
original_idx, loaded_idx,
"theme index must survive a full save/load cycle"
);
}
#[test]
fn resolve_theme_idx_all_presets_are_found() {
let themes = Theme::all_presets();
for (i, (name, _, _)) in themes.iter().enumerate() {
let resolved = resolve_theme_idx(name, &themes);
assert_eq!(
resolved, i,
"preset {name:?} resolved to wrong index {resolved} (expected {i})"
);
}
}
#[test]
fn all_fields_independent_round_trips() {
let existing_dir = std::env::temp_dir();
let cases: Vec<AppState> = vec![
AppState {
theme: Some("dracula".into()),
..Default::default()
},
AppState {
last_dir: Some(existing_dir.clone()),
..Default::default()
},
AppState {
sort_mode: Some(SortMode::Extension),
..Default::default()
},
AppState {
show_hidden: Some(true),
..Default::default()
},
AppState {
single_pane: Some(true),
..Default::default()
},
];
for case in cases {
let (_dir, path) = tmp_state_path();
save_state_to(&path, &case).unwrap();
let loaded = load_state_from(&path);
assert_eq!(loaded, case, "round-trip failed for {case:?}");
}
}
#[test]
fn last_dir_right_is_preserved_when_single_pane_is_active() {
let (_dir, path) = tmp_state_path();
let left_dir = std::env::temp_dir();
let right_dir = {
let p = std::env::temp_dir().join("tfe_test_right_pane_persist");
std::fs::create_dir_all(&p).unwrap();
p
};
let first_session = AppState {
last_dir: Some(left_dir.clone()),
last_dir_right: Some(right_dir.clone()),
single_pane: Some(false),
..Default::default()
};
save_state_to(&path, &first_session).unwrap();
let saved = load_state_from(&path);
assert_eq!(
saved.last_dir_right,
Some(right_dir.clone()),
"right pane dir should have survived the first save"
);
let mirrored_right = left_dir.clone(); let last_dir_right = if true
{
saved.last_dir_right.clone() } else {
Some(mirrored_right)
};
let second_session = AppState {
last_dir: Some(left_dir.clone()),
last_dir_right,
single_pane: Some(true),
..Default::default()
};
save_state_to(&path, &second_session).unwrap();
let restored = load_state_from(&path);
assert_eq!(
restored.last_dir_right,
Some(right_dir.clone()),
"last_dir_right must not be clobbered by the hidden right pane's mirrored path \
when single_pane was active on exit"
);
assert_ne!(
restored.last_dir_right, restored.last_dir,
"right and left pane dirs should remain independent after a single-pane session"
);
}
#[test]
fn last_dir_right_is_none_on_fresh_install() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent").join("state");
let state = load_state_from(&path);
assert!(
state.last_dir_right.is_none(),
"fresh install should have no persisted right-pane dir"
);
}
#[test]
fn last_dir_right_is_updated_when_dual_pane_is_active() {
let (_dir, path) = tmp_state_path();
let left_dir = std::env::temp_dir();
let right_dir = {
let p = std::env::temp_dir().join("tfe_test_right_dual");
std::fs::create_dir_all(&p).unwrap();
p
};
let state = AppState {
last_dir: Some(left_dir.clone()),
last_dir_right: Some(right_dir.clone()),
single_pane: Some(false),
..Default::default()
};
save_state_to(&path, &state).unwrap();
let loaded = load_state_from(&path);
assert_eq!(
loaded.last_dir_right,
Some(right_dir),
"dual-pane exit should persist the right pane's actual directory"
);
}
}