use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::Editor;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct PersistedWindow {
pub(crate) id: u64,
pub(crate) label: String,
pub(crate) root: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) project_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "is_false")]
pub(crate) shared_worktree: bool,
#[serde(default)]
pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
}
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct PersistedWindows {
#[serde(default = "default_version")]
pub(crate) version: u32,
pub(crate) active: u64,
pub(crate) next_id: u64,
pub(crate) windows: Vec<PersistedWindow>,
}
fn default_version() -> u32 {
1
}
const CURRENT_VERSION: u32 = 2;
pub(crate) fn read_persisted_windows_env(
filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
data_dir: &Path,
_working_dir: &Path,
) -> Option<PersistedWindows> {
let global_p = global_windows_path(data_dir);
if !filesystem.exists(&global_p) {
migrate_legacy_windows(filesystem, data_dir);
}
if !filesystem.exists(&global_p) {
return None;
}
match filesystem.read_file(&global_p) {
Ok(bytes) => match serde_json::from_slice::<PersistedWindows>(&bytes) {
Ok(env) => Some(env),
Err(e) => {
tracing::warn!("orchestrator persistence: failed to parse {global_p:?}: {e}");
None
}
},
Err(e) => {
tracing::warn!("orchestrator persistence: failed to read {global_p:?}: {e}");
None
}
}
}
pub(crate) fn pick_active_window_for_cwd<'a>(
env: Option<&'a PersistedWindows>,
cwd: &Path,
) -> Option<&'a PersistedWindow> {
let env = env?;
if let Some(w) = env
.windows
.iter()
.find(|w| w.id == env.active && window_matches_cwd(w, cwd))
{
return Some(w);
}
env.windows
.iter()
.filter(|w| window_matches_cwd(w, cwd))
.max_by_key(|w| w.id)
}
fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
paths_equal(&w.root, cwd)
}
fn paths_equal(a: &Path, b: &Path) -> bool {
let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
ca == cb
}
fn migrate_legacy_windows(
filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
data_dir: &Path,
) {
let orch_root = data_dir.join("orchestrator");
if !filesystem.exists(&orch_root) {
return;
}
let entries = match filesystem.read_dir(&orch_root) {
Ok(es) => es,
Err(_) => return,
};
let mut merged_windows: Vec<PersistedWindow> = Vec::new();
let mut merged_active: u64 = 1;
let mut merged_next_id: u64 = 2;
let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
for entry in entries {
let dir = entry.path;
if !filesystem.is_dir(&dir).unwrap_or(false) {
continue;
}
let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if dir_name == "state" {
continue;
}
let legacy_p = dir.join("windows.json");
if !filesystem.exists(&legacy_p) {
continue;
}
let bytes = match filesystem.read_file(&legacy_p) {
Ok(b) => b,
Err(_) => continue,
};
let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
Ok(e) => e,
Err(_) => continue,
};
let project_path = crate::workspace::decode_filename_to_path(&dir_name)
.unwrap_or_else(|| PathBuf::from(dir_name.clone()));
let mut local_renum: HashMap<u64, u64> = HashMap::new();
for mut w in env.windows.into_iter() {
if w.project_path.is_none() {
w.project_path = Some(project_path.clone());
}
if used_ids.contains(&w.id) {
let new_id = merged_next_id;
local_renum.insert(w.id, new_id);
merged_next_id = merged_next_id.saturating_add(1);
used_ids.insert(new_id);
w.id = new_id;
} else {
used_ids.insert(w.id);
merged_next_id = merged_next_id.max(w.id.saturating_add(1));
}
merged_windows.push(w);
}
let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
merged_active = active_id;
legacy_to_rename.push(legacy_p);
}
if merged_windows.is_empty() {
return;
}
merged_windows.sort_by_key(|w| w.id);
let envelope = PersistedWindows {
version: CURRENT_VERSION,
active: merged_active,
next_id: merged_next_id,
windows: merged_windows,
};
let global_p = global_windows_path(data_dir);
if let Err(e) = filesystem.create_dir_all(&orch_root) {
tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
return;
}
let bytes = match serde_json::to_vec_pretty(&envelope) {
Ok(b) => b,
Err(e) => {
tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
return;
}
};
if let Err(e) = filesystem.write_file(&global_p, &bytes) {
tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
return;
}
for legacy_p in legacy_to_rename {
let backup = legacy_p.with_extension("json.migrated.bak");
if let Err(e) = filesystem.rename(&legacy_p, &backup) {
tracing::warn!(
"orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
);
}
}
tracing::info!(
"orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
envelope.windows.len(),
global_p
);
}
pub(crate) fn read_persisted_plugin_state(
filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
data_dir: &Path,
_working_dir: &Path,
) -> HashMap<String, HashMap<String, serde_json::Value>> {
let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
let state_dir = global_state_dir(data_dir);
if !filesystem.exists(&state_dir) {
migrate_legacy_plugin_state(filesystem, data_dir);
}
if !filesystem.exists(&state_dir) {
return out;
}
let entries = match filesystem.read_dir(&state_dir) {
Ok(es) => es,
Err(e) => {
tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
return out;
}
};
for entry in entries {
let path = entry.path;
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if !plugin_name_is_safe(stem) {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match filesystem.read_file(&path) {
Ok(bytes) => {
match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
Ok(map) if !map.is_empty() => {
out.insert(stem.to_owned(), map);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
}
}
}
Err(e) => {
tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
}
}
}
out
}
fn orchestrator_dir(data_dir: &Path) -> PathBuf {
data_dir.join("orchestrator")
}
fn global_windows_path(data_dir: &Path) -> PathBuf {
orchestrator_dir(data_dir).join("windows.json")
}
fn global_state_dir(data_dir: &Path) -> PathBuf {
orchestrator_dir(data_dir).join("state")
}
fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
global_state_dir(data_dir).join(format!("{plugin}.json"))
}
fn plugin_name_is_safe(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
&& !name.starts_with('.')
}
fn migrate_legacy_plugin_state(
filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
data_dir: &Path,
) {
let orch_root = data_dir.join("orchestrator");
if !filesystem.exists(&orch_root) {
return;
}
let cwd_entries = match filesystem.read_dir(&orch_root) {
Ok(es) => es,
Err(_) => return,
};
let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
for cwd_entry in cwd_entries {
let dir = cwd_entry.path;
if !filesystem.is_dir(&dir).unwrap_or(false) {
continue;
}
let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if dir_name == "state" {
continue;
}
let state_dir = dir.join("state");
if !filesystem.exists(&state_dir) {
continue;
}
let plugin_entries = match filesystem.read_dir(&state_dir) {
Ok(es) => es,
Err(_) => continue,
};
for pe in plugin_entries {
let p = pe.path;
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if !plugin_name_is_safe(stem) {
continue;
}
if p.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let bytes = match filesystem.read_file(&p) {
Ok(b) => b,
Err(_) => continue,
};
let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(_) => continue,
};
let slot = merged.entry(stem.to_owned()).or_default();
for (k, v) in map {
slot.insert(k, v);
}
legacy_to_rename.push(p);
}
}
if merged.is_empty() {
return;
}
let target_state_dir = global_state_dir(data_dir);
if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
return;
}
for (plugin, map) in &merged {
let path = global_plugin_state_path(data_dir, plugin);
let bytes = match serde_json::to_vec_pretty(map) {
Ok(b) => b,
Err(e) => {
tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
continue;
}
};
if let Err(e) = filesystem.write_file(&path, &bytes) {
tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
}
}
for legacy_p in legacy_to_rename {
let backup = legacy_p.with_extension("json.migrated.bak");
if let Err(e) = filesystem.rename(&legacy_p, &backup) {
tracing::warn!(
"orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
);
}
}
tracing::info!(
"orchestrator persistence: migrated plugin state for {} plugins",
merged.len()
);
}
impl Editor {
pub fn save_orchestrator_state(&self) {
let data_dir = self.dir_context.data_dir.clone();
let orch_dir = orchestrator_dir(&data_dir);
if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
return;
}
let existing: Option<PersistedWindows> = {
let p = global_windows_path(&data_dir);
if self.authority.filesystem.exists(&p) {
match self.authority.filesystem.read_file(&p) {
Ok(bytes) => serde_json::from_slice::<PersistedWindows>(&bytes).ok(),
Err(_) => None,
}
} else {
None
}
};
let our_ids: std::collections::HashSet<u64> = self.windows.keys().map(|id| id.0).collect();
let mut windows: Vec<PersistedWindow> = self
.windows
.values()
.map(|s| {
let (project_path, shared_worktree) = read_orch_session_meta(&s.plugin_state);
PersistedWindow {
id: s.id.0,
label: s.label.clone(),
root: s.root.clone(),
project_path,
shared_worktree,
plugin_state: s.plugin_state.clone(),
}
})
.collect();
if let Some(env) = existing {
for w in env.windows.into_iter() {
if !our_ids.contains(&w.id) {
windows.push(w);
}
}
}
windows.sort_by_key(|s| s.id);
let envelope = PersistedWindows {
version: CURRENT_VERSION,
active: self.active_window.0,
next_id: self.next_window_id,
windows,
};
match serde_json::to_vec_pretty(&envelope) {
Ok(bytes) => {
let path = global_windows_path(&data_dir);
let tmp = path.with_extension("json.tmp");
if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
return;
}
if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
tracing::warn!(
"orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
);
}
}
Err(e) => {
tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
}
}
let state_dir = global_state_dir(&data_dir);
if !self.plugin_global_state.is_empty() {
if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
return;
}
}
for (plugin, map) in &self.plugin_global_state {
if !plugin_name_is_safe(plugin) {
tracing::warn!(
"orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
);
continue;
}
if map.is_empty() {
continue;
}
match serde_json::to_vec_pretty(map) {
Ok(bytes) => {
let path = global_plugin_state_path(&data_dir, plugin);
let tmp = path.with_extension("json.tmp");
if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
continue;
}
if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
tracing::warn!(
"orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
);
}
}
Err(e) => {
tracing::warn!(
"orchestrator persistence: failed to serialise plugin {plugin}: {e}"
);
}
}
}
}
}
fn read_orch_session_meta(
plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
) -> (Option<PathBuf>, bool) {
let slot = plugin_state.get("orchestrator");
let project_path = slot
.and_then(|m| m.get("project_path"))
.and_then(|v| v.as_str())
.map(PathBuf::from);
let shared_worktree = slot
.and_then(|m| m.get("shared_worktree"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
(project_path, shared_worktree)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_live_under_data_dir_not_working_dir() {
let data_dir = Path::new("/tmp/fresh-data");
let working_dir = Path::new("/home/user/project");
let wp = global_windows_path(data_dir);
let sd = global_state_dir(data_dir);
let psp = global_plugin_state_path(data_dir, "orchestrator");
assert!(
wp.starts_with(data_dir),
"windows_path must live under data_dir, got {wp:?}"
);
assert!(
sd.starts_with(data_dir),
"state_dir must live under data_dir, got {sd:?}"
);
assert!(
psp.starts_with(data_dir),
"plugin_state_path must live under data_dir, got {psp:?}"
);
for p in [&wp, &sd, &psp] {
assert!(
!p.starts_with(working_dir),
"orchestrator path must not be inside the working tree: {p:?}"
);
for component in p.components() {
if let std::path::Component::Normal(c) = component {
assert_ne!(
c, ".fresh",
"orchestrator path must not contain a `.fresh` component: {p:?}"
);
}
}
}
}
fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
PersistedWindow {
id,
label: String::new(),
root: PathBuf::from(root),
project_path: project_path.map(PathBuf::from),
shared_worktree: false,
plugin_state: HashMap::new(),
}
}
fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
PersistedWindows {
version: CURRENT_VERSION,
active,
next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
windows,
}
}
#[test]
fn pick_active_never_crosses_projects() {
let env = env_with(
2,
vec![
make_window(1, "/repoA", Some("/repoA")),
make_window(2, "/repoA", Some("/repoA")),
make_window(3, "/repoB", Some("/repoB")),
],
);
let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
.expect("a /repoB session exists");
assert_eq!(
picked.id, 3,
"must pick the /repoB session, not env.active=2"
);
}
#[test]
fn pick_active_reopens_last_used_for_cwd() {
let env = env_with(
2,
vec![
make_window(2, "/repoA", Some("/repoA")),
make_window(5, "/repoA", Some("/repoA")),
],
);
let picked =
pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
assert_eq!(
picked.id, 2,
"env.active is the last-used session for the cwd"
);
}
#[test]
fn pick_active_falls_back_to_most_recent_session_for_cwd() {
let env = env_with(
9,
vec![
make_window(2, "/repoA", Some("/repoA")),
make_window(7, "/repoA", Some("/repoA")),
make_window(9, "/repoB", Some("/repoB")),
],
);
let picked =
pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
}
#[test]
fn pick_active_returns_none_when_no_window_matches_cwd() {
let env = env_with(
1,
vec![
make_window(1, "/repoA", Some("/repoA")),
make_window(2, "/repoB", Some("/repoB")),
],
);
assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
}
#[test]
fn pick_active_falls_back_to_root_when_project_path_missing() {
let env = env_with(
2,
vec![
make_window(1, "/repoA", None),
make_window(2, "/repoB", None),
],
);
let picked =
pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
assert_eq!(picked.id, 1);
}
#[test]
fn global_paths_are_independent_of_working_dir() {
let data_dir = Path::new("/tmp/fresh-data");
let a = global_windows_path(data_dir);
let b = global_windows_path(data_dir);
assert_eq!(a, b);
assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
}
}