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