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)]
55 pub stale_collapsed_projects: HashSet<String>,
56 #[serde(default)]
57 pub routines_expanded: HashMap<String, bool>,
58 #[serde(default)]
59 pub tree_selected: usize,
60 #[serde(default)]
61 pub cursor_identity: Option<CursorIdentity>,
62 #[serde(default)]
64 pub muted_terminals: HashSet<String>,
65 #[serde(default)]
67 pub acknowledged_outcomes: HashMap<String, u64>,
68 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
70 pub dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
71 #[serde(skip)]
72 migration_needed: bool,
73 #[serde(skip)]
74 stale_provenance_missing: bool,
75}
76
77#[derive(Deserialize, Default)]
78#[serde(default)]
79struct WorkspaceCacheWire {
80 written_at_unix_ms: Option<u64>,
81 worktree_expanded: HashMap<String, bool>,
82 project_expanded: HashMap<String, bool>,
83 project_touched_unix_ms: HashMap<String, u64>,
84 stale_collapsed_projects: Option<HashSet<String>>,
85 routines_expanded: HashMap<String, bool>,
86 tree_selected: usize,
87 cursor_identity: Option<CursorIdentity>,
88 #[serde(alias = "muted_sessions")]
89 muted_terminals: HashSet<String>,
90 acknowledged_outcomes: HashMap<String, u64>,
91 active_group: Option<toml::Value>,
92 active_groups: Option<toml::Value>,
93 active_tab: Option<toml::Value>,
94 integration_prompt_version: Option<String>,
95 dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
96}
97
98impl<'de> Deserialize<'de> for WorkspaceCache {
99 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
100 where
101 D: Deserializer<'de>,
102 {
103 let wire = WorkspaceCacheWire::deserialize(deserializer)?;
104 let migration_needed = wire.active_group.is_some()
107 || wire.active_groups.is_some()
108 || wire.active_tab.is_some();
109 Ok(Self {
110 written_at_unix_ms: wire.written_at_unix_ms,
111 worktree_expanded: wire.worktree_expanded,
112 project_expanded: wire.project_expanded,
113 project_touched_unix_ms: wire.project_touched_unix_ms,
114 stale_provenance_missing: wire.stale_collapsed_projects.is_none(),
115 stale_collapsed_projects: wire.stale_collapsed_projects.unwrap_or_default(),
116 routines_expanded: wire.routines_expanded,
117 tree_selected: wire.tree_selected,
118 cursor_identity: wire.cursor_identity,
119 muted_terminals: wire.muted_terminals,
120 acknowledged_outcomes: wire.acknowledged_outcomes,
121 dismissed_integration_prompts: wire.dismissed_integration_prompts,
122 migration_needed: migration_needed || wire.integration_prompt_version.is_some(),
123 })
124 }
125}
126
127impl WorkspaceCache {
128 pub fn load() -> anyhow::Result<Self> {
129 Self::load_from_paths(&cache_path(), &legacy_cache_path())
130 }
131
132 fn load_from_paths(
133 canonical: &std::path::Path,
134 legacy: &std::path::Path,
135 ) -> anyhow::Result<Self> {
136 let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
137 Ok(content) => (content, false),
138 Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
139 Ok(content) => (content, true),
140 Err(_) => return Ok(Self::default()),
141 },
142 Err(_) => return Ok(Self::default()),
143 };
144 let mut cache: Self = toml::from_str(&content).unwrap_or_default();
145 if imported_legacy || cache.migration_needed {
146 cache.save_to(canonical, false)?;
147 cache.migration_needed = false;
148 }
149 Ok(cache)
150 }
151
152 pub fn save(&self, sync: bool) -> anyhow::Result<()> {
153 self.save_to(&cache_path(), sync)
154 }
155
156 fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
157 let mut cache = self.clone();
158 cache.written_at_unix_ms = Some(now_unix_ms());
159 let text = toml::to_string(&cache)?;
160 atomic_write_private(path, text.as_bytes(), sync)?;
161 Ok(())
162 }
163}
164
165fn now_unix_ms() -> u64 {
166 SystemTime::now()
167 .duration_since(UNIX_EPOCH)
168 .unwrap_or_default()
169 .as_millis()
170 .try_into()
171 .unwrap_or(u64::MAX)
172}
173
174fn cached_project_touch_unix_ms(
175 cache: &WorkspaceCache,
176 project_key: &str,
177 loaded_at_unix_ms: u64,
178) -> u64 {
179 cache
180 .project_touched_unix_ms
181 .get(project_key)
182 .copied()
183 .or(cache.written_at_unix_ms)
184 .unwrap_or(loaded_at_unix_ms)
185}
186
187fn legacy_seeded_touch_cohort(cache: &WorkspaceCache) -> Option<u64> {
188 if !cache.stale_provenance_missing {
189 return None;
190 }
191 let mut counts = HashMap::<u64, usize>::new();
192 for timestamp in cache.project_touched_unix_ms.values() {
193 *counts.entry(*timestamp).or_default() += 1;
194 }
195 let cohort_size = cache.project_touched_unix_ms.len();
196 counts
199 .into_iter()
200 .filter(|(_, count)| *count >= 3 && *count > cohort_size / 2)
201 .max_by_key(|(timestamp, count)| (*count, *timestamp))
202 .map(|(timestamp, _)| timestamp)
203}
204
205fn cache_path() -> PathBuf {
206 dirs::cache_dir()
207 .unwrap_or_else(|| PathBuf::from("/tmp"))
208 .join("wsx")
209 .join("workspace-v2.toml")
210}
211
212fn legacy_cache_path() -> PathBuf {
213 dirs::cache_dir()
214 .unwrap_or_else(|| PathBuf::from("/tmp"))
215 .join("wsx")
216 .join("workspace.toml")
217}
218
219#[derive(Serialize, Deserialize)]
220struct GroupSelection {
221 selected: GroupKey,
222}
223
224fn group_selection_path() -> PathBuf {
225 dirs::cache_dir()
226 .unwrap_or_else(|| PathBuf::from("/tmp"))
227 .join("wsx")
228 .join("group-selection-v1.toml")
229}
230
231pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
232 load_group_selection_from(&group_selection_path())
233}
234
235fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
236 let content = match std::fs::read_to_string(path) {
237 Ok(content) => content,
238 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
239 Err(error) => return Err(error.into()),
240 };
241 Ok(toml::from_str::<GroupSelection>(&content)
242 .ok()
243 .map(|selection| selection.selected))
244}
245
246pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
247 save_group_selection_to(&group_selection_path(), selected)
248}
249
250fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
251 let text = toml::to_string(&GroupSelection {
252 selected: selected.clone(),
253 })?;
254 atomic_write_private(path, text.as_bytes(), true)?;
255 Ok(())
256}
257
258pub type AppliedCache = (
259 usize,
260 Option<CursorIdentity>,
261 HashMap<PathBuf, u64>,
262 HashSet<PathBuf>,
263 HashSet<String>,
264 HashMap<String, u64>,
265 HashSet<crate::integration::IntegrationTarget>,
266);
267
268pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
270 let cache = WorkspaceCache::load()?;
271 apply_workspace_cache(workspace, cache, |cache| cache.save(false))
272}
273
274fn apply_workspace_cache(
275 workspace: &mut WorkspaceState,
276 mut cache: WorkspaceCache,
277 persist_migration: impl FnOnce(&WorkspaceCache) -> anyhow::Result<()>,
278) -> anyhow::Result<AppliedCache> {
279 let mut migrated_muted_terminals = HashSet::new();
280 let mut project_touched_unix_ms = HashMap::new();
281 let mut stale_collapsed_projects = HashSet::new();
282 let loaded_at_unix_ms = now_unix_ms();
283 let legacy_seeded_touch = legacy_seeded_touch_cohort(&cache);
284 for project in &mut workspace.projects {
285 let project_key = project.path.to_string_lossy().to_string();
286 let touched_unix_ms = cached_project_touch_unix_ms(&cache, &project_key, loaded_at_unix_ms);
287 project_touched_unix_ms.insert(project.path.clone(), touched_unix_ms);
288 if let Some(expanded) = cache.project_expanded.get(&project_key) {
289 project.expanded = *expanded;
290 }
291 if cache.stale_collapsed_projects.contains(&project_key)
292 || (!project.expanded && legacy_seeded_touch == Some(touched_unix_ms))
293 {
294 stale_collapsed_projects.insert(project.path.clone());
295 }
296 if let Some(expanded) = cache.routines_expanded.get(&project_key) {
297 project.routines_expanded = *expanded;
298 }
299 for worktree in &mut project.worktrees {
300 let key = worktree.path.to_string_lossy().to_string();
301 if let Some(expanded) = cache.worktree_expanded.get(&key) {
302 worktree.expanded = *expanded;
303 }
304 for session in &mut worktree.sessions {
305 session.muted = cache
306 .muted_terminals
307 .contains(&session.terminal_id.to_string())
308 || cache.muted_terminals.contains(&session.pane_id.to_string());
309 if session.muted {
310 migrated_muted_terminals.insert(session.terminal_id.to_string());
311 }
312 for pane in &mut session.panes {
313 pane.outcome_acknowledged = pane.agent_status
314 == crate::runtime::AgentState::Done
315 && cache
316 .acknowledged_outcomes
317 .get(&pane.terminal_id.to_string())
318 == Some(&pane.revision);
319 }
320 session.outcome_acknowledged = session
321 .panes
322 .iter()
323 .find(|pane| pane.pane_id == session.pane_id)
324 .is_some_and(|pane| pane.outcome_acknowledged);
325 }
326 }
327 }
328 if cache.stale_provenance_missing {
329 cache.project_touched_unix_ms = project_touched_unix_ms
330 .iter()
331 .map(|(path, timestamp)| (path.to_string_lossy().into_owned(), *timestamp))
332 .collect();
333 cache.stale_collapsed_projects = stale_collapsed_projects
334 .iter()
335 .map(|path| path.to_string_lossy().into_owned())
336 .collect();
337 cache.stale_provenance_missing = false;
338 persist_migration(&cache)?;
339 }
340 Ok((
341 cache.tree_selected,
342 cache.cursor_identity,
343 project_touched_unix_ms,
344 stale_collapsed_projects,
345 migrated_muted_terminals,
346 cache.acknowledged_outcomes,
347 cache.dismissed_integration_prompts,
348 ))
349}
350
351pub fn find_cursor_index(
352 workspace: &WorkspaceState,
353 flat: &[FlatEntry],
354 id: &CursorIdentity,
355) -> Option<usize> {
356 match id {
357 CursorIdentity::Project { path } => flat.iter().position(|entry| {
358 matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
359 }),
360 CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
361 matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
362 }),
363 CursorIdentity::Session {
364 worktree_path,
365 terminal_id,
366 pane_id,
367 } => flat.iter().position(|entry| {
368 let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
369 FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
370 (*project_idx, *worktree_idx, *session_idx, None)
371 }
372 FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
373 (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
374 }
375 _ => return false,
376 };
377 let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
378 let session = &wt.sessions[session_idx];
379 let (terminal, pane) = pane_idx
380 .and_then(|idx| session.panes.get(idx))
381 .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
382 wt.path.to_string_lossy() == worktree_path.as_str()
383 && terminal_id
384 .as_ref()
385 .map(|id| terminal.to_string() == *id)
386 .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
387 .unwrap_or(false)
388 }),
389 CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
390 matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
391 }),
392 CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
393 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)
394 }),
395 }
396}
397
398pub fn save_cache(
399 workspace: &WorkspaceState,
400 tree_selected: usize,
401 flat: &[FlatEntry],
402 project_touched_unix_ms: &HashMap<PathBuf, u64>,
403 stale_collapsed_projects: &HashSet<PathBuf>,
404 dismissed_integration_prompts: &HashSet<crate::integration::IntegrationTarget>,
405 sync: bool,
406) -> Option<String> {
407 let mut cache = WorkspaceCache {
408 written_at_unix_ms: Some(now_unix_ms()),
409 tree_selected,
410 cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
411 dismissed_integration_prompts: dismissed_integration_prompts.clone(),
412 ..Default::default()
413 };
414 for project in &workspace.projects {
415 let project_path = project.path.to_string_lossy().into_owned();
416 cache
417 .project_expanded
418 .insert(project_path.clone(), project.expanded);
419 if let Some(touched_unix_ms) = project_touched_unix_ms.get(&project.path) {
420 cache
421 .project_touched_unix_ms
422 .insert(project_path.clone(), *touched_unix_ms);
423 }
424 if stale_collapsed_projects.contains(&project.path) {
425 cache.stale_collapsed_projects.insert(project_path.clone());
426 }
427 cache
428 .routines_expanded
429 .insert(project_path, project.routines_expanded);
430 for worktree in &project.worktrees {
431 cache.worktree_expanded.insert(
432 worktree.path.to_string_lossy().into_owned(),
433 worktree.expanded,
434 );
435 cache.muted_terminals.extend(
436 worktree
437 .sessions
438 .iter()
439 .filter(|s| s.muted)
440 .map(|s| s.terminal_id.to_string()),
441 );
442 for session in &worktree.sessions {
443 for pane in &session.panes {
444 if pane.outcome_acknowledged {
445 cache
446 .acknowledged_outcomes
447 .insert(pane.terminal_id.to_string(), pane.revision);
448 }
449 }
450 }
451 }
452 }
453 cache
454 .save(sync)
455 .err()
456 .map(|e| format!("cache save failed: {e}"))
457}
458
459pub fn resolve_cursor_identity(
460 workspace: &WorkspaceState,
461 flat: &[FlatEntry],
462 idx: usize,
463) -> Option<CursorIdentity> {
464 match flat.get(idx)? {
465 FlatEntry::Project { idx } => Some(CursorIdentity::Project {
466 path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
467 }),
468 FlatEntry::Worktree {
469 project_idx,
470 worktree_idx,
471 } => Some(CursorIdentity::Worktree {
472 path: workspace.projects[*project_idx].worktrees[*worktree_idx]
473 .path
474 .to_string_lossy()
475 .into_owned(),
476 }),
477 FlatEntry::Session {
478 project_idx,
479 worktree_idx,
480 session_idx,
481 } => {
482 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
483 Some(CursorIdentity::Session {
484 worktree_path: wt.path.to_string_lossy().into_owned(),
485 terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
486 pane_id: None,
487 })
488 }
489 FlatEntry::Pane {
490 project_idx,
491 worktree_idx,
492 session_idx,
493 pane_idx,
494 } => {
495 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
496 Some(CursorIdentity::Session {
497 worktree_path: wt.path.to_string_lossy().into_owned(),
498 terminal_id: Some(
499 wt.sessions[*session_idx].panes[*pane_idx]
500 .terminal_id
501 .to_string(),
502 ),
503 pane_id: None,
504 })
505 }
506 FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
507 project_path: workspace.projects[*project_idx]
508 .path
509 .to_string_lossy()
510 .into_owned(),
511 }),
512 FlatEntry::Routine {
513 project_idx,
514 routine_idx,
515 } => Some(CursorIdentity::Routine {
516 project_path: workspace.projects[*project_idx]
517 .path
518 .to_string_lossy()
519 .into_owned(),
520 routine_name: workspace.projects[*project_idx].routines[*routine_idx]
521 .routine
522 .name
523 .clone(),
524 }),
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn legacy_cache_defaults_missing_dismissed_integration_prompts() {
534 let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
535 assert!(cache.dismissed_integration_prompts.is_empty());
536 }
537
538 #[test]
539 fn dismissed_integration_prompts_round_trip_per_agent() {
540 let cache = WorkspaceCache {
541 dismissed_integration_prompts: [crate::integration::IntegrationTarget::Pi]
542 .into_iter()
543 .collect(),
544 ..Default::default()
545 };
546 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
547 assert_eq!(
548 decoded.dismissed_integration_prompts,
549 cache.dismissed_integration_prompts
550 );
551 }
552
553 #[test]
554 fn legacy_blanket_dismissal_is_migrated_away() {
555 let cache: WorkspaceCache =
556 toml::from_str("tree_selected = 2\nintegration_prompt_version = \"0.21.0\"\n").unwrap();
557 assert!(cache.dismissed_integration_prompts.is_empty());
558 assert!(cache.migration_needed);
559 }
560
561 #[test]
562 fn expansion_maps_round_trip_by_stable_path() {
563 let cache = WorkspaceCache {
564 project_expanded: HashMap::from([("/projects/app".into(), true)]),
565 project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
566 stale_collapsed_projects: HashSet::from(["/projects/old".into()]),
567 worktree_expanded: HashMap::from([("/projects/app/feature".into(), false)]),
568 routines_expanded: HashMap::from([("/projects/app".into(), false)]),
569 ..Default::default()
570 };
571
572 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
573
574 assert_eq!(decoded.project_expanded, cache.project_expanded);
575 assert_eq!(
576 decoded.project_touched_unix_ms,
577 cache.project_touched_unix_ms
578 );
579 assert_eq!(
580 decoded.stale_collapsed_projects,
581 cache.stale_collapsed_projects
582 );
583 assert_eq!(decoded.worktree_expanded, cache.worktree_expanded);
584 assert_eq!(decoded.routines_expanded, cache.routines_expanded);
585 let empty = toml::to_string(&WorkspaceCache::default()).unwrap();
586 assert!(empty.contains("stale_collapsed_projects = []"));
587 assert!(
588 !toml::from_str::<WorkspaceCache>(&empty)
589 .unwrap()
590 .stale_provenance_missing
591 );
592 }
593
594 #[test]
595 fn legacy_cache_defaults_missing_routines_expanded_map() {
596 let cache: WorkspaceCache = toml::from_str(
597 r#"[project_expanded]
598"/projects/app" = true
599
600[worktree_expanded]
601"/projects/app/main" = false
602"#,
603 )
604 .unwrap();
605
606 assert!(cache.routines_expanded.is_empty());
607 assert!(cache.project_touched_unix_ms.is_empty());
608 assert!(cache.stale_collapsed_projects.is_empty());
609 assert!(cache.stale_provenance_missing);
610 }
611
612 #[test]
613 fn missing_project_touch_uses_cache_write_time_once() {
614 let legacy = WorkspaceCache {
615 written_at_unix_ms: Some(41),
616 ..Default::default()
617 };
618 assert_eq!(
619 cached_project_touch_unix_ms(&legacy, "/projects/app", 99),
620 41
621 );
622
623 let current = WorkspaceCache {
624 written_at_unix_ms: Some(41),
625 project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
626 ..Default::default()
627 };
628 assert_eq!(
629 cached_project_touch_unix_ms(¤t, "/projects/app", 99),
630 42
631 );
632
633 assert_eq!(
634 cached_project_touch_unix_ms(&WorkspaceCache::default(), "/projects/app", 99),
635 99
636 );
637 }
638
639 #[test]
640 fn repeated_legacy_migration_timestamp_identifies_one_stale_cohort() {
641 let cache: WorkspaceCache = toml::from_str(
642 r#"written_at_unix_ms = 99
643
644[project_touched_unix_ms]
645"/projects/a" = 41
646"/projects/b" = 41
647"/projects/c" = 41
648"/projects/touched" = 72
649"#,
650 )
651 .unwrap();
652
653 assert_eq!(legacy_seeded_touch_cohort(&cache), Some(41));
654
655 let ambiguous: WorkspaceCache = toml::from_str(
656 r#"[project_touched_unix_ms]
657"/projects/a" = 41
658"/projects/b" = 41
659"#,
660 )
661 .unwrap();
662 assert_eq!(legacy_seeded_touch_cohort(&ambiguous), None);
663
664 let current: WorkspaceCache = toml::from_str(
665 r#"stale_collapsed_projects = []
666
667[project_touched_unix_ms]
668"/projects/a" = 41
669"/projects/b" = 41
670"#,
671 )
672 .unwrap();
673 assert_eq!(legacy_seeded_touch_cohort(¤t), None);
674 }
675
676 #[test]
677 fn acknowledged_outcome_revisions_round_trip() {
678 let cache = WorkspaceCache {
679 acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
680 ..Default::default()
681 };
682
683 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
684
685 assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
686 }
687
688 #[test]
689 fn legacy_tmux_and_session_fields_are_ignored() {
690 let cache: WorkspaceCache = toml::from_str(
691 r#"tmux_server_pid = 123
692sessions = { "/tmp/repo" = ["old-tmux-session"] }
693muted_sessions = ["pane-1"]
694"#,
695 )
696 .unwrap();
697 assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
698 }
699
700 #[test]
701 fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
702 let cache: WorkspaceCache = toml::from_str(
703 r#"[cursor_identity.Session]
704worktree_path = "/repo"
705pane_id = "pane-1"
706"#,
707 )
708 .unwrap();
709 assert_eq!(
710 cache.cursor_identity,
711 Some(CursorIdentity::Session {
712 worktree_path: "/repo".into(),
713 terminal_id: None,
714 pane_id: Some("pane-1".into()),
715 })
716 );
717 }
718
719 #[test]
720 fn historical_active_group_shapes_are_discarded_on_rewrite() {
721 for historical in [
722 "active_group = \"work\"\n",
723 "active_tab = \"work\"\n",
724 "active_groups = [\"work\", \"other\"]\n",
725 "active_groups = []\n",
726 ] {
727 let cache: WorkspaceCache = toml::from_str(historical).unwrap();
728 assert!(cache.migration_needed);
729 let encoded = toml::to_string(&cache).unwrap();
730 assert!(!encoded.contains("active_group"));
731 assert!(!encoded.contains("active_groups"));
732 assert!(!encoded.contains("active_tab"));
733 }
734 }
735
736 #[test]
737 fn group_selection_is_independent_and_malformed_data_defaults_absent() {
738 let unique = SystemTime::now()
739 .duration_since(UNIX_EPOCH)
740 .unwrap()
741 .as_nanos();
742 let directory = std::env::current_dir()
743 .unwrap()
744 .join(".work/group-selection-tests")
745 .join(format!("{}-{unique}", std::process::id()));
746 std::fs::create_dir_all(&directory).unwrap();
747 let path = directory.join("group-selection-v1.toml");
748
749 assert_eq!(load_group_selection_from(&path).unwrap(), None);
750 save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
751 assert_eq!(
752 load_group_selection_from(&path).unwrap(),
753 Some(GroupKey::Named("work".into()))
754 );
755 std::fs::write(&path, "selected = [\n").unwrap();
756 assert_eq!(load_group_selection_from(&path).unwrap(), None);
757 std::fs::remove_dir_all(directory).unwrap();
758 }
759
760 #[test]
761 fn workspace_cache_serialization_never_carries_group_selection() {
762 let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
763 assert!(!encoded.contains("selected_group"));
764 assert!(!encoded.contains("active_group"));
765 }
766
767 #[test]
768 fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
769 let unique = SystemTime::now()
770 .duration_since(UNIX_EPOCH)
771 .unwrap()
772 .as_nanos();
773 let directory = std::env::current_dir()
774 .unwrap()
775 .join(".work/cache-v2-tests")
776 .join(format!("{}-{unique}", std::process::id()));
777 std::fs::create_dir_all(&directory).unwrap();
778 let canonical = directory.join("workspace-v2.toml");
779 let legacy = directory.join("workspace.toml");
780 let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
781 std::fs::write(&legacy, legacy_text).unwrap();
782
783 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
784
785 assert_eq!(cache.tree_selected, 3);
786 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
787 let canonical_text = std::fs::read_to_string(&canonical).unwrap();
788 assert!(!canonical_text.contains("active_group"));
789 assert!(!canonical_text.contains("active_tab"));
790 std::fs::remove_dir_all(directory).unwrap();
791 }
792
793 #[test]
794 fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
795 let unique = SystemTime::now()
796 .duration_since(UNIX_EPOCH)
797 .unwrap()
798 .as_nanos();
799 let directory = std::env::current_dir()
800 .unwrap()
801 .join(".work/cache-v2-tests")
802 .join(format!("malformed-{}-{unique}", std::process::id()));
803 std::fs::create_dir_all(&directory).unwrap();
804 let canonical = directory.join("workspace-v2.toml");
805 let legacy = directory.join("workspace.toml");
806 std::fs::write(&canonical, "active_group = [\n").unwrap();
807 std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
808
809 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
810
811 assert_eq!(cache.tree_selected, 0);
812 assert_eq!(
813 std::fs::read_to_string(&canonical).unwrap(),
814 "active_group = [\n"
815 );
816 std::fs::remove_dir_all(directory).unwrap();
817 }
818
819 #[test]
820 fn applying_legacy_touch_cohort_persists_stale_provenance_immediately() {
821 use crate::model::workspace::Project;
822
823 fn project(path: &str) -> Project {
824 Project {
825 name: path.into(),
826 path: path.into(),
827 default_branch: "main".into(),
828 last_agent_active_unix_ms: None,
829 last_terminal_active_unix_ms: None,
830 worktrees: vec![],
831 routines: vec![],
832 routine_revision: 0,
833 routines_expanded: true,
834 config: None,
835 expanded: true,
836 missing: false,
837 }
838 }
839
840 let unique = SystemTime::now()
841 .duration_since(UNIX_EPOCH)
842 .unwrap()
843 .as_nanos();
844 let directory = std::env::current_dir()
845 .unwrap()
846 .join(".work/cache-v2-tests")
847 .join(format!("stale-migration-{}-{unique}", std::process::id()));
848 std::fs::create_dir_all(&directory).unwrap();
849 let canonical = directory.join("workspace-v2.toml");
850 let legacy = directory.join("workspace.toml");
851 let cache = WorkspaceCache {
852 project_expanded: HashMap::from([
853 ("/closed".into(), false),
854 ("/closed-two".into(), false),
855 ("/open".into(), true),
856 ]),
857 project_touched_unix_ms: HashMap::from([
858 ("/closed".into(), 100),
859 ("/closed-two".into(), 100),
860 ("/open".into(), 100),
861 ]),
862 stale_provenance_missing: true,
863 ..Default::default()
864 };
865 let mut workspace = WorkspaceState {
866 projects: vec![project("/closed"), project("/closed-two"), project("/open")],
867 };
868
869 let (_, _, touches, stale, _, _, _) =
870 apply_workspace_cache(&mut workspace, cache, |cache| {
871 cache.save_to(&canonical, false)
872 })
873 .unwrap();
874
875 assert!(!workspace.projects[0].expanded);
876 assert!(!workspace.projects[1].expanded);
877 assert!(workspace.projects[2].expanded);
878 assert_eq!(touches.get(&PathBuf::from("/closed")), Some(&100));
879 assert_eq!(
880 stale,
881 HashSet::from([PathBuf::from("/closed"), PathBuf::from("/closed-two")])
882 );
883 let persisted = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
884 assert!(!persisted.stale_provenance_missing);
885 assert_eq!(
886 persisted.stale_collapsed_projects,
887 HashSet::from(["/closed".into(), "/closed-two".into()])
888 );
889 assert_eq!(
890 persisted.project_touched_unix_ms,
891 HashMap::from([
892 ("/closed".into(), 100),
893 ("/closed-two".into(), 100),
894 ("/open".into(), 100),
895 ])
896 );
897 std::fs::remove_dir_all(directory).unwrap();
898 }
899
900 #[test]
901 fn cursor_identity_round_trips_through_stable_terminal_id() {
902 use crate::{
903 model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
904 runtime::{AgentState, PaneId, SessionId, TerminalId},
905 };
906 let workspace = WorkspaceState {
907 projects: vec![Project {
908 name: "repo".into(),
909 path: "/repo".into(),
910 default_branch: "main".into(),
911 last_agent_active_unix_ms: None,
912 last_terminal_active_unix_ms: None,
913 worktrees: vec![WorktreeInfo {
914 name: "main".into(),
915 branch: "main".into(),
916 path: "/repo".into(),
917 is_main: true,
918 alias: None,
919 sessions: vec![SessionInfo {
920 session_id: SessionId(1),
921 pane_id: PaneId(1),
922 terminal_id: TerminalId(1),
923 agent: None,
924 display_name: "agent".into(),
925 agent_status: AgentState::Working,
926 revision: 1,
927 layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
928 panes: vec![],
929 muted: false,
930 outcome_acknowledged: false,
931 }],
932 expanded: true,
933 git_info: None,
934 fetch_failed: false,
935 fetch_fail_count: 0,
936 fetch_fail_reason: None,
937 last_fetched: None,
938 git_info_fetched_at: None,
939 }],
940 routines: vec![],
941 routine_revision: 0,
942 routines_expanded: true,
943 config: None,
944 expanded: true,
945 missing: false,
946 }],
947 };
948 let flat = flatten_tree(&workspace);
949 let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
950 assert_eq!(
951 identity,
952 CursorIdentity::Session {
953 worktree_path: "/repo".into(),
954 terminal_id: Some("1".into()),
955 pane_id: None,
956 }
957 );
958 assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
959 }
960}