Skip to main content

tui_file_explorer/
persistence.rs

1//! Persist application state between sessions.
2//!
3//! State is stored at `$XDG_CONFIG_HOME/tfe/settings.json` (falling back to
4//! `~/.config/tfe/settings.json`) as a JSON file.
5//!
6//! Writes use atomic rename (write to `.tmp`, then rename) to avoid
7//! corruption on crash.
8//!
9//! Unknown keys are silently ignored so that older versions of the binary can
10//! read state files written by newer ones without errors.
11
12use std::{
13    fs, io,
14    path::{Path, PathBuf},
15};
16
17use serde::{Deserialize, Serialize};
18
19use crate::{SortMode, Theme};
20
21// ── SortMode serde helper ─────────────────────────────────────────────────────
22
23mod sort_mode_serde {
24    use super::*;
25    use serde::{self, Deserialize, Deserializer, Serializer};
26
27    pub fn serialize<S>(value: &Option<SortMode>, serializer: S) -> Result<S::Ok, S::Error>
28    where
29        S: Serializer,
30    {
31        match value {
32            Some(m) => serializer.serialize_some(sort_mode_to_key(*m)),
33            None => serializer.serialize_none(),
34        }
35    }
36
37    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SortMode>, D::Error>
38    where
39        D: Deserializer<'de>,
40    {
41        let opt: Option<String> = Option::deserialize(deserializer)?;
42        Ok(opt.and_then(|s| sort_mode_from_key(&s)))
43    }
44}
45
46// ── AppState ──────────────────────────────────────────────────────────────────
47
48/// All application state that is persisted between sessions.
49///
50/// Every field is an `Option` so that absent keys are handled gracefully —
51/// the caller provides a sensible default for any field that is `None`.
52///
53/// # Example
54///
55/// ```
56/// use tui_file_explorer::persistence::{load_state_from, save_state_to, AppState};
57/// use tui_file_explorer::SortMode;
58///
59/// let path = tempfile::tempdir().unwrap().path().join("state.toml");
60/// let mut state = load_state_from(&path); // missing file → defaults
61/// state.theme       = Some("nord".into());
62/// state.sort_mode   = Some(SortMode::SizeDesc);
63/// state.show_hidden = Some(true);
64/// save_state_to(&path, &state).unwrap();
65/// assert_eq!(load_state_from(&path), state);
66/// ```
67#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(default)]
69pub struct AppState {
70    /// Colour theme name (e.g. `"grape"`, `"nord"`, `"catppuccin-mocha"`).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub theme: Option<String>,
73
74    /// Directory that was open in the left pane when the app last exited.
75    ///
76    /// Only restored when the path still exists as a directory; stale entries
77    /// (deleted directories) are silently ignored.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub last_dir: Option<PathBuf>,
80
81    /// Directory that was open in the right pane when the app last exited.
82    ///
83    /// Only restored when the path still exists as a directory; stale entries
84    /// (deleted directories) are silently ignored.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub last_dir_right: Option<PathBuf>,
87
88    /// Active sort mode: `Name`, `SizeDesc`, or `Extension`.
89    #[serde(
90        default,
91        skip_serializing_if = "Option::is_none",
92        with = "sort_mode_serde"
93    )]
94    pub sort_mode: Option<SortMode>,
95
96    /// Whether hidden (dot-prefixed) files were visible.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub show_hidden: Option<bool>,
99
100    /// Whether file/folder sizes were shown.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub show_sizes: Option<bool>,
103
104    /// Whether recursive folder sizes were computed (`false` = item counts only).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub show_dir_sizes: Option<bool>,
107
108    /// Whether deletions go to the OS trash (needs the `trash` feature).
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub use_trash: Option<bool>,
111
112    /// Whether git change markers were shown.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub show_git: Option<bool>,
115
116    /// Bookmarked folders.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub bookmarks: Option<Vec<PathBuf>>,
119
120    /// Whether single-pane mode was active.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub single_pane: Option<bool>,
123
124    /// Whether the cd-on-exit feature is enabled.
125    ///
126    /// When `true`, `tfe` prints the active pane's current directory to stdout
127    /// on dismiss so the shell wrapper can `cd` to it.  When `false` (default),
128    /// dismissing without a selection prints nothing and exits with code 1.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub cd_on_exit: Option<bool>,
131
132    /// The editor to use when the user presses `e` on a file.
133    ///
134    /// Serialised as a short key string (e.g. `"helix"`, `"nvim"`,
135    /// `"custom:code"`).  `None` means "use the compiled-in default" (Helix).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub editor: Option<String>,
138
139    /// Which pane (left or right) had keyboard focus when the app last exited.
140    /// Serialised as `"left"` or `"right"`.
141    ///
142    /// Superseded by `active_pane_idx`; still written/read for backward
143    /// compatibility with older `tfe` versions that only support two panes.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub active_pane: Option<String>,
146
147    /// Starting directory for every pane, in left-to-right order.
148    ///
149    /// Supersedes `last_dir` / `last_dir_right` now that `tfe` supports an
150    /// arbitrary number of panes. When absent (older state files), the
151    /// caller falls back to reconstructing a 2-entry list from `last_dir` /
152    /// `last_dir_right`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub pane_dirs: Option<Vec<PathBuf>>,
155
156    /// Index of the pane that had keyboard focus when the app last exited.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub active_pane_idx: Option<usize>,
159}
160
161// ── Config-dir helpers ────────────────────────────────────────────────────────
162
163/// Returns the `tfe` config directory, following XDG conventions.
164///
165/// Priority: `$XDG_CONFIG_HOME/tfe` → `$HOME/.config/tfe` → `None`.
166pub(crate) fn config_dir() -> Option<PathBuf> {
167    let base = std::env::var_os("XDG_CONFIG_HOME")
168        .map(PathBuf::from)
169        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
170    Some(base.join("tfe"))
171}
172
173/// Path of the JSON state file (`$XDG_CONFIG_HOME/tfe/settings.json`).
174#[must_use]
175pub fn state_path() -> Option<PathBuf> {
176    config_dir().map(|d| d.join("settings.json"))
177}
178
179// ── SortMode serialisation helpers ───────────────────────────────────────────
180
181/// Convert a `SortMode` to its stable on-disk key string.
182fn sort_mode_to_key(mode: SortMode) -> &'static str {
183    match mode {
184        SortMode::Name => "name",
185        SortMode::SizeDesc => "size_desc",
186        SortMode::Extension => "extension",
187    }
188}
189
190/// Parse a `SortMode` from its on-disk key string.
191///
192/// Returns `None` for unrecognised values so that the field is left as `None`
193/// rather than silently defaulting to `Name`.
194fn sort_mode_from_key(s: &str) -> Option<SortMode> {
195    match s {
196        "name" => Some(SortMode::Name),
197        "size_desc" => Some(SortMode::SizeDesc),
198        "extension" => Some(SortMode::Extension),
199        _ => None,
200    }
201}
202
203// ── Path-based helpers (used by tests and the public API) ─────────────────────
204
205/// Load state from a JSON file at `path`.
206///
207/// Returns a default empty state if the file does not exist or cannot be
208/// parsed. Directory fields are validated: stale paths are set to `None`.
209#[must_use]
210pub fn load_state_from(path: &Path) -> AppState {
211    try_load_state_from(path).unwrap_or_default()
212}
213
214/// Load state from a JSON file while preserving I/O and parse failures.
215pub fn try_load_state_from(path: &Path) -> io::Result<AppState> {
216    let data = fs::read_to_string(path)?;
217    let mut state = serde_json::from_str::<AppState>(&data)
218        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
219
220    // Validate that directory paths still exist on disk.
221    if let Some(ref p) = state.last_dir
222        && !p.is_dir()
223    {
224        state.last_dir = None;
225    }
226    if let Some(ref p) = state.last_dir_right
227        && !p.is_dir()
228    {
229        state.last_dir_right = None;
230    }
231    if let Some(ref dirs) = state.pane_dirs
232        && (dirs.is_empty() || dirs.iter().any(|d| !d.is_dir()))
233    {
234        state.pane_dirs = None;
235    }
236
237    Ok(state)
238}
239
240/// Save state to a JSON file at `path`.
241///
242/// Uses atomic write (write to `.tmp`, then rename) to avoid corruption.
243/// Creates parent directories if they don't exist.
244pub fn save_state_to(path: &Path, state: &AppState) -> io::Result<()> {
245    if let Some(parent) = path.parent() {
246        fs::create_dir_all(parent)?;
247    }
248    let json = serde_json::to_string_pretty(state).map_err(|e| io::Error::other(e.to_string()))?;
249
250    let tmp_path = path.with_extension("tmp");
251    fs::write(&tmp_path, json.as_bytes())?;
252    fs::rename(&tmp_path, path)?;
253    Ok(())
254}
255
256// ── Public API ────────────────────────────────────────────────────────────────
257
258/// Load application state from the default XDG config path.
259///
260/// Never returns an error — any I/O problem simply yields an empty state so
261/// that the app can always start with sensible defaults.
262#[must_use]
263pub fn load_state() -> AppState {
264    let Some(json_path) = state_path() else {
265        return AppState::default();
266    };
267    load_state_from(&json_path)
268}
269
270/// Persist `state` to the default XDG config path.
271///
272/// Errors are silently discarded — persistence is best-effort and must never
273/// cause the application to crash or block.
274pub fn save_state(state: &AppState) {
275    let _ = try_save_state(state);
276}
277
278/// Persist `state` like [`save_state`], but report failures.
279///
280/// Returns `Ok(())` when there is no config directory to write to (nothing
281/// to persist is not an error).
282///
283/// # Errors
284///
285/// Returns the underlying [`io::Error`] when the state file cannot be written.
286pub fn try_save_state(state: &AppState) -> io::Result<()> {
287    match state_path() {
288        Some(path) => save_state_to(&path, state),
289        None => Ok(()),
290    }
291}
292
293// ── Theme resolution ──────────────────────────────────────────────────────────
294
295/// Find the index into `themes` whose name matches `name`.
296///
297/// Matching is **case-insensitive** and treats **hyphens as spaces**, so
298/// `"catppuccin-mocha"` matches `"Catppuccin Mocha"`.
299///
300/// Returns `0` (the built-in default theme) when no match is found, and
301/// prints a hint to stderr suggesting `--list-themes`.
302#[must_use]
303pub fn resolve_theme_idx(name: &str, themes: &[(&str, Theme)]) -> usize {
304    let key = name.to_lowercase().replace('-', " ");
305    for (i, (n, _)) in themes.iter().enumerate() {
306        if n.to_lowercase().replace('-', " ") == key {
307            return i;
308        }
309    }
310    eprintln!(
311        "tfe: unknown theme {name:?} — falling back to default. \
312         Run `tfe --list-themes` to see available options."
313    );
314    0
315}
316
317#[cfg(test)]
318#[path = "persistence_tests.rs"]
319mod tests;