Skip to main content

wsx_core/
cache.rs

1// Startup cache — persists last known sessions + expand state.
2// Loaded before first refresh_all() so the tree is populated immediately.
3
4use std::collections::{HashMap, HashSet};
5use std::io::Write;
6use std::path::PathBuf;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::model::workspace::{
10    session_display_name_from_tmux, FlatEntry, ForegroundKind, SessionInfo, WorkspaceState,
11};
12use serde::{Deserialize, Serialize};
13
14/// Stable cursor identity — survives session appear/disappear and expand-state changes.
15#[derive(Serialize, Deserialize, Clone)]
16pub enum CursorIdentity {
17    Project {
18        path: String,
19    },
20    Worktree {
21        path: String,
22    },
23    Session {
24        worktree_path: String,
25        session_name: String,
26    },
27}
28
29#[derive(Serialize, Deserialize, Default, Clone)]
30pub struct WorkspaceCache {
31    /// Write timestamp used to choose the newest source after a crash.
32    #[serde(default)]
33    pub written_at_unix_ms: Option<u64>,
34    /// worktree path → session names
35    pub sessions: HashMap<String, Vec<String>>,
36    /// worktree path → expanded
37    pub worktree_expanded: HashMap<String, bool>,
38    /// project path → expanded
39    pub project_expanded: HashMap<String, bool>,
40    /// last cursor position in the flat tree (raw fallback)
41    pub tree_selected: usize,
42    /// stable cursor identity (preferred over raw index)
43    #[serde(default)]
44    pub cursor_identity: Option<CursorIdentity>,
45    /// session names the user has muted (no activity updates, shown as ⊘)
46    #[serde(default)]
47    pub muted_sessions: HashSet<String>,
48    /// global send-command history (Shift+S), newest last, capped at 50
49    #[serde(default)]
50    pub command_history: Vec<String>,
51    /// last active tab name (None = default tab)
52    #[serde(default)]
53    pub active_tab: Option<String>,
54    /// tmux server PID at last save — used to detect server restart on next launch
55    #[serde(default)]
56    pub tmux_server_pid: Option<u32>,
57}
58
59impl WorkspaceCache {
60    pub fn load() -> Self {
61        let Ok(content) = std::fs::read_to_string(cache_path()) else {
62            return Self::default();
63        };
64        toml::from_str(&content).unwrap_or_default()
65    }
66
67    pub fn save(&self, sync: bool) -> anyhow::Result<()> {
68        let mut cache = self.clone();
69        cache.written_at_unix_ms = Some(now_unix_ms());
70        let path = cache_path();
71        if let Some(dir) = path.parent() {
72            std::fs::create_dir_all(dir)?;
73        }
74        let s = toml::to_string(&cache)?;
75        // Atomic write: temp file + rename so a crash mid-write leaves the original intact.
76        let tmp = path.with_extension("toml.tmp");
77        let mut f = std::fs::File::create(&tmp)?;
78        f.write_all(s.as_bytes())?;
79        if sync {
80            f.sync_all()?;
81        }
82        drop(f);
83        std::fs::rename(&tmp, &path)?;
84        Ok(())
85    }
86}
87
88/// Atomic write: write to a `.toml.tmp` sibling then rename over the target.
89fn write_atomic(path: &std::path::Path, content: &[u8], sync: bool) -> std::io::Result<()> {
90    let tmp = path.with_extension("toml.tmp");
91    let mut f = std::fs::File::create(&tmp)?;
92    f.write_all(content)?;
93    if sync {
94        f.sync_all()?;
95    }
96    drop(f);
97    std::fs::rename(&tmp, path)
98}
99
100fn now_unix_ms() -> u64 {
101    SystemTime::now()
102        .duration_since(UNIX_EPOCH)
103        .unwrap_or_default()
104        .as_millis()
105        .try_into()
106        .unwrap_or(u64::MAX)
107}
108
109fn cache_path() -> PathBuf {
110    dirs::cache_dir()
111        .unwrap_or_else(|| PathBuf::from("/tmp"))
112        .join("wsx")
113        .join("workspace.toml")
114}
115
116fn session_snapshot_path() -> Option<PathBuf> {
117    let base = dirs::config_dir().or_else(|| dirs::home_dir().map(|h| h.join(".config")))?;
118    Some(base.join("wsx").join("sessions.toml"))
119}
120
121/// Collect session names per worktree path — shared by snapshot write and restore fallback.
122pub fn collect_session_names(workspace: &WorkspaceState) -> HashMap<String, Vec<String>> {
123    let mut map = HashMap::new();
124    for project in &workspace.projects {
125        for wt in &project.worktrees {
126            let names = wt.session_names();
127            if !names.is_empty() {
128                map.insert(wt.path.to_string_lossy().into_owned(), names);
129            }
130        }
131    }
132    map
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct SessionSnapshot {
137    pub sessions: HashMap<String, Vec<String>>,
138    pub written_at_unix_ms: Option<u64>,
139}
140
141#[derive(Serialize, Deserialize)]
142struct PersistedSessionSnapshot {
143    version: u32,
144    written_at_unix_ms: u64,
145    #[serde(default)]
146    tmux_server_pid: Option<u32>,
147    sessions: HashMap<String, Vec<String>>,
148}
149
150/// Persist session names to Application Support — survives tmux crashes because
151/// it's outside the cache and written whenever sessions change.
152pub fn save_session_snapshot(workspace: &WorkspaceState, sync: bool) {
153    let Some(path) = session_snapshot_path() else {
154        return;
155    };
156    save_snapshot_to(workspace, &path, sync);
157}
158
159pub(crate) fn save_snapshot_to(workspace: &WorkspaceState, path: &std::path::Path, sync: bool) {
160    let map = collect_session_names(workspace);
161    let snapshot = PersistedSessionSnapshot {
162        version: 1,
163        written_at_unix_ms: now_unix_ms(),
164        tmux_server_pid: crate::tmux::session::server_pid(),
165        sessions: map,
166    };
167    let Ok(s) = toml::to_string(&snapshot) else {
168        return;
169    };
170    if let Some(dir) = path.parent() {
171        if std::fs::create_dir_all(dir).is_err() {
172            return;
173        }
174    }
175    let _ = write_atomic(path, s.as_bytes(), sync);
176}
177
178/// Load the session snapshot written by `save_session_snapshot`.
179pub fn load_session_snapshot() -> HashMap<String, Vec<String>> {
180    load_session_snapshot_with_meta().sessions
181}
182
183pub fn load_session_snapshot_with_meta() -> SessionSnapshot {
184    let Some(path) = session_snapshot_path() else {
185        return SessionSnapshot::default();
186    };
187    load_snapshot_from(&path)
188}
189
190pub(crate) fn load_snapshot_from(path: &std::path::Path) -> SessionSnapshot {
191    let Ok(content) = std::fs::read_to_string(path) else {
192        return SessionSnapshot::default();
193    };
194    if let Ok(snapshot) = toml::from_str::<PersistedSessionSnapshot>(&content) {
195        return SessionSnapshot {
196            sessions: snapshot.sessions,
197            written_at_unix_ms: Some(snapshot.written_at_unix_ms),
198        };
199    }
200    let sessions = toml::from_str(&content).unwrap_or_default();
201    SessionSnapshot {
202        sessions,
203        written_at_unix_ms: None,
204    }
205}
206
207/// Return type for `apply_cache`. Last two fields are for one-time tmux flag migration.
208#[allow(clippy::type_complexity)]
209type CacheResult = (
210    usize,
211    Option<CursorIdentity>,
212    Vec<String>,
213    Option<String>,
214    Option<u32>,
215    HashSet<String>,
216);
217
218/// Pre-populate workspace with cached state before first live sync.
219pub fn apply_cache(workspace: &mut WorkspaceState) -> CacheResult {
220    let cache = WorkspaceCache::load();
221    for project in &mut workspace.projects {
222        let proj_key = project.path.to_string_lossy().to_string();
223        let cached = cache.project_expanded.get(&proj_key).copied();
224        if let Some(expanded) = cached {
225            project.expanded = expanded;
226        }
227        for wt in &mut project.worktrees {
228            let key = wt.path.to_string_lossy().to_string();
229            if let Some(&expanded) = cache.worktree_expanded.get(&key) {
230                wt.expanded = expanded;
231            }
232            if let Some(names) = cache.sessions.get(&key) {
233                wt.sessions = names
234                    .iter()
235                    .map(|name| {
236                        let display_name = session_display_name_from_tmux(
237                            name,
238                            &project.name,
239                            &wt.path,
240                            &wt.branch,
241                            wt.alias.as_deref(),
242                        );
243                        SessionInfo {
244                            name: name.clone(),
245                            display_name,
246                            has_activity: false,
247                            pane_capture: None,
248                            last_activity: None,
249                            foreground: ForegroundKind::Unknown,
250                            is_running_wsx: false,
251                            muted: cache.muted_sessions.contains(name),
252                        }
253                    })
254                    .collect();
255            }
256        }
257    }
258    (
259        cache.tree_selected,
260        cache.cursor_identity,
261        cache.command_history,
262        cache.active_tab,
263        cache.tmux_server_pid,
264        cache.muted_sessions,
265    )
266}
267
268/// Resolve a saved CursorIdentity back to a flat-tree index.
269pub fn find_cursor_index(
270    workspace: &WorkspaceState,
271    flat: &[FlatEntry],
272    id: &CursorIdentity,
273) -> Option<usize> {
274    match id {
275        CursorIdentity::Project { path } => flat.iter().position(|e| {
276            if let FlatEntry::Project { idx } = e {
277                workspace.projects[*idx].path.to_string_lossy() == path.as_str()
278            } else {
279                false
280            }
281        }),
282        CursorIdentity::Worktree { path } => flat.iter().position(|e| {
283            if let FlatEntry::Worktree {
284                project_idx: pi,
285                worktree_idx: wi,
286            } = e
287            {
288                workspace.projects[*pi].worktrees[*wi]
289                    .path
290                    .to_string_lossy()
291                    == path.as_str()
292            } else {
293                false
294            }
295        }),
296        CursorIdentity::Session {
297            worktree_path,
298            session_name,
299        } => flat.iter().position(|e| {
300            if let FlatEntry::Session {
301                project_idx: pi,
302                worktree_idx: wi,
303                session_idx: si,
304            } = e
305            {
306                let wt = &workspace.projects[*pi].worktrees[*wi];
307                wt.path.to_string_lossy() == worktree_path.as_str()
308                    && wt.sessions[*si].name == *session_name
309            } else {
310                false
311            }
312        }),
313    }
314}
315
316/// Persist session names, expand states, cursor position, active_tab, and command history.
317/// Returns an error string if the save fails (caller should surface it in TUI).
318pub fn save_cache(
319    workspace: &WorkspaceState,
320    tree_selected: usize,
321    flat: &[FlatEntry],
322    command_history: &[String],
323    active_tab: Option<&str>,
324    sync: bool,
325) -> Option<String> {
326    let mut cache = WorkspaceCache {
327        written_at_unix_ms: Some(now_unix_ms()),
328        tree_selected,
329        cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
330        command_history: command_history.to_vec(),
331        active_tab: active_tab.map(|s| s.to_string()),
332        tmux_server_pid: crate::tmux::session::server_pid(),
333        ..Default::default()
334    };
335    for project in &workspace.projects {
336        let proj_key = project.path.to_string_lossy().to_string();
337        cache.project_expanded.insert(proj_key, project.expanded);
338        for wt in &project.worktrees {
339            let key = wt.path.to_string_lossy().to_string();
340            cache.sessions.insert(
341                key.clone(),
342                wt.sessions.iter().map(|s| s.name.clone()).collect(),
343            );
344            cache.worktree_expanded.insert(key, wt.expanded);
345            // ^ muted_sessions intentionally omitted: stored as @wsx-muted tmux user option
346            // so all instances share it without cache coordination.
347        }
348    }
349    cache
350        .save(sync)
351        .err()
352        .map(|e| format!("cache save failed: {e}"))
353}
354
355/// One-time migration: write cached muted session names as tmux user options so they
356/// survive future cache writes and are visible to all instances. Idempotent and non-fatal.
357pub fn migrate_flags_to_tmux(muted: &HashSet<String>) {
358    use crate::tmux::session::{set_session_opt, OPT_MUTED};
359    for name in muted {
360        set_session_opt(name, OPT_MUTED, "1");
361    }
362}
363
364fn resolve_cursor_identity(
365    workspace: &WorkspaceState,
366    flat: &[FlatEntry],
367    idx: usize,
368) -> Option<CursorIdentity> {
369    match flat.get(idx)? {
370        FlatEntry::Project { idx: pi } => Some(CursorIdentity::Project {
371            path: workspace.projects[*pi].path.to_string_lossy().to_string(),
372        }),
373        FlatEntry::Worktree {
374            project_idx: pi,
375            worktree_idx: wi,
376        } => {
377            let wt = &workspace.projects[*pi].worktrees[*wi];
378            Some(CursorIdentity::Worktree {
379                path: wt.path.to_string_lossy().to_string(),
380            })
381        }
382        FlatEntry::Session {
383            project_idx: pi,
384            worktree_idx: wi,
385            session_idx: si,
386        } => {
387            let wt = &workspace.projects[*pi].worktrees[*wi];
388            Some(CursorIdentity::Session {
389                worktree_path: wt.path.to_string_lossy().to_string(),
390                session_name: wt.sessions[*si].name.clone(),
391            })
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::model::workspace::{Project, SessionInfo, WorktreeInfo};
400
401    fn make_session(name: &str) -> SessionInfo {
402        SessionInfo {
403            name: name.into(),
404            display_name: name.into(),
405            has_activity: false,
406            pane_capture: None,
407            last_activity: None,
408            foreground: ForegroundKind::Unknown,
409            is_running_wsx: false,
410            muted: false,
411        }
412    }
413
414    fn make_worktree(path: &str, sessions: &[&str]) -> WorktreeInfo {
415        WorktreeInfo {
416            name: "main".into(),
417            branch: "main".into(),
418            path: std::path::PathBuf::from(path),
419            is_main: true,
420            alias: None,
421            sessions: sessions.iter().map(|s| make_session(s)).collect(),
422            expanded: true,
423            git_info: None,
424            fetch_failed: false,
425            fetch_fail_count: 0,
426            fetch_fail_reason: None,
427            last_fetched: None,
428            git_info_fetched_at: None,
429        }
430    }
431
432    fn make_workspace(worktrees: &[(&str, &[&str])]) -> WorkspaceState {
433        WorkspaceState {
434            projects: vec![Project {
435                name: "test".into(),
436                path: std::path::PathBuf::from("/tmp/test"),
437                default_branch: "main".into(),
438                worktrees: worktrees
439                    .iter()
440                    .map(|(path, sessions)| make_worktree(path, sessions))
441                    .collect(),
442                config: None,
443                expanded: true,
444                missing: false,
445            }],
446        }
447    }
448
449    // ── collect_session_names (regression + new) ─────────────────────────────
450
451    #[test]
452    fn collect_session_names_maps_by_path() {
453        let ws = make_workspace(&[("/tmp/proj", &["proj-main-claude", "proj-main-shell"])]);
454        let map = collect_session_names(&ws);
455        assert_eq!(
456            map["/tmp/proj"],
457            vec!["proj-main-claude", "proj-main-shell"]
458        );
459    }
460
461    #[test]
462    fn collect_session_names_skips_empty_worktrees() {
463        let ws = make_workspace(&[("/tmp/proj-a", &["proj-a-claude"]), ("/tmp/proj-b", &[])]);
464        let map = collect_session_names(&ws);
465        assert!(map.contains_key("/tmp/proj-a"));
466        assert!(!map.contains_key("/tmp/proj-b"));
467    }
468
469    #[test]
470    fn collect_session_names_empty_workspace_returns_empty_map() {
471        let ws = make_workspace(&[]);
472        assert!(collect_session_names(&ws).is_empty());
473    }
474
475    // ── snapshot roundtrip ───────────────────────────────────────────────────
476
477    #[test]
478    fn snapshot_roundtrip_via_path() {
479        let dir = std::env::temp_dir().join("wsx_test_snapshot_roundtrip");
480        std::fs::create_dir_all(&dir).unwrap();
481        let path = dir.join("sessions.toml");
482
483        let ws = make_workspace(&[
484            ("/tmp/proj-a", &["proj-a-claude"]),
485            ("/tmp/proj-b", &["proj-b-shell", "proj-b-build"]),
486        ]);
487
488        save_snapshot_to(&ws, &path, true);
489        let loaded = load_snapshot_from(&path);
490
491        assert_eq!(loaded.sessions["/tmp/proj-a"], vec!["proj-a-claude"]);
492        assert_eq!(
493            loaded.sessions["/tmp/proj-b"],
494            vec!["proj-b-shell", "proj-b-build"]
495        );
496        assert!(loaded.written_at_unix_ms.is_some());
497
498        std::fs::remove_dir_all(&dir).ok();
499    }
500
501    #[test]
502    fn snapshot_load_missing_file_returns_empty() {
503        let path = std::path::Path::new("/tmp/wsx_nonexistent_snapshot.toml");
504        assert!(load_snapshot_from(path).sessions.is_empty());
505    }
506
507    #[test]
508    fn snapshot_empty_workspace_writes_and_loads_empty() {
509        let dir = std::env::temp_dir().join("wsx_test_snapshot_empty");
510        std::fs::create_dir_all(&dir).unwrap();
511        let path = dir.join("sessions.toml");
512
513        let ws = make_workspace(&[]);
514        save_snapshot_to(&ws, &path, true);
515        assert!(load_snapshot_from(&path).sessions.is_empty());
516
517        std::fs::remove_dir_all(&dir).ok();
518    }
519
520    #[test]
521    fn snapshot_loads_legacy_bare_session_map() {
522        let dir = std::env::temp_dir().join("wsx_test_snapshot_legacy");
523        std::fs::create_dir_all(&dir).unwrap();
524        let path = dir.join("sessions.toml");
525        std::fs::write(&path, "\"/tmp/proj\" = [\"proj-main-claude\"]\n").unwrap();
526
527        let loaded = load_snapshot_from(&path);
528
529        assert_eq!(loaded.sessions["/tmp/proj"], vec!["proj-main-claude"]);
530        assert_eq!(loaded.written_at_unix_ms, None);
531
532        std::fs::remove_dir_all(&dir).ok();
533    }
534}