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