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}