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    #[serde(default)]
51    pub tree_selected: usize,
52    #[serde(default)]
53    pub cursor_identity: Option<CursorIdentity>,
54    /// Stable wsx terminal IDs muted in this local UI.
55    #[serde(default)]
56    pub muted_terminals: HashSet<String>,
57    /// Provider outcome revisions acknowledged by explicit interaction, keyed by terminal ID.
58    #[serde(default)]
59    pub acknowledged_outcomes: HashMap<String, u64>,
60    /// wsx version for which the user dismissed the integration setup prompt.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub integration_prompt_version: Option<String>,
63    #[serde(skip)]
64    migration_needed: bool,
65}
66
67#[derive(Deserialize, Default)]
68#[serde(default)]
69struct WorkspaceCacheWire {
70    written_at_unix_ms: Option<u64>,
71    worktree_expanded: HashMap<String, bool>,
72    project_expanded: HashMap<String, bool>,
73    tree_selected: usize,
74    cursor_identity: Option<CursorIdentity>,
75    #[serde(alias = "muted_sessions")]
76    muted_terminals: HashSet<String>,
77    acknowledged_outcomes: HashMap<String, u64>,
78    active_group: Option<toml::Value>,
79    active_groups: Option<toml::Value>,
80    active_tab: Option<toml::Value>,
81    integration_prompt_version: Option<String>,
82}
83
84impl<'de> Deserialize<'de> for WorkspaceCache {
85    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86    where
87        D: Deserializer<'de>,
88    {
89        let wire = WorkspaceCacheWire::deserialize(deserializer)?;
90        // ^ Group selection is process-local. Reading any historical selector requests a
91        // canonical rewrite that strips it instead of restoring stale UI state.
92        let migration_needed = wire.active_group.is_some()
93            || wire.active_groups.is_some()
94            || wire.active_tab.is_some();
95        Ok(Self {
96            written_at_unix_ms: wire.written_at_unix_ms,
97            worktree_expanded: wire.worktree_expanded,
98            project_expanded: wire.project_expanded,
99            tree_selected: wire.tree_selected,
100            cursor_identity: wire.cursor_identity,
101            muted_terminals: wire.muted_terminals,
102            acknowledged_outcomes: wire.acknowledged_outcomes,
103            integration_prompt_version: wire.integration_prompt_version,
104            migration_needed,
105        })
106    }
107}
108
109impl WorkspaceCache {
110    pub fn load() -> anyhow::Result<Self> {
111        Self::load_from_paths(&cache_path(), &legacy_cache_path())
112    }
113
114    fn load_from_paths(
115        canonical: &std::path::Path,
116        legacy: &std::path::Path,
117    ) -> anyhow::Result<Self> {
118        let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
119            Ok(content) => (content, false),
120            Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
121                Ok(content) => (content, true),
122                Err(_) => return Ok(Self::default()),
123            },
124            Err(_) => return Ok(Self::default()),
125        };
126        let mut cache: Self = toml::from_str(&content).unwrap_or_default();
127        if imported_legacy || cache.migration_needed {
128            cache.save_to(canonical, false)?;
129            cache.migration_needed = false;
130        }
131        Ok(cache)
132    }
133
134    pub fn save(&self, sync: bool) -> anyhow::Result<()> {
135        self.save_to(&cache_path(), sync)
136    }
137
138    fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
139        let mut cache = self.clone();
140        cache.written_at_unix_ms = Some(now_unix_ms());
141        let text = toml::to_string(&cache)?;
142        atomic_write_private(path, text.as_bytes(), sync)?;
143        Ok(())
144    }
145}
146
147fn now_unix_ms() -> u64 {
148    SystemTime::now()
149        .duration_since(UNIX_EPOCH)
150        .unwrap_or_default()
151        .as_millis()
152        .try_into()
153        .unwrap_or(u64::MAX)
154}
155
156fn cache_path() -> PathBuf {
157    dirs::cache_dir()
158        .unwrap_or_else(|| PathBuf::from("/tmp"))
159        .join("wsx")
160        .join("workspace-v2.toml")
161}
162
163fn legacy_cache_path() -> PathBuf {
164    dirs::cache_dir()
165        .unwrap_or_else(|| PathBuf::from("/tmp"))
166        .join("wsx")
167        .join("workspace.toml")
168}
169
170#[derive(Serialize, Deserialize)]
171struct GroupSelection {
172    selected: GroupKey,
173}
174
175fn group_selection_path() -> PathBuf {
176    dirs::cache_dir()
177        .unwrap_or_else(|| PathBuf::from("/tmp"))
178        .join("wsx")
179        .join("group-selection-v1.toml")
180}
181
182pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
183    load_group_selection_from(&group_selection_path())
184}
185
186fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
187    let content = match std::fs::read_to_string(path) {
188        Ok(content) => content,
189        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
190        Err(error) => return Err(error.into()),
191    };
192    Ok(toml::from_str::<GroupSelection>(&content)
193        .ok()
194        .map(|selection| selection.selected))
195}
196
197pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
198    save_group_selection_to(&group_selection_path(), selected)
199}
200
201fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
202    let text = toml::to_string(&GroupSelection {
203        selected: selected.clone(),
204    })?;
205    atomic_write_private(path, text.as_bytes(), true)?;
206    Ok(())
207}
208
209pub type AppliedCache = (
210    usize,
211    Option<CursorIdentity>,
212    HashSet<String>,
213    HashMap<String, u64>,
214    Option<String>,
215);
216
217/// Apply only cached UI and local mute state. Sessions always come from wsxd.
218pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
219    let cache = WorkspaceCache::load()?;
220    let mut migrated_muted_terminals = HashSet::new();
221    for project in &mut workspace.projects {
222        let project_key = project.path.to_string_lossy().to_string();
223        if let Some(expanded) = cache.project_expanded.get(&project_key) {
224            project.expanded = *expanded;
225        }
226        for worktree in &mut project.worktrees {
227            let key = worktree.path.to_string_lossy().to_string();
228            if let Some(expanded) = cache.worktree_expanded.get(&key) {
229                worktree.expanded = *expanded;
230            }
231            for session in &mut worktree.sessions {
232                session.muted = cache
233                    .muted_terminals
234                    .contains(&session.terminal_id.to_string())
235                    || cache.muted_terminals.contains(&session.pane_id.to_string());
236                if session.muted {
237                    migrated_muted_terminals.insert(session.terminal_id.to_string());
238                }
239                for pane in &mut session.panes {
240                    pane.outcome_acknowledged = pane.agent_status
241                        == crate::runtime::AgentState::Done
242                        && cache
243                            .acknowledged_outcomes
244                            .get(&pane.terminal_id.to_string())
245                            == Some(&pane.revision);
246                }
247                session.outcome_acknowledged = session
248                    .panes
249                    .iter()
250                    .find(|pane| pane.pane_id == session.pane_id)
251                    .is_some_and(|pane| pane.outcome_acknowledged);
252            }
253        }
254    }
255    Ok((
256        cache.tree_selected,
257        cache.cursor_identity,
258        migrated_muted_terminals,
259        cache.acknowledged_outcomes,
260        cache.integration_prompt_version,
261    ))
262}
263
264pub fn find_cursor_index(
265    workspace: &WorkspaceState,
266    flat: &[FlatEntry],
267    id: &CursorIdentity,
268) -> Option<usize> {
269    match id {
270        CursorIdentity::Project { path } => flat.iter().position(|entry| {
271            matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
272        }),
273        CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
274            matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
275        }),
276        CursorIdentity::Session {
277            worktree_path,
278            terminal_id,
279            pane_id,
280        } => flat.iter().position(|entry| {
281            let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
282                FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
283                    (*project_idx, *worktree_idx, *session_idx, None)
284                }
285                FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
286                    (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
287                }
288                _ => return false,
289            };
290            let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
291            let session = &wt.sessions[session_idx];
292            let (terminal, pane) = pane_idx
293                .and_then(|idx| session.panes.get(idx))
294                .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
295            wt.path.to_string_lossy() == worktree_path.as_str()
296                && terminal_id
297                    .as_ref()
298                    .map(|id| terminal.to_string() == *id)
299                    .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
300                    .unwrap_or(false)
301        }),
302        CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
303            matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
304        }),
305        CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
306            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)
307        }),
308    }
309}
310
311pub fn save_cache(
312    workspace: &WorkspaceState,
313    tree_selected: usize,
314    flat: &[FlatEntry],
315    integration_prompt_version: Option<&str>,
316    sync: bool,
317) -> Option<String> {
318    let mut cache = WorkspaceCache {
319        written_at_unix_ms: Some(now_unix_ms()),
320        tree_selected,
321        cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
322        integration_prompt_version: integration_prompt_version.map(str::to_owned),
323        ..Default::default()
324    };
325    for project in &workspace.projects {
326        cache.project_expanded.insert(
327            project.path.to_string_lossy().into_owned(),
328            project.expanded,
329        );
330        for worktree in &project.worktrees {
331            cache.worktree_expanded.insert(
332                worktree.path.to_string_lossy().into_owned(),
333                worktree.expanded,
334            );
335            cache.muted_terminals.extend(
336                worktree
337                    .sessions
338                    .iter()
339                    .filter(|s| s.muted)
340                    .map(|s| s.terminal_id.to_string()),
341            );
342            for session in &worktree.sessions {
343                for pane in &session.panes {
344                    if pane.outcome_acknowledged {
345                        cache
346                            .acknowledged_outcomes
347                            .insert(pane.terminal_id.to_string(), pane.revision);
348                    }
349                }
350            }
351        }
352    }
353    cache
354        .save(sync)
355        .err()
356        .map(|e| format!("cache save failed: {e}"))
357}
358
359pub fn resolve_cursor_identity(
360    workspace: &WorkspaceState,
361    flat: &[FlatEntry],
362    idx: usize,
363) -> Option<CursorIdentity> {
364    match flat.get(idx)? {
365        FlatEntry::Project { idx } => Some(CursorIdentity::Project {
366            path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
367        }),
368        FlatEntry::Worktree {
369            project_idx,
370            worktree_idx,
371        } => Some(CursorIdentity::Worktree {
372            path: workspace.projects[*project_idx].worktrees[*worktree_idx]
373                .path
374                .to_string_lossy()
375                .into_owned(),
376        }),
377        FlatEntry::Session {
378            project_idx,
379            worktree_idx,
380            session_idx,
381        } => {
382            let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
383            Some(CursorIdentity::Session {
384                worktree_path: wt.path.to_string_lossy().into_owned(),
385                terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
386                pane_id: None,
387            })
388        }
389        FlatEntry::Pane {
390            project_idx,
391            worktree_idx,
392            session_idx,
393            pane_idx,
394        } => {
395            let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
396            Some(CursorIdentity::Session {
397                worktree_path: wt.path.to_string_lossy().into_owned(),
398                terminal_id: Some(
399                    wt.sessions[*session_idx].panes[*pane_idx]
400                        .terminal_id
401                        .to_string(),
402                ),
403                pane_id: None,
404            })
405        }
406        FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
407            project_path: workspace.projects[*project_idx]
408                .path
409                .to_string_lossy()
410                .into_owned(),
411        }),
412        FlatEntry::Routine {
413            project_idx,
414            routine_idx,
415        } => Some(CursorIdentity::Routine {
416            project_path: workspace.projects[*project_idx]
417                .path
418                .to_string_lossy()
419                .into_owned(),
420            routine_name: workspace.projects[*project_idx].routines[*routine_idx]
421                .routine
422                .name
423                .clone(),
424        }),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn legacy_cache_defaults_missing_integration_prompt_version() {
434        let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
435        assert_eq!(cache.integration_prompt_version, None);
436    }
437
438    #[test]
439    fn integration_prompt_version_round_trips() {
440        let cache = WorkspaceCache {
441            integration_prompt_version: Some("0.18.0".into()),
442            ..Default::default()
443        };
444        let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
445        assert_eq!(
446            decoded.integration_prompt_version.as_deref(),
447            Some("0.18.0")
448        );
449    }
450
451    #[test]
452    fn acknowledged_outcome_revisions_round_trip() {
453        let cache = WorkspaceCache {
454            acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
455            ..Default::default()
456        };
457
458        let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
459
460        assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
461    }
462
463    #[test]
464    fn legacy_tmux_and_session_fields_are_ignored() {
465        let cache: WorkspaceCache = toml::from_str(
466            r#"tmux_server_pid = 123
467sessions = { "/tmp/repo" = ["old-tmux-session"] }
468muted_sessions = ["pane-1"]
469"#,
470        )
471        .unwrap();
472        assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
473    }
474
475    #[test]
476    fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
477        let cache: WorkspaceCache = toml::from_str(
478            r#"[cursor_identity.Session]
479worktree_path = "/repo"
480pane_id = "pane-1"
481"#,
482        )
483        .unwrap();
484        assert_eq!(
485            cache.cursor_identity,
486            Some(CursorIdentity::Session {
487                worktree_path: "/repo".into(),
488                terminal_id: None,
489                pane_id: Some("pane-1".into()),
490            })
491        );
492    }
493
494    #[test]
495    fn historical_active_group_shapes_are_discarded_on_rewrite() {
496        for historical in [
497            "active_group = \"work\"\n",
498            "active_tab = \"work\"\n",
499            "active_groups = [\"work\", \"other\"]\n",
500            "active_groups = []\n",
501        ] {
502            let cache: WorkspaceCache = toml::from_str(historical).unwrap();
503            assert!(cache.migration_needed);
504            let encoded = toml::to_string(&cache).unwrap();
505            assert!(!encoded.contains("active_group"));
506            assert!(!encoded.contains("active_groups"));
507            assert!(!encoded.contains("active_tab"));
508        }
509    }
510
511    #[test]
512    fn group_selection_is_independent_and_malformed_data_defaults_absent() {
513        let unique = SystemTime::now()
514            .duration_since(UNIX_EPOCH)
515            .unwrap()
516            .as_nanos();
517        let directory = std::env::current_dir()
518            .unwrap()
519            .join(".work/group-selection-tests")
520            .join(format!("{}-{unique}", std::process::id()));
521        std::fs::create_dir_all(&directory).unwrap();
522        let path = directory.join("group-selection-v1.toml");
523
524        assert_eq!(load_group_selection_from(&path).unwrap(), None);
525        save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
526        assert_eq!(
527            load_group_selection_from(&path).unwrap(),
528            Some(GroupKey::Named("work".into()))
529        );
530        std::fs::write(&path, "selected = [\n").unwrap();
531        assert_eq!(load_group_selection_from(&path).unwrap(), None);
532        std::fs::remove_dir_all(directory).unwrap();
533    }
534
535    #[test]
536    fn workspace_cache_serialization_never_carries_group_selection() {
537        let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
538        assert!(!encoded.contains("selected_group"));
539        assert!(!encoded.contains("active_group"));
540    }
541
542    #[test]
543    fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
544        let unique = SystemTime::now()
545            .duration_since(UNIX_EPOCH)
546            .unwrap()
547            .as_nanos();
548        let directory = std::env::current_dir()
549            .unwrap()
550            .join(".work/cache-v2-tests")
551            .join(format!("{}-{unique}", std::process::id()));
552        std::fs::create_dir_all(&directory).unwrap();
553        let canonical = directory.join("workspace-v2.toml");
554        let legacy = directory.join("workspace.toml");
555        let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
556        std::fs::write(&legacy, legacy_text).unwrap();
557
558        let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
559
560        assert_eq!(cache.tree_selected, 3);
561        assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
562        let canonical_text = std::fs::read_to_string(&canonical).unwrap();
563        assert!(!canonical_text.contains("active_group"));
564        assert!(!canonical_text.contains("active_tab"));
565        std::fs::remove_dir_all(directory).unwrap();
566    }
567
568    #[test]
569    fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
570        let unique = SystemTime::now()
571            .duration_since(UNIX_EPOCH)
572            .unwrap()
573            .as_nanos();
574        let directory = std::env::current_dir()
575            .unwrap()
576            .join(".work/cache-v2-tests")
577            .join(format!("malformed-{}-{unique}", std::process::id()));
578        std::fs::create_dir_all(&directory).unwrap();
579        let canonical = directory.join("workspace-v2.toml");
580        let legacy = directory.join("workspace.toml");
581        std::fs::write(&canonical, "active_group = [\n").unwrap();
582        std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
583
584        let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
585
586        assert_eq!(cache.tree_selected, 0);
587        assert_eq!(
588            std::fs::read_to_string(&canonical).unwrap(),
589            "active_group = [\n"
590        );
591        std::fs::remove_dir_all(directory).unwrap();
592    }
593
594    #[test]
595    fn cursor_identity_round_trips_through_stable_terminal_id() {
596        use crate::{
597            model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
598            runtime::{AgentState, PaneId, SessionId, TerminalId},
599        };
600        let workspace = WorkspaceState {
601            projects: vec![Project {
602                name: "repo".into(),
603                path: "/repo".into(),
604                default_branch: "main".into(),
605                last_agent_active_unix_ms: None,
606                last_terminal_active_unix_ms: None,
607                worktrees: vec![WorktreeInfo {
608                    name: "main".into(),
609                    branch: "main".into(),
610                    path: "/repo".into(),
611                    is_main: true,
612                    alias: None,
613                    sessions: vec![SessionInfo {
614                        session_id: SessionId(1),
615                        pane_id: PaneId(1),
616                        terminal_id: TerminalId(1),
617                        agent: None,
618                        display_name: "agent".into(),
619                        agent_status: AgentState::Working,
620                        revision: 1,
621                        layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
622                        panes: vec![],
623                        muted: false,
624                        outcome_acknowledged: false,
625                    }],
626                    expanded: true,
627                    git_info: None,
628                    fetch_failed: false,
629                    fetch_fail_count: 0,
630                    fetch_fail_reason: None,
631                    last_fetched: None,
632                    git_info_fetched_at: None,
633                }],
634                routines: vec![],
635                routine_revision: 0,
636                routines_expanded: true,
637                config: None,
638                expanded: true,
639                missing: false,
640            }],
641        };
642        let flat = flatten_tree(&workspace);
643        let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
644        assert_eq!(
645            identity,
646            CursorIdentity::Session {
647                worktree_path: "/repo".into(),
648                terminal_id: Some("1".into()),
649                pane_id: None,
650            }
651        );
652        assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
653    }
654}