1use serde::{Deserialize, Serialize};
58use std::collections::HashMap;
59use std::path::{Path, PathBuf};
60
61use super::Editor;
62
63#[derive(Serialize, Deserialize, Debug, Clone)]
65pub(crate) struct PersistedWindow {
66 pub(crate) id: u64,
67 pub(crate) label: String,
68 pub(crate) root: PathBuf,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub(crate) project_path: Option<PathBuf>,
79 #[serde(default, skip_serializing_if = "is_false")]
85 pub(crate) shared_worktree: bool,
86 #[serde(default)]
90 pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
91}
92
93fn is_false(b: &bool) -> bool {
94 !b
95}
96
97#[derive(Serialize, Deserialize, Debug, Clone)]
99pub(crate) struct PersistedWindows {
100 #[serde(default = "default_version")]
105 pub(crate) version: u32,
106 pub(crate) active: u64,
110 pub(crate) next_id: u64,
114 pub(crate) windows: Vec<PersistedWindow>,
115}
116
117fn default_version() -> u32 {
118 1
119}
120
121const CURRENT_VERSION: u32 = 2;
122
123pub(crate) fn read_persisted_windows_env(
140 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
141 data_dir: &Path,
142 _working_dir: &Path,
143) -> Option<PersistedWindows> {
144 let global_p = global_windows_path(data_dir);
147 if !filesystem.exists(&global_p) {
148 migrate_legacy_windows(filesystem, data_dir);
149 }
150 if !filesystem.exists(&global_p) {
151 return None;
152 }
153 match filesystem.read_file(&global_p) {
154 Ok(bytes) => match serde_json::from_slice::<PersistedWindows>(&bytes) {
155 Ok(env) => Some(env),
156 Err(e) => {
157 tracing::warn!("orchestrator persistence: failed to parse {global_p:?}: {e}");
158 None
159 }
160 },
161 Err(e) => {
162 tracing::warn!("orchestrator persistence: failed to read {global_p:?}: {e}");
163 None
164 }
165 }
166}
167
168pub(crate) fn pick_active_window_for_cwd<'a>(
187 env: Option<&'a PersistedWindows>,
188 cwd: &Path,
189) -> Option<&'a PersistedWindow> {
190 let env = env?;
191 if let Some(w) = env
192 .windows
193 .iter()
194 .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
195 {
196 return Some(w);
197 }
198 env.windows.iter().find(|w| window_matches_cwd(w, cwd))
199}
200
201fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
202 let candidate = w.project_path.as_deref().unwrap_or(&w.root);
203 paths_equal(candidate, cwd)
204}
205
206fn paths_equal(a: &Path, b: &Path) -> bool {
207 let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
208 let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
209 ca == cb
210}
211
212fn migrate_legacy_windows(
226 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
227 data_dir: &Path,
228) {
229 let orch_root = data_dir.join("orchestrator");
230 if !filesystem.exists(&orch_root) {
231 return;
232 }
233 let entries = match filesystem.read_dir(&orch_root) {
234 Ok(es) => es,
235 Err(_) => return,
236 };
237 let mut merged_windows: Vec<PersistedWindow> = Vec::new();
238 let mut merged_active: u64 = 1;
239 let mut merged_next_id: u64 = 2;
240 let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
241 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
242
243 for entry in entries {
244 let dir = entry.path;
245 if !filesystem.is_dir(&dir).unwrap_or(false) {
246 continue;
247 }
248 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
252 Some(n) => n.to_string(),
253 None => continue,
254 };
255 if dir_name == "state" {
256 continue;
257 }
258 let legacy_p = dir.join("windows.json");
259 if !filesystem.exists(&legacy_p) {
260 continue;
261 }
262 let bytes = match filesystem.read_file(&legacy_p) {
263 Ok(b) => b,
264 Err(_) => continue,
265 };
266 let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
267 Ok(e) => e,
268 Err(_) => continue,
269 };
270 let project_path = crate::workspace::decode_filename_to_path(&dir_name)
271 .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
272
273 let mut local_renum: HashMap<u64, u64> = HashMap::new();
274 for mut w in env.windows.into_iter() {
275 if w.project_path.is_none() {
279 w.project_path = Some(project_path.clone());
280 }
281 if used_ids.contains(&w.id) {
282 let new_id = merged_next_id;
283 local_renum.insert(w.id, new_id);
284 merged_next_id = merged_next_id.saturating_add(1);
285 used_ids.insert(new_id);
286 w.id = new_id;
287 } else {
288 used_ids.insert(w.id);
289 merged_next_id = merged_next_id.max(w.id.saturating_add(1));
290 }
291 merged_windows.push(w);
292 }
293 let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
298 merged_active = active_id;
299 legacy_to_rename.push(legacy_p);
300 }
301
302 if merged_windows.is_empty() {
303 return;
304 }
305 merged_windows.sort_by_key(|w| w.id);
306 let envelope = PersistedWindows {
307 version: CURRENT_VERSION,
308 active: merged_active,
309 next_id: merged_next_id,
310 windows: merged_windows,
311 };
312 let global_p = global_windows_path(data_dir);
313 if let Err(e) = filesystem.create_dir_all(&orch_root) {
314 tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
315 return;
316 }
317 let bytes = match serde_json::to_vec_pretty(&envelope) {
318 Ok(b) => b,
319 Err(e) => {
320 tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
321 return;
322 }
323 };
324 if let Err(e) = filesystem.write_file(&global_p, &bytes) {
325 tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
326 return;
327 }
328 for legacy_p in legacy_to_rename {
329 let backup = legacy_p.with_extension("json.migrated.bak");
330 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
331 tracing::warn!(
332 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
333 );
334 }
335 }
336 tracing::info!(
337 "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
338 envelope.windows.len(),
339 global_p
340 );
341}
342
343pub(crate) fn read_persisted_plugin_state(
356 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
357 data_dir: &Path,
358 _working_dir: &Path,
359) -> HashMap<String, HashMap<String, serde_json::Value>> {
360 let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
361 let state_dir = global_state_dir(data_dir);
362 if !filesystem.exists(&state_dir) {
363 migrate_legacy_plugin_state(filesystem, data_dir);
364 }
365 if !filesystem.exists(&state_dir) {
366 return out;
367 }
368 let entries = match filesystem.read_dir(&state_dir) {
369 Ok(es) => es,
370 Err(e) => {
371 tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
372 return out;
373 }
374 };
375 for entry in entries {
376 let path = entry.path;
377 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
378 continue;
379 };
380 if !plugin_name_is_safe(stem) {
381 continue;
382 }
383 if path.extension().and_then(|e| e.to_str()) != Some("json") {
384 continue;
385 }
386 match filesystem.read_file(&path) {
387 Ok(bytes) => {
388 match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
389 Ok(map) if !map.is_empty() => {
390 out.insert(stem.to_owned(), map);
391 }
392 Ok(_) => {}
393 Err(e) => {
394 tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
395 }
396 }
397 }
398 Err(e) => {
399 tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
400 }
401 }
402 }
403 out
404}
405
406fn orchestrator_dir(data_dir: &Path) -> PathBuf {
411 data_dir.join("orchestrator")
412}
413
414fn global_windows_path(data_dir: &Path) -> PathBuf {
415 orchestrator_dir(data_dir).join("windows.json")
416}
417
418fn global_state_dir(data_dir: &Path) -> PathBuf {
419 orchestrator_dir(data_dir).join("state")
420}
421
422fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
423 global_state_dir(data_dir).join(format!("{plugin}.json"))
429}
430
431fn plugin_name_is_safe(name: &str) -> bool {
432 !name.is_empty()
433 && name
434 .chars()
435 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
436 && !name.starts_with('.')
437}
438
439fn migrate_legacy_plugin_state(
446 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
447 data_dir: &Path,
448) {
449 let orch_root = data_dir.join("orchestrator");
450 if !filesystem.exists(&orch_root) {
451 return;
452 }
453 let cwd_entries = match filesystem.read_dir(&orch_root) {
454 Ok(es) => es,
455 Err(_) => return,
456 };
457 let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
458 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
459 for cwd_entry in cwd_entries {
460 let dir = cwd_entry.path;
461 if !filesystem.is_dir(&dir).unwrap_or(false) {
462 continue;
463 }
464 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
465 Some(n) => n.to_string(),
466 None => continue,
467 };
468 if dir_name == "state" {
469 continue;
470 }
471 let state_dir = dir.join("state");
472 if !filesystem.exists(&state_dir) {
473 continue;
474 }
475 let plugin_entries = match filesystem.read_dir(&state_dir) {
476 Ok(es) => es,
477 Err(_) => continue,
478 };
479 for pe in plugin_entries {
480 let p = pe.path;
481 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
482 continue;
483 };
484 if !plugin_name_is_safe(stem) {
485 continue;
486 }
487 if p.extension().and_then(|e| e.to_str()) != Some("json") {
488 continue;
489 }
490 let bytes = match filesystem.read_file(&p) {
491 Ok(b) => b,
492 Err(_) => continue,
493 };
494 let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
495 Ok(m) => m,
496 Err(_) => continue,
497 };
498 let slot = merged.entry(stem.to_owned()).or_default();
499 for (k, v) in map {
500 slot.insert(k, v);
501 }
502 legacy_to_rename.push(p);
503 }
504 }
505 if merged.is_empty() {
506 return;
507 }
508 let target_state_dir = global_state_dir(data_dir);
509 if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
510 tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
511 return;
512 }
513 for (plugin, map) in &merged {
514 let path = global_plugin_state_path(data_dir, plugin);
515 let bytes = match serde_json::to_vec_pretty(map) {
516 Ok(b) => b,
517 Err(e) => {
518 tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
519 continue;
520 }
521 };
522 if let Err(e) = filesystem.write_file(&path, &bytes) {
523 tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
524 }
525 }
526 for legacy_p in legacy_to_rename {
527 let backup = legacy_p.with_extension("json.migrated.bak");
528 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
529 tracing::warn!(
530 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
531 );
532 }
533 }
534 tracing::info!(
535 "orchestrator persistence: migrated plugin state for {} plugins",
536 merged.len()
537 );
538}
539
540impl Editor {
541 pub fn save_orchestrator_state(&self) {
545 let data_dir = self.dir_context.data_dir.clone();
546 let orch_dir = orchestrator_dir(&data_dir);
547 if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
548 tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
549 return;
550 }
551
552 let existing: Option<PersistedWindows> = {
559 let p = global_windows_path(&data_dir);
560 if self.authority.filesystem.exists(&p) {
561 match self.authority.filesystem.read_file(&p) {
562 Ok(bytes) => serde_json::from_slice::<PersistedWindows>(&bytes).ok(),
563 Err(_) => None,
564 }
565 } else {
566 None
567 }
568 };
569 let our_ids: std::collections::HashSet<u64> = self.windows.keys().map(|id| id.0).collect();
570
571 let mut windows: Vec<PersistedWindow> = self
573 .windows
574 .values()
575 .map(|s| {
576 let (project_path, shared_worktree) = read_orch_session_meta(&s.plugin_state);
583 PersistedWindow {
584 id: s.id.0,
585 label: s.label.clone(),
586 root: s.root.clone(),
587 project_path,
588 shared_worktree,
589 plugin_state: s.plugin_state.clone(),
590 }
591 })
592 .collect();
593
594 if let Some(env) = existing {
597 for w in env.windows.into_iter() {
598 if !our_ids.contains(&w.id) {
599 windows.push(w);
600 }
601 }
602 }
603 windows.sort_by_key(|s| s.id);
607 let envelope = PersistedWindows {
608 version: CURRENT_VERSION,
609 active: self.active_window.0,
610 next_id: self.next_window_id,
611 windows,
612 };
613 match serde_json::to_vec_pretty(&envelope) {
614 Ok(bytes) => {
615 let path = global_windows_path(&data_dir);
616 let tmp = path.with_extension("json.tmp");
622 if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
623 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
624 return;
625 }
626 if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
627 tracing::warn!(
628 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
629 );
630 }
631 }
632 Err(e) => {
633 tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
634 }
635 }
636
637 let state_dir = global_state_dir(&data_dir);
642 if !self.plugin_global_state.is_empty() {
643 if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
644 tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
645 return;
646 }
647 }
648 for (plugin, map) in &self.plugin_global_state {
649 if !plugin_name_is_safe(plugin) {
650 tracing::warn!(
651 "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
652 );
653 continue;
654 }
655 if map.is_empty() {
656 continue;
657 }
658 match serde_json::to_vec_pretty(map) {
659 Ok(bytes) => {
660 let path = global_plugin_state_path(&data_dir, plugin);
661 let tmp = path.with_extension("json.tmp");
662 if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
663 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
664 continue;
665 }
666 if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
667 tracing::warn!(
668 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
669 );
670 }
671 }
672 Err(e) => {
673 tracing::warn!(
674 "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
675 );
676 }
677 }
678 }
679 }
680}
681
682fn read_orch_session_meta(
688 plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
689) -> (Option<PathBuf>, bool) {
690 let slot = plugin_state.get("orchestrator");
691 let project_path = slot
692 .and_then(|m| m.get("project_path"))
693 .and_then(|v| v.as_str())
694 .map(PathBuf::from);
695 let shared_worktree = slot
696 .and_then(|m| m.get("shared_worktree"))
697 .and_then(|v| v.as_bool())
698 .unwrap_or(false);
699 (project_path, shared_worktree)
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn paths_live_under_data_dir_not_working_dir() {
708 let data_dir = Path::new("/tmp/fresh-data");
711 let working_dir = Path::new("/home/user/project");
712
713 let wp = global_windows_path(data_dir);
714 let sd = global_state_dir(data_dir);
715 let psp = global_plugin_state_path(data_dir, "orchestrator");
716
717 assert!(
718 wp.starts_with(data_dir),
719 "windows_path must live under data_dir, got {wp:?}"
720 );
721 assert!(
722 sd.starts_with(data_dir),
723 "state_dir must live under data_dir, got {sd:?}"
724 );
725 assert!(
726 psp.starts_with(data_dir),
727 "plugin_state_path must live under data_dir, got {psp:?}"
728 );
729
730 for p in [&wp, &sd, &psp] {
731 assert!(
732 !p.starts_with(working_dir),
733 "orchestrator path must not be inside the working tree: {p:?}"
734 );
735 for component in p.components() {
736 if let std::path::Component::Normal(c) = component {
737 assert_ne!(
738 c, ".fresh",
739 "orchestrator path must not contain a `.fresh` component: {p:?}"
740 );
741 }
742 }
743 }
744 }
745
746 fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
747 PersistedWindow {
748 id,
749 label: String::new(),
750 root: PathBuf::from(root),
751 project_path: project_path.map(PathBuf::from),
752 shared_worktree: false,
753 plugin_state: HashMap::new(),
754 }
755 }
756
757 fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
758 PersistedWindows {
759 version: CURRENT_VERSION,
760 active,
761 next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
762 windows,
763 }
764 }
765
766 #[test]
767 fn pick_active_falls_through_when_persisted_active_belongs_to_another_project() {
768 let env = env_with(
772 2,
773 vec![
774 make_window(1, "/repoA", Some("/repoA")),
775 make_window(2, "/repoB", Some("/repoB")),
776 ],
777 );
778 let cwd = Path::new("/repoA");
779 let picked = pick_active_window_for_cwd(Some(&env), cwd).expect("should pick a window");
780 assert_eq!(
781 picked.id, 1,
782 "must pick the /repoA-rooted window, not env.active=2"
783 );
784 }
785
786 #[test]
787 fn pick_active_keeps_env_active_when_it_matches_cwd() {
788 let env = env_with(
789 2,
790 vec![
791 make_window(1, "/repoA", Some("/repoA")),
792 make_window(2, "/repoA", Some("/repoA")),
793 ],
794 );
795 let picked =
796 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
797 assert_eq!(picked.id, 2, "env.active wins when it matches");
798 }
799
800 #[test]
801 fn pick_active_returns_none_when_no_window_matches_cwd() {
802 let env = env_with(
803 1,
804 vec![
805 make_window(1, "/repoA", Some("/repoA")),
806 make_window(2, "/repoB", Some("/repoB")),
807 ],
808 );
809 assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
810 }
811
812 #[test]
813 fn pick_active_falls_back_to_root_when_project_path_missing() {
814 let env = env_with(
816 2,
817 vec![
818 make_window(1, "/repoA", None),
819 make_window(2, "/repoB", None),
820 ],
821 );
822 let picked =
823 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
824 assert_eq!(picked.id, 1);
825 }
826
827 #[test]
828 fn global_paths_are_independent_of_working_dir() {
829 let data_dir = Path::new("/tmp/fresh-data");
834 let a = global_windows_path(data_dir);
835 let b = global_windows_path(data_dir);
836 assert_eq!(a, b);
837 assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
838 }
839}