Skip to main content

fresh/app/
orchestrator_persistence.rs

1//! Cross-restart persistence for Orchestrator sessions and
2//! plugin global state.
3//!
4//! On quit, `save_orchestrator_state` writes:
5//!   - `<working_dir>/.fresh/windows.json` — list of sessions
6//!     (id, label, root, per-session plugin_state) plus the
7//!     last-active session id and the next id to allocate so
8//!     id-based references on disk stay stable across restarts.
9//!   - `<working_dir>/.fresh/state/<plugin>.json` — one file per
10//!     plugin holding its `editor.setGlobalState(...)` map.
11//!
12//! On startup, [`read_persisted_windows_env`] +
13//! [`read_persisted_plugin_state`] are called from
14//! `Editor::with_options` (see `editor_init.rs`) *before* the
15//! editor struct is built. The factory uses the parsed envelope
16//! to pick the active window's id and root (so the spawned LSP
17//! targets the right project), to attach the seed buffer +
18//! split layout to the active window directly, and to populate
19//! `plugin_global_state` so plugins reading `getGlobalState`
20//! during their on-load handler see the previous run's values.
21//! All non-active persisted windows come back as inert shells
22//! (no splits, no LSP); first dive into one re-warms it on
23//! demand exactly like a freshly-`createWindow`-ed session.
24//!
25//! The "warm" half of warm-swap (split layout, LSP, file
26//! explorer state) is intentionally *not* persisted: the only
27//! purpose of warmth is "fast switch within one editor
28//! lifetime"; serialising those across restarts buys nothing
29//! and is a large amount of fragile state-machine work.
30//! Re-warming on first dive is fast enough.
31
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36use super::Editor;
37
38/// One session as it appears on disk.
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub(crate) struct PersistedWindow {
41    pub(crate) id: u64,
42    pub(crate) label: String,
43    pub(crate) root: PathBuf,
44    /// Per-session plugin state (the same map kept in
45    /// `Session.plugin_state`). Empty plugins / empty keys are
46    /// stripped on save.
47    #[serde(default)]
48    pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
49}
50
51/// Top-level shape of `.fresh/windows.json`.
52#[derive(Serialize, Deserialize, Debug, Clone)]
53pub(crate) struct PersistedWindows {
54    /// Last active session id at quit time. The loader makes
55    /// this session the active one again. If missing or
56    /// dangling, falls back to the base session.
57    pub(crate) active: u64,
58    /// `next_window_id` at quit time — preserved so newly
59    /// created sessions after restart don't collide with ids
60    /// the user might still see in plugin state.
61    pub(crate) next_id: u64,
62    pub(crate) windows: Vec<PersistedWindow>,
63}
64
65/// Read `.fresh/windows.json` from `working_dir` and return the
66/// parsed envelope. Returns `None` when the file doesn't exist or
67/// fails to parse — those are not error cases at the editor level
68/// (a missing or corrupted file just means "no persisted state").
69///
70/// Pure file IO + JSON parse. Used by the editor factory to
71/// decide how to build the initial windows map before any `Editor`
72/// instance exists.
73pub(crate) fn read_persisted_windows_env(
74    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
75    working_dir: &Path,
76) -> Option<PersistedWindows> {
77    let windows_p = windows_path(working_dir);
78    if !filesystem.exists(&windows_p) {
79        return None;
80    }
81    match filesystem.read_file(&windows_p) {
82        Ok(bytes) => match serde_json::from_slice::<PersistedWindows>(&bytes) {
83            Ok(env) => Some(env),
84            Err(e) => {
85                tracing::warn!("orchestrator persistence: failed to parse {windows_p:?}: {e}");
86                None
87            }
88        },
89        Err(e) => {
90            tracing::warn!("orchestrator persistence: failed to read {windows_p:?}: {e}");
91            None
92        }
93    }
94}
95
96/// Read every `.fresh/state/<plugin>.json` from `working_dir` into
97/// a flat `plugin → key → value` map. Skips files with unsafe
98/// names, non-JSON extensions, parse errors, and empty maps. Same
99/// motivations as [`read_persisted_windows_env`] — used by the
100/// editor factory pre-construction.
101pub(crate) fn read_persisted_plugin_state(
102    filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
103    working_dir: &Path,
104) -> HashMap<String, HashMap<String, serde_json::Value>> {
105    let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
106    let state_dir = state_dir(working_dir);
107    if !filesystem.exists(&state_dir) {
108        return out;
109    }
110    let entries = match filesystem.read_dir(&state_dir) {
111        Ok(es) => es,
112        Err(e) => {
113            tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
114            return out;
115        }
116    };
117    for entry in entries {
118        let path = entry.path;
119        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
120            continue;
121        };
122        if !plugin_name_is_safe(stem) {
123            continue;
124        }
125        if path.extension().and_then(|e| e.to_str()) != Some("json") {
126            continue;
127        }
128        match filesystem.read_file(&path) {
129            Ok(bytes) => {
130                match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
131                    Ok(map) if !map.is_empty() => {
132                        out.insert(stem.to_owned(), map);
133                    }
134                    Ok(_) => {}
135                    Err(e) => {
136                        tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
137                    }
138                }
139            }
140            Err(e) => {
141                tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
142            }
143        }
144    }
145    out
146}
147
148fn windows_path(working_dir: &Path) -> PathBuf {
149    working_dir.join(".fresh").join("windows.json")
150}
151
152fn state_dir(working_dir: &Path) -> PathBuf {
153    working_dir.join(".fresh").join("state")
154}
155
156fn plugin_state_path(working_dir: &Path, plugin: &str) -> PathBuf {
157    // Plugin names are short identifiers (`orchestrator`,
158    // `live_grep`, …) so no escaping is needed for typical
159    // input. Reject anything that would escape the state dir to
160    // avoid `../`-style traversal in case a plugin picks a
161    // pathological name.
162    state_dir(working_dir).join(format!("{plugin}.json"))
163}
164
165fn plugin_name_is_safe(name: &str) -> bool {
166    !name.is_empty()
167        && name
168            .chars()
169            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
170        && !name.starts_with('.')
171}
172
173impl Editor {
174    /// Persist `sessions` + `plugin_global_state` to disk. Best-
175    /// effort: filesystem errors are logged at WARN and swallowed
176    /// so a transient `.fresh/` permission glitch doesn't block
177    /// quit.
178    pub fn save_orchestrator_state(&self) {
179        let working_dir = self.working_dir().to_path_buf();
180        let fresh_dir = working_dir.join(".fresh");
181        if let Err(e) = self.authority.filesystem.create_dir_all(&fresh_dir) {
182            tracing::warn!("orchestrator persistence: failed to create {fresh_dir:?}: {e}");
183            return;
184        }
185
186        // Windows.
187        let mut windows: Vec<PersistedWindow> = self
188            .windows
189            .values()
190            .map(|s| PersistedWindow {
191                id: s.id.0,
192                label: s.label.clone(),
193                root: s.root.clone(),
194                plugin_state: s.plugin_state.clone(),
195            })
196            .collect();
197        // Stable on-disk order — `HashMap` iteration order would
198        // make the file diff differently every quit, which is
199        // ugly for users who keep `.fresh/` in version control.
200        windows.sort_by_key(|s| s.id);
201        let envelope = PersistedWindows {
202            active: self.active_window.0,
203            next_id: self.next_window_id,
204            windows,
205        };
206        match serde_json::to_vec_pretty(&envelope) {
207            Ok(bytes) => {
208                let path = windows_path(&working_dir);
209                if let Err(e) = self.authority.filesystem.write_file(&path, &bytes) {
210                    tracing::warn!("orchestrator persistence: failed to write {path:?}: {e}");
211                }
212            }
213            Err(e) => {
214                tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
215            }
216        }
217
218        // Plugin global state — one file per plugin so concurrent
219        // editors writing different plugins don't clobber each
220        // other (a future feature; today single-process).
221        let state_dir = state_dir(&working_dir);
222        if !self.plugin_global_state.is_empty() {
223            if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
224                tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
225                return;
226            }
227        }
228        for (plugin, map) in &self.plugin_global_state {
229            if !plugin_name_is_safe(plugin) {
230                tracing::warn!(
231                    "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
232                );
233                continue;
234            }
235            if map.is_empty() {
236                continue;
237            }
238            match serde_json::to_vec_pretty(map) {
239                Ok(bytes) => {
240                    let path = plugin_state_path(&working_dir, plugin);
241                    if let Err(e) = self.authority.filesystem.write_file(&path, &bytes) {
242                        tracing::warn!("orchestrator persistence: failed to write {path:?}: {e}");
243                    }
244                }
245                Err(e) => {
246                    tracing::warn!(
247                        "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
248                    );
249                }
250            }
251        }
252    }
253}