Skip to main content

wsx_core/
cache.rs

1//! Persistent wsx UI state, local mute flags, and acknowledged outcomes.
2//!
3//! The wsx daemon is authoritative for sessions. Legacy backend/session fields
4//! in older TOML files are ignored by serde and are never imported.
5
6use 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/// Stable cursor identity for projects, worktrees, terminal panes, and routines.
17#[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        /// Legacy identity read once and migrated through the live snapshot.
30        #[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    /// Latest explicit project interaction, keyed by stable project path.
51    #[serde(default)]
52    pub project_touched_unix_ms: HashMap<String, u64>,
53    /// Projects whose last collapse was caused by the inactivity timer.
54    #[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    /// Stable wsx terminal IDs muted in this local UI.
63    #[serde(default)]
64    pub muted_terminals: HashSet<String>,
65    /// Provider outcome revisions acknowledged by explicit interaction, keyed by terminal ID.
66    #[serde(default)]
67    pub acknowledged_outcomes: HashMap<String, u64>,
68    /// Agent integrations whose demand-driven setup prompt the user declined.
69    #[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        // ^ Group selection is process-local. Reading any historical selector requests a
105        // canonical rewrite that strips it instead of restoring stale UI state.
106        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    // ^ 0.26.1 seeded one timestamp across the large untouched-project cohort.
197    // Do not infer collapse provenance from a small or non-majority timestamp collision.
198    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
268/// Apply only cached UI and local mute state. Sessions always come from wsxd.
269pub 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(&current, "/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(&current), 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}