1use serde::{Deserialize, Serialize};
63use std::collections::HashMap;
64use std::path::{Path, PathBuf};
65
66use super::Editor;
67
68#[derive(Serialize, Deserialize, Debug, Clone)]
70pub(crate) struct PersistedWindow {
71 pub(crate) id: u64,
72 pub(crate) label: String,
73 pub(crate) root: PathBuf,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub(crate) project_path: Option<PathBuf>,
84 #[serde(default, skip_serializing_if = "is_false")]
90 pub(crate) shared_worktree: bool,
91 #[serde(default)]
95 pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
96}
97
98fn is_false(b: &bool) -> bool {
99 !b
100}
101
102#[derive(Serialize, Deserialize, Debug, Clone)]
104pub(crate) struct PersistedWindows {
105 #[serde(default = "default_version")]
110 pub(crate) version: u32,
111 pub(crate) active: u64,
115 pub(crate) next_id: u64,
119 pub(crate) windows: Vec<PersistedWindow>,
120}
121
122fn default_version() -> u32 {
123 1
124}
125
126const CURRENT_VERSION: u32 = 2;
127
128pub(crate) fn read_persisted_windows_env(
145 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
146 data_dir: &Path,
147 _working_dir: &Path,
148) -> Option<PersistedWindows> {
149 let global_p = global_windows_path(data_dir);
152 if !filesystem.exists(&global_p) {
153 migrate_legacy_windows(filesystem, data_dir);
154 }
155 if !filesystem.exists(&global_p) {
156 return None;
157 }
158 match filesystem.read_file(&global_p) {
159 Ok(bytes) => match serde_json::from_slice::<PersistedWindows>(&bytes) {
160 Ok(env) => Some(env),
161 Err(e) => {
162 tracing::warn!("orchestrator persistence: failed to parse {global_p:?}: {e}");
163 None
164 }
165 },
166 Err(e) => {
167 tracing::warn!("orchestrator persistence: failed to read {global_p:?}: {e}");
168 None
169 }
170 }
171}
172
173pub(crate) fn pick_active_window_for_cwd<'a>(
201 env: Option<&'a PersistedWindows>,
202 cwd: &Path,
203) -> Option<&'a PersistedWindow> {
204 let env = env?;
205 if let Some(w) = env
206 .windows
207 .iter()
208 .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
209 {
210 return Some(w);
211 }
212 env.windows
213 .iter()
214 .filter(|w| window_matches_cwd(w, cwd))
215 .max_by_key(|w| w.id)
216}
217
218fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
219 paths_equal(&w.root, cwd)
220}
221
222fn paths_equal(a: &Path, b: &Path) -> bool {
223 let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
224 let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
225 ca == cb
226}
227
228fn migrate_legacy_windows(
242 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
243 data_dir: &Path,
244) {
245 let orch_root = data_dir.join("orchestrator");
246 if !filesystem.exists(&orch_root) {
247 return;
248 }
249 let entries = match filesystem.read_dir(&orch_root) {
250 Ok(es) => es,
251 Err(_) => return,
252 };
253 let mut merged_windows: Vec<PersistedWindow> = Vec::new();
254 let mut merged_active: u64 = 1;
255 let mut merged_next_id: u64 = 2;
256 let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
257 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
258
259 for entry in entries {
260 let dir = entry.path;
261 if !filesystem.is_dir(&dir).unwrap_or(false) {
262 continue;
263 }
264 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
268 Some(n) => n.to_string(),
269 None => continue,
270 };
271 if dir_name == "state" {
272 continue;
273 }
274 let legacy_p = dir.join("windows.json");
275 if !filesystem.exists(&legacy_p) {
276 continue;
277 }
278 let bytes = match filesystem.read_file(&legacy_p) {
279 Ok(b) => b,
280 Err(_) => continue,
281 };
282 let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
283 Ok(e) => e,
284 Err(_) => continue,
285 };
286 let project_path = crate::workspace::decode_filename_to_path(&dir_name)
287 .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
288
289 let mut local_renum: HashMap<u64, u64> = HashMap::new();
290 for mut w in env.windows.into_iter() {
291 if w.project_path.is_none() {
295 w.project_path = Some(project_path.clone());
296 }
297 if used_ids.contains(&w.id) {
298 let new_id = merged_next_id;
299 local_renum.insert(w.id, new_id);
300 merged_next_id = merged_next_id.saturating_add(1);
301 used_ids.insert(new_id);
302 w.id = new_id;
303 } else {
304 used_ids.insert(w.id);
305 merged_next_id = merged_next_id.max(w.id.saturating_add(1));
306 }
307 merged_windows.push(w);
308 }
309 let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
314 merged_active = active_id;
315 legacy_to_rename.push(legacy_p);
316 }
317
318 if merged_windows.is_empty() {
319 return;
320 }
321 merged_windows.sort_by_key(|w| w.id);
322 let envelope = PersistedWindows {
323 version: CURRENT_VERSION,
324 active: merged_active,
325 next_id: merged_next_id,
326 windows: merged_windows,
327 };
328 let global_p = global_windows_path(data_dir);
329 if let Err(e) = filesystem.create_dir_all(&orch_root) {
330 tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
331 return;
332 }
333 let bytes = match serde_json::to_vec_pretty(&envelope) {
334 Ok(b) => b,
335 Err(e) => {
336 tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
337 return;
338 }
339 };
340 if let Err(e) = filesystem.write_file(&global_p, &bytes) {
341 tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
342 return;
343 }
344 for legacy_p in legacy_to_rename {
345 let backup = legacy_p.with_extension("json.migrated.bak");
346 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
347 tracing::warn!(
348 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
349 );
350 }
351 }
352 tracing::info!(
353 "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
354 envelope.windows.len(),
355 global_p
356 );
357}
358
359pub(crate) fn read_persisted_plugin_state(
372 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
373 data_dir: &Path,
374 _working_dir: &Path,
375) -> HashMap<String, HashMap<String, serde_json::Value>> {
376 let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
377 let state_dir = global_state_dir(data_dir);
378 if !filesystem.exists(&state_dir) {
379 migrate_legacy_plugin_state(filesystem, data_dir);
380 }
381 if !filesystem.exists(&state_dir) {
382 return out;
383 }
384 let entries = match filesystem.read_dir(&state_dir) {
385 Ok(es) => es,
386 Err(e) => {
387 tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
388 return out;
389 }
390 };
391 for entry in entries {
392 let path = entry.path;
393 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
394 continue;
395 };
396 if !plugin_name_is_safe(stem) {
397 continue;
398 }
399 if path.extension().and_then(|e| e.to_str()) != Some("json") {
400 continue;
401 }
402 match filesystem.read_file(&path) {
403 Ok(bytes) => {
404 match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
405 Ok(map) if !map.is_empty() => {
406 out.insert(stem.to_owned(), map);
407 }
408 Ok(_) => {}
409 Err(e) => {
410 tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
411 }
412 }
413 }
414 Err(e) => {
415 tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
416 }
417 }
418 }
419 out
420}
421
422fn orchestrator_dir(data_dir: &Path) -> PathBuf {
427 data_dir.join("orchestrator")
428}
429
430fn global_windows_path(data_dir: &Path) -> PathBuf {
431 orchestrator_dir(data_dir).join("windows.json")
432}
433
434fn global_state_dir(data_dir: &Path) -> PathBuf {
435 orchestrator_dir(data_dir).join("state")
436}
437
438fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
439 global_state_dir(data_dir).join(format!("{plugin}.json"))
445}
446
447fn plugin_name_is_safe(name: &str) -> bool {
448 !name.is_empty()
449 && name
450 .chars()
451 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
452 && !name.starts_with('.')
453}
454
455fn migrate_legacy_plugin_state(
462 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
463 data_dir: &Path,
464) {
465 let orch_root = data_dir.join("orchestrator");
466 if !filesystem.exists(&orch_root) {
467 return;
468 }
469 let cwd_entries = match filesystem.read_dir(&orch_root) {
470 Ok(es) => es,
471 Err(_) => return,
472 };
473 let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
474 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
475 for cwd_entry in cwd_entries {
476 let dir = cwd_entry.path;
477 if !filesystem.is_dir(&dir).unwrap_or(false) {
478 continue;
479 }
480 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
481 Some(n) => n.to_string(),
482 None => continue,
483 };
484 if dir_name == "state" {
485 continue;
486 }
487 let state_dir = dir.join("state");
488 if !filesystem.exists(&state_dir) {
489 continue;
490 }
491 let plugin_entries = match filesystem.read_dir(&state_dir) {
492 Ok(es) => es,
493 Err(_) => continue,
494 };
495 for pe in plugin_entries {
496 let p = pe.path;
497 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
498 continue;
499 };
500 if !plugin_name_is_safe(stem) {
501 continue;
502 }
503 if p.extension().and_then(|e| e.to_str()) != Some("json") {
504 continue;
505 }
506 let bytes = match filesystem.read_file(&p) {
507 Ok(b) => b,
508 Err(_) => continue,
509 };
510 let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
511 Ok(m) => m,
512 Err(_) => continue,
513 };
514 let slot = merged.entry(stem.to_owned()).or_default();
515 for (k, v) in map {
516 slot.insert(k, v);
517 }
518 legacy_to_rename.push(p);
519 }
520 }
521 if merged.is_empty() {
522 return;
523 }
524 let target_state_dir = global_state_dir(data_dir);
525 if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
526 tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
527 return;
528 }
529 for (plugin, map) in &merged {
530 let path = global_plugin_state_path(data_dir, plugin);
531 let bytes = match serde_json::to_vec_pretty(map) {
532 Ok(b) => b,
533 Err(e) => {
534 tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
535 continue;
536 }
537 };
538 if let Err(e) = filesystem.write_file(&path, &bytes) {
539 tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
540 }
541 }
542 for legacy_p in legacy_to_rename {
543 let backup = legacy_p.with_extension("json.migrated.bak");
544 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
545 tracing::warn!(
546 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
547 );
548 }
549 }
550 tracing::info!(
551 "orchestrator persistence: migrated plugin state for {} plugins",
552 merged.len()
553 );
554}
555
556impl Editor {
557 pub fn save_orchestrator_state(&self) {
561 let data_dir = self.dir_context.data_dir.clone();
562 let orch_dir = orchestrator_dir(&data_dir);
563 if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
564 tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
565 return;
566 }
567
568 let existing: Option<PersistedWindows> = {
575 let p = global_windows_path(&data_dir);
576 if self.authority.filesystem.exists(&p) {
577 match self.authority.filesystem.read_file(&p) {
578 Ok(bytes) => serde_json::from_slice::<PersistedWindows>(&bytes).ok(),
579 Err(_) => None,
580 }
581 } else {
582 None
583 }
584 };
585 let our_ids: std::collections::HashSet<u64> = self.windows.keys().map(|id| id.0).collect();
586
587 let mut windows: Vec<PersistedWindow> = self
589 .windows
590 .values()
591 .map(|s| {
592 let (project_path, shared_worktree) = read_orch_session_meta(&s.plugin_state);
599 PersistedWindow {
600 id: s.id.0,
601 label: s.label.clone(),
602 root: s.root.clone(),
603 project_path,
604 shared_worktree,
605 plugin_state: s.plugin_state.clone(),
606 }
607 })
608 .collect();
609
610 if let Some(env) = existing {
613 for w in env.windows.into_iter() {
614 if !our_ids.contains(&w.id) {
615 windows.push(w);
616 }
617 }
618 }
619 windows.sort_by_key(|s| s.id);
623 let envelope = PersistedWindows {
624 version: CURRENT_VERSION,
625 active: self.active_window.0,
626 next_id: self.next_window_id,
627 windows,
628 };
629 match serde_json::to_vec_pretty(&envelope) {
630 Ok(bytes) => {
631 let path = global_windows_path(&data_dir);
632 let tmp = path.with_extension("json.tmp");
638 if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
639 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
640 return;
641 }
642 if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
643 tracing::warn!(
644 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
645 );
646 }
647 }
648 Err(e) => {
649 tracing::warn!("orchestrator persistence: failed to serialise sessions: {e}");
650 }
651 }
652
653 let state_dir = global_state_dir(&data_dir);
658 if !self.plugin_global_state.is_empty() {
659 if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
660 tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
661 return;
662 }
663 }
664 for (plugin, map) in &self.plugin_global_state {
665 if !plugin_name_is_safe(plugin) {
666 tracing::warn!(
667 "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
668 );
669 continue;
670 }
671 if map.is_empty() {
672 continue;
673 }
674 match serde_json::to_vec_pretty(map) {
675 Ok(bytes) => {
676 let path = global_plugin_state_path(&data_dir, plugin);
677 let tmp = path.with_extension("json.tmp");
678 if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
679 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
680 continue;
681 }
682 if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
683 tracing::warn!(
684 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
685 );
686 }
687 }
688 Err(e) => {
689 tracing::warn!(
690 "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
691 );
692 }
693 }
694 }
695 }
696}
697
698fn read_orch_session_meta(
704 plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
705) -> (Option<PathBuf>, bool) {
706 let slot = plugin_state.get("orchestrator");
707 let project_path = slot
708 .and_then(|m| m.get("project_path"))
709 .and_then(|v| v.as_str())
710 .map(PathBuf::from);
711 let shared_worktree = slot
712 .and_then(|m| m.get("shared_worktree"))
713 .and_then(|v| v.as_bool())
714 .unwrap_or(false);
715 (project_path, shared_worktree)
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 #[test]
723 fn paths_live_under_data_dir_not_working_dir() {
724 let data_dir = Path::new("/tmp/fresh-data");
727 let working_dir = Path::new("/home/user/project");
728
729 let wp = global_windows_path(data_dir);
730 let sd = global_state_dir(data_dir);
731 let psp = global_plugin_state_path(data_dir, "orchestrator");
732
733 assert!(
734 wp.starts_with(data_dir),
735 "windows_path must live under data_dir, got {wp:?}"
736 );
737 assert!(
738 sd.starts_with(data_dir),
739 "state_dir must live under data_dir, got {sd:?}"
740 );
741 assert!(
742 psp.starts_with(data_dir),
743 "plugin_state_path must live under data_dir, got {psp:?}"
744 );
745
746 for p in [&wp, &sd, &psp] {
747 assert!(
748 !p.starts_with(working_dir),
749 "orchestrator path must not be inside the working tree: {p:?}"
750 );
751 for component in p.components() {
752 if let std::path::Component::Normal(c) = component {
753 assert_ne!(
754 c, ".fresh",
755 "orchestrator path must not contain a `.fresh` component: {p:?}"
756 );
757 }
758 }
759 }
760 }
761
762 fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
763 PersistedWindow {
764 id,
765 label: String::new(),
766 root: PathBuf::from(root),
767 project_path: project_path.map(PathBuf::from),
768 shared_worktree: false,
769 plugin_state: HashMap::new(),
770 }
771 }
772
773 fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
774 PersistedWindows {
775 version: CURRENT_VERSION,
776 active,
777 next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
778 windows,
779 }
780 }
781
782 #[test]
783 fn pick_active_never_crosses_projects() {
784 let env = env_with(
788 2,
789 vec![
790 make_window(1, "/repoA", Some("/repoA")),
791 make_window(2, "/repoA", Some("/repoA")),
792 make_window(3, "/repoB", Some("/repoB")),
793 ],
794 );
795 let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
796 .expect("a /repoB session exists");
797 assert_eq!(
798 picked.id, 3,
799 "must pick the /repoB session, not env.active=2"
800 );
801 }
802
803 #[test]
804 fn pick_active_reopens_last_used_for_cwd() {
805 let env = env_with(
808 2,
809 vec![
810 make_window(2, "/repoA", Some("/repoA")),
811 make_window(5, "/repoA", Some("/repoA")),
812 ],
813 );
814 let picked =
815 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
816 assert_eq!(
817 picked.id, 2,
818 "env.active is the last-used session for the cwd"
819 );
820 }
821
822 #[test]
823 fn pick_active_falls_back_to_most_recent_session_for_cwd() {
824 let env = env_with(
828 9,
829 vec![
830 make_window(2, "/repoA", Some("/repoA")),
831 make_window(7, "/repoA", Some("/repoA")),
832 make_window(9, "/repoB", Some("/repoB")),
833 ],
834 );
835 let picked =
836 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
837 assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
838 }
839
840 #[test]
841 fn pick_active_returns_none_when_no_window_matches_cwd() {
842 let env = env_with(
844 1,
845 vec![
846 make_window(1, "/repoA", Some("/repoA")),
847 make_window(2, "/repoB", Some("/repoB")),
848 ],
849 );
850 assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
851 }
852
853 #[test]
854 fn pick_active_falls_back_to_root_when_project_path_missing() {
855 let env = env_with(
857 2,
858 vec![
859 make_window(1, "/repoA", None),
860 make_window(2, "/repoB", None),
861 ],
862 );
863 let picked =
864 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
865 assert_eq!(picked.id, 1);
866 }
867
868 #[test]
869 fn global_paths_are_independent_of_working_dir() {
870 let data_dir = Path::new("/tmp/fresh-data");
875 let a = global_windows_path(data_dir);
876 let b = global_windows_path(data_dir);
877 assert_eq!(a, b);
878 assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
879 }
880}