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;