1use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::{
11 config::global::{atomic_write_private, GroupKey},
12 model::workspace::{FlatEntry, WorkspaceState},
13};
14use serde::{Deserialize, Deserializer, Serialize};
15
16#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
18pub enum CursorIdentity {
19 Project {
20 path: String,
21 },
22 Worktree {
23 path: String,
24 },
25 Session {
26 worktree_path: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 terminal_id: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pane_id: Option<String>,
32 },
33 RoutinesHeader {
34 project_path: String,
35 },
36 Routine {
37 project_path: String,
38 routine_name: String,
39 },
40}
41
42#[derive(Serialize, Default, Clone)]
43pub struct WorkspaceCache {
44 #[serde(default)]
45 pub written_at_unix_ms: Option<u64>,
46 #[serde(default)]
47 pub worktree_expanded: HashMap<String, bool>,
48 #[serde(default)]
49 pub project_expanded: HashMap<String, bool>,
50 #[serde(default)]
52 pub project_touched_unix_ms: HashMap<String, u64>,
53 #[serde(default)]
54 pub routines_expanded: HashMap<String, bool>,
55 #[serde(default)]
56 pub tree_selected: usize,
57 #[serde(default)]
58 pub cursor_identity: Option<CursorIdentity>,
59 #[serde(default)]
61 pub muted_terminals: HashSet<String>,
62 #[serde(default)]
64 pub acknowledged_outcomes: HashMap<String, u64>,
65 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
67 pub dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
68 #[serde(skip)]
69 migration_needed: bool,
70}
71
72#[derive(Deserialize, Default)]
73#[serde(default)]
74struct WorkspaceCacheWire {
75 written_at_unix_ms: Option<u64>,
76 worktree_expanded: HashMap<String, bool>,
77 project_expanded: HashMap<String, bool>,
78 project_touched_unix_ms: HashMap<String, u64>,
79 routines_expanded: HashMap<String, bool>,
80 tree_selected: usize,
81 cursor_identity: Option<CursorIdentity>,
82 #[serde(alias = "muted_sessions")]
83 muted_terminals: HashSet<String>,
84 acknowledged_outcomes: HashMap<String, u64>,
85 active_group: Option<toml::Value>,
86 active_groups: Option<toml::Value>,
87 active_tab: Option<toml::Value>,
88 integration_prompt_version: Option<String>,
89 dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
90}
91
92impl<'de> Deserialize<'de> for WorkspaceCache {
93 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
94 where
95 D: Deserializer<'de>,
96 {
97 let wire = WorkspaceCacheWire::deserialize(deserializer)?;
98 let migration_needed = wire.active_group.is_some()
101 || wire.active_groups.is_some()
102 || wire.active_tab.is_some();
103 Ok(Self {
104 written_at_unix_ms: wire.written_at_unix_ms,
105 worktree_expanded: wire.worktree_expanded,
106 project_expanded: wire.project_expanded,
107 project_touched_unix_ms: wire.project_touched_unix_ms,
108 routines_expanded: wire.routines_expanded,
109 tree_selected: wire.tree_selected,
110 cursor_identity: wire.cursor_identity,
111 muted_terminals: wire.muted_terminals,
112 acknowledged_outcomes: wire.acknowledged_outcomes,
113 dismissed_integration_prompts: wire.dismissed_integration_prompts,
114 migration_needed: migration_needed || wire.integration_prompt_version.is_some(),
115 })
116 }
117}
118
119impl WorkspaceCache {
120 pub fn load() -> anyhow::Result<Self> {
121 Self::load_from_paths(&cache_path(), &legacy_cache_path())
122 }
123
124 fn load_from_paths(
125 canonical: &std::path::Path,
126 legacy: &std::path::Path,
127 ) -> anyhow::Result<Self> {
128 let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
129 Ok(content) => (content, false),
130 Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
131 Ok(content) => (content, true),
132 Err(_) => return Ok(Self::default()),
133 },
134 Err(_) => return Ok(Self::default()),
135 };
136 let mut cache: Self = toml::from_str(&content).unwrap_or_default();
137 if imported_legacy || cache.migration_needed {
138 cache.save_to(canonical, false)?;
139 cache.migration_needed = false;
140 }
141 Ok(cache)
142 }
143
144 pub fn save(&self, sync: bool) -> anyhow::Result<()> {
145 self.save_to(&cache_path(), sync)
146 }
147
148 fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
149 let mut cache = self.clone();
150 cache.written_at_unix_ms = Some(now_unix_ms());
151 let text = toml::to_string(&cache)?;
152 atomic_write_private(path, text.as_bytes(), sync)?;
153 Ok(())
154 }
155}
156
157fn now_unix_ms() -> u64 {
158 SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_millis()
162 .try_into()
163 .unwrap_or(u64::MAX)
164}
165
166fn cached_project_touch_unix_ms(
167 cache: &WorkspaceCache,
168 project_key: &str,
169 loaded_at_unix_ms: u64,
170) -> u64 {
171 cache
172 .project_touched_unix_ms
173 .get(project_key)
174 .copied()
175 .or(cache.written_at_unix_ms)
176 .unwrap_or(loaded_at_unix_ms)
177}
178
179fn cache_path() -> PathBuf {
180 dirs::cache_dir()
181 .unwrap_or_else(|| PathBuf::from("/tmp"))
182 .join("wsx")
183 .join("workspace-v2.toml")
184}
185
186fn legacy_cache_path() -> PathBuf {
187 dirs::cache_dir()
188 .unwrap_or_else(|| PathBuf::from("/tmp"))
189 .join("wsx")
190 .join("workspace.toml")
191}
192
193#[derive(Serialize, Deserialize)]
194struct GroupSelection {
195 selected: GroupKey,
196}
197
198fn group_selection_path() -> PathBuf {
199 dirs::cache_dir()
200 .unwrap_or_else(|| PathBuf::from("/tmp"))
201 .join("wsx")
202 .join("group-selection-v1.toml")
203}
204
205pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
206 load_group_selection_from(&group_selection_path())
207}
208
209fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
210 let content = match std::fs::read_to_string(path) {
211 Ok(content) => content,
212 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
213 Err(error) => return Err(error.into()),
214 };
215 Ok(toml::from_str::<GroupSelection>(&content)
216 .ok()
217 .map(|selection| selection.selected))
218}
219
220pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
221 save_group_selection_to(&group_selection_path(), selected)
222}
223
224fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
225 let text = toml::to_string(&GroupSelection {
226 selected: selected.clone(),
227 })?;
228 atomic_write_private(path, text.as_bytes(), true)?;
229 Ok(())
230}
231
232pub type AppliedCache = (
233 usize,
234 Option<CursorIdentity>,
235 HashMap<PathBuf, u64>,
236 HashSet<String>,
237 HashMap<String, u64>,
238 HashSet<crate::integration::IntegrationTarget>,
239);
240
241pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
243 let cache = WorkspaceCache::load()?;
244 let mut migrated_muted_terminals = HashSet::new();
245 let mut project_touched_unix_ms = HashMap::new();
246 let loaded_at_unix_ms = now_unix_ms();
247 for project in &mut workspace.projects {
248 let project_key = project.path.to_string_lossy().to_string();
249 project_touched_unix_ms.insert(
250 project.path.clone(),
251 cached_project_touch_unix_ms(&cache, &project_key, loaded_at_unix_ms),
252 );
253 if let Some(expanded) = cache.project_expanded.get(&project_key) {
254 project.expanded = *expanded;
255 }
256 if let Some(expanded) = cache.routines_expanded.get(&project_key) {
257 project.routines_expanded = *expanded;
258 }
259 for worktree in &mut project.worktrees {
260 let key = worktree.path.to_string_lossy().to_string();
261 if let Some(expanded) = cache.worktree_expanded.get(&key) {
262 worktree.expanded = *expanded;
263 }
264 for session in &mut worktree.sessions {
265 session.muted = cache
266 .muted_terminals
267 .contains(&session.terminal_id.to_string())
268 || cache.muted_terminals.contains(&session.pane_id.to_string());
269 if session.muted {
270 migrated_muted_terminals.insert(session.terminal_id.to_string());
271 }
272 for pane in &mut session.panes {
273 pane.outcome_acknowledged = pane.agent_status
274 == crate::runtime::AgentState::Done
275 && cache
276 .acknowledged_outcomes
277 .get(&pane.terminal_id.to_string())
278 == Some(&pane.revision);
279 }
280 session.outcome_acknowledged = session
281 .panes
282 .iter()
283 .find(|pane| pane.pane_id == session.pane_id)
284 .is_some_and(|pane| pane.outcome_acknowledged);
285 }
286 }
287 }
288 Ok((
289 cache.tree_selected,
290 cache.cursor_identity,
291 project_touched_unix_ms,
292 migrated_muted_terminals,
293 cache.acknowledged_outcomes,
294 cache.dismissed_integration_prompts,
295 ))
296}
297
298pub fn find_cursor_index(
299 workspace: &WorkspaceState,
300 flat: &[FlatEntry],
301 id: &CursorIdentity,
302) -> Option<usize> {
303 match id {
304 CursorIdentity::Project { path } => flat.iter().position(|entry| {
305 matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
306 }),
307 CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
308 matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
309 }),
310 CursorIdentity::Session {
311 worktree_path,
312 terminal_id,
313 pane_id,
314 } => flat.iter().position(|entry| {
315 let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
316 FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
317 (*project_idx, *worktree_idx, *session_idx, None)
318 }
319 FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
320 (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
321 }
322 _ => return false,
323 };
324 let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
325 let session = &wt.sessions[session_idx];
326 let (terminal, pane) = pane_idx
327 .and_then(|idx| session.panes.get(idx))
328 .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
329 wt.path.to_string_lossy() == worktree_path.as_str()
330 && terminal_id
331 .as_ref()
332 .map(|id| terminal.to_string() == *id)
333 .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
334 .unwrap_or(false)
335 }),
336 CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
337 matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
338 }),
339 CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
340 matches!(entry, FlatEntry::Routine { project_idx, routine_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str() && workspace.projects[*project_idx].routines[*routine_idx].routine.name == *routine_name)
341 }),
342 }
343}
344
345pub fn save_cache(
346 workspace: &WorkspaceState,
347 tree_selected: usize,
348 flat: &[FlatEntry],
349 project_touched_unix_ms: &HashMap<PathBuf, u64>,
350 dismissed_integration_prompts: &HashSet<crate::integration::IntegrationTarget>,
351 sync: bool,
352) -> Option<String> {
353 let mut cache = WorkspaceCache {
354 written_at_unix_ms: Some(now_unix_ms()),
355 tree_selected,
356 cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
357 dismissed_integration_prompts: dismissed_integration_prompts.clone(),
358 ..Default::default()
359 };
360 for project in &workspace.projects {
361 let project_path = project.path.to_string_lossy().into_owned();
362 cache
363 .project_expanded
364 .insert(project_path.clone(), project.expanded);
365 if let Some(touched_unix_ms) = project_touched_unix_ms.get(&project.path) {
366 cache
367 .project_touched_unix_ms
368 .insert(project_path.clone(), *touched_unix_ms);
369 }
370 cache
371 .routines_expanded
372 .insert(project_path, project.routines_expanded);
373 for worktree in &project.worktrees {
374 cache.worktree_expanded.insert(
375 worktree.path.to_string_lossy().into_owned(),
376 worktree.expanded,
377 );
378 cache.muted_terminals.extend(
379 worktree
380 .sessions
381 .iter()
382 .filter(|s| s.muted)
383 .map(|s| s.terminal_id.to_string()),
384 );
385 for session in &worktree.sessions {
386 for pane in &session.panes {
387 if pane.outcome_acknowledged {
388 cache
389 .acknowledged_outcomes
390 .insert(pane.terminal_id.to_string(), pane.revision);
391 }
392 }
393 }
394 }
395 }
396 cache
397 .save(sync)
398 .err()
399 .map(|e| format!("cache save failed: {e}"))
400}
401
402pub fn resolve_cursor_identity(
403 workspace: &WorkspaceState,
404 flat: &[FlatEntry],
405 idx: usize,
406) -> Option<CursorIdentity> {
407 match flat.get(idx)? {
408 FlatEntry::Project { idx } => Some(CursorIdentity::Project {
409 path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
410 }),
411 FlatEntry::Worktree {
412 project_idx,
413 worktree_idx,
414 } => Some(CursorIdentity::Worktree {
415 path: workspace.projects[*project_idx].worktrees[*worktree_idx]
416 .path
417 .to_string_lossy()
418 .into_owned(),
419 }),
420 FlatEntry::Session {
421 project_idx,
422 worktree_idx,
423 session_idx,
424 } => {
425 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
426 Some(CursorIdentity::Session {
427 worktree_path: wt.path.to_string_lossy().into_owned(),
428 terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
429 pane_id: None,
430 })
431 }
432 FlatEntry::Pane {
433 project_idx,
434 worktree_idx,
435 session_idx,
436 pane_idx,
437 } => {
438 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
439 Some(CursorIdentity::Session {
440 worktree_path: wt.path.to_string_lossy().into_owned(),
441 terminal_id: Some(
442 wt.sessions[*session_idx].panes[*pane_idx]
443 .terminal_id
444 .to_string(),
445 ),
446 pane_id: None,
447 })
448 }
449 FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
450 project_path: workspace.projects[*project_idx]
451 .path
452 .to_string_lossy()
453 .into_owned(),
454 }),
455 FlatEntry::Routine {
456 project_idx,
457 routine_idx,
458 } => Some(CursorIdentity::Routine {
459 project_path: workspace.projects[*project_idx]
460 .path
461 .to_string_lossy()
462 .into_owned(),
463 routine_name: workspace.projects[*project_idx].routines[*routine_idx]
464 .routine
465 .name
466 .clone(),
467 }),
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn legacy_cache_defaults_missing_dismissed_integration_prompts() {
477 let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
478 assert!(cache.dismissed_integration_prompts.is_empty());
479 }
480
481 #[test]
482 fn dismissed_integration_prompts_round_trip_per_agent() {
483 let cache = WorkspaceCache {
484 dismissed_integration_prompts: [crate::integration::IntegrationTarget::Pi]
485 .into_iter()
486 .collect(),
487 ..Default::default()
488 };
489 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
490 assert_eq!(
491 decoded.dismissed_integration_prompts,
492 cache.dismissed_integration_prompts
493 );
494 }
495
496 #[test]
497 fn legacy_blanket_dismissal_is_migrated_away() {
498 let cache: WorkspaceCache =
499 toml::from_str("tree_selected = 2\nintegration_prompt_version = \"0.21.0\"\n").unwrap();
500 assert!(cache.dismissed_integration_prompts.is_empty());
501 assert!(cache.migration_needed);
502 }
503
504 #[test]
505 fn expansion_maps_round_trip_by_stable_path() {
506 let cache = WorkspaceCache {
507 project_expanded: HashMap::from([("/projects/app".into(), true)]),
508 project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
509 worktree_expanded: HashMap::from([("/projects/app/feature".into(), false)]),
510 routines_expanded: HashMap::from([("/projects/app".into(), false)]),
511 ..Default::default()
512 };
513
514 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
515
516 assert_eq!(decoded.project_expanded, cache.project_expanded);
517 assert_eq!(
518 decoded.project_touched_unix_ms,
519 cache.project_touched_unix_ms
520 );
521 assert_eq!(decoded.worktree_expanded, cache.worktree_expanded);
522 assert_eq!(decoded.routines_expanded, cache.routines_expanded);
523 }
524
525 #[test]
526 fn legacy_cache_defaults_missing_routines_expanded_map() {
527 let cache: WorkspaceCache = toml::from_str(
528 r#"[project_expanded]
529"/projects/app" = true
530
531[worktree_expanded]
532"/projects/app/main" = false
533"#,
534 )
535 .unwrap();
536
537 assert!(cache.routines_expanded.is_empty());
538 assert!(cache.project_touched_unix_ms.is_empty());
539 }
540
541 #[test]
542 fn missing_project_touch_uses_cache_write_time_once() {
543 let legacy = WorkspaceCache {
544 written_at_unix_ms: Some(41),
545 ..Default::default()
546 };
547 assert_eq!(
548 cached_project_touch_unix_ms(&legacy, "/projects/app", 99),
549 41
550 );
551
552 let current = WorkspaceCache {
553 written_at_unix_ms: Some(41),
554 project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
555 ..Default::default()
556 };
557 assert_eq!(
558 cached_project_touch_unix_ms(¤t, "/projects/app", 99),
559 42
560 );
561
562 assert_eq!(
563 cached_project_touch_unix_ms(&WorkspaceCache::default(), "/projects/app", 99),
564 99
565 );
566 }
567
568 #[test]
569 fn acknowledged_outcome_revisions_round_trip() {
570 let cache = WorkspaceCache {
571 acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
572 ..Default::default()
573 };
574
575 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
576
577 assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
578 }
579
580 #[test]
581 fn legacy_tmux_and_session_fields_are_ignored() {
582 let cache: WorkspaceCache = toml::from_str(
583 r#"tmux_server_pid = 123
584sessions = { "/tmp/repo" = ["old-tmux-session"] }
585muted_sessions = ["pane-1"]
586"#,
587 )
588 .unwrap();
589 assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
590 }
591
592 #[test]
593 fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
594 let cache: WorkspaceCache = toml::from_str(
595 r#"[cursor_identity.Session]
596worktree_path = "/repo"
597pane_id = "pane-1"
598"#,
599 )
600 .unwrap();
601 assert_eq!(
602 cache.cursor_identity,
603 Some(CursorIdentity::Session {
604 worktree_path: "/repo".into(),
605 terminal_id: None,
606 pane_id: Some("pane-1".into()),
607 })
608 );
609 }
610
611 #[test]
612 fn historical_active_group_shapes_are_discarded_on_rewrite() {
613 for historical in [
614 "active_group = \"work\"\n",
615 "active_tab = \"work\"\n",
616 "active_groups = [\"work\", \"other\"]\n",
617 "active_groups = []\n",
618 ] {
619 let cache: WorkspaceCache = toml::from_str(historical).unwrap();
620 assert!(cache.migration_needed);
621 let encoded = toml::to_string(&cache).unwrap();
622 assert!(!encoded.contains("active_group"));
623 assert!(!encoded.contains("active_groups"));
624 assert!(!encoded.contains("active_tab"));
625 }
626 }
627
628 #[test]
629 fn group_selection_is_independent_and_malformed_data_defaults_absent() {
630 let unique = SystemTime::now()
631 .duration_since(UNIX_EPOCH)
632 .unwrap()
633 .as_nanos();
634 let directory = std::env::current_dir()
635 .unwrap()
636 .join(".work/group-selection-tests")
637 .join(format!("{}-{unique}", std::process::id()));
638 std::fs::create_dir_all(&directory).unwrap();
639 let path = directory.join("group-selection-v1.toml");
640
641 assert_eq!(load_group_selection_from(&path).unwrap(), None);
642 save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
643 assert_eq!(
644 load_group_selection_from(&path).unwrap(),
645 Some(GroupKey::Named("work".into()))
646 );
647 std::fs::write(&path, "selected = [\n").unwrap();
648 assert_eq!(load_group_selection_from(&path).unwrap(), None);
649 std::fs::remove_dir_all(directory).unwrap();
650 }
651
652 #[test]
653 fn workspace_cache_serialization_never_carries_group_selection() {
654 let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
655 assert!(!encoded.contains("selected_group"));
656 assert!(!encoded.contains("active_group"));
657 }
658
659 #[test]
660 fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
661 let unique = SystemTime::now()
662 .duration_since(UNIX_EPOCH)
663 .unwrap()
664 .as_nanos();
665 let directory = std::env::current_dir()
666 .unwrap()
667 .join(".work/cache-v2-tests")
668 .join(format!("{}-{unique}", std::process::id()));
669 std::fs::create_dir_all(&directory).unwrap();
670 let canonical = directory.join("workspace-v2.toml");
671 let legacy = directory.join("workspace.toml");
672 let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
673 std::fs::write(&legacy, legacy_text).unwrap();
674
675 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
676
677 assert_eq!(cache.tree_selected, 3);
678 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
679 let canonical_text = std::fs::read_to_string(&canonical).unwrap();
680 assert!(!canonical_text.contains("active_group"));
681 assert!(!canonical_text.contains("active_tab"));
682 std::fs::remove_dir_all(directory).unwrap();
683 }
684
685 #[test]
686 fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
687 let unique = SystemTime::now()
688 .duration_since(UNIX_EPOCH)
689 .unwrap()
690 .as_nanos();
691 let directory = std::env::current_dir()
692 .unwrap()
693 .join(".work/cache-v2-tests")
694 .join(format!("malformed-{}-{unique}", std::process::id()));
695 std::fs::create_dir_all(&directory).unwrap();
696 let canonical = directory.join("workspace-v2.toml");
697 let legacy = directory.join("workspace.toml");
698 std::fs::write(&canonical, "active_group = [\n").unwrap();
699 std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
700
701 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
702
703 assert_eq!(cache.tree_selected, 0);
704 assert_eq!(
705 std::fs::read_to_string(&canonical).unwrap(),
706 "active_group = [\n"
707 );
708 std::fs::remove_dir_all(directory).unwrap();
709 }
710
711 #[test]
712 fn cursor_identity_round_trips_through_stable_terminal_id() {
713 use crate::{
714 model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
715 runtime::{AgentState, PaneId, SessionId, TerminalId},
716 };
717 let workspace = WorkspaceState {
718 projects: vec![Project {
719 name: "repo".into(),
720 path: "/repo".into(),
721 default_branch: "main".into(),
722 last_agent_active_unix_ms: None,
723 last_terminal_active_unix_ms: None,
724 worktrees: vec![WorktreeInfo {
725 name: "main".into(),
726 branch: "main".into(),
727 path: "/repo".into(),
728 is_main: true,
729 alias: None,
730 sessions: vec![SessionInfo {
731 session_id: SessionId(1),
732 pane_id: PaneId(1),
733 terminal_id: TerminalId(1),
734 agent: None,
735 display_name: "agent".into(),
736 agent_status: AgentState::Working,
737 revision: 1,
738 layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
739 panes: vec![],
740 muted: false,
741 outcome_acknowledged: false,
742 }],
743 expanded: true,
744 git_info: None,
745 fetch_failed: false,
746 fetch_fail_count: 0,
747 fetch_fail_reason: None,
748 last_fetched: None,
749 git_info_fetched_at: None,
750 }],
751 routines: vec![],
752 routine_revision: 0,
753 routines_expanded: true,
754 config: None,
755 expanded: true,
756 missing: false,
757 }],
758 };
759 let flat = flatten_tree(&workspace);
760 let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
761 assert_eq!(
762 identity,
763 CursorIdentity::Session {
764 worktree_path: "/repo".into(),
765 terminal_id: Some("1".into()),
766 pane_id: None,
767 }
768 );
769 assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
770 }
771}