1use 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#[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 #[serde(default)]
40 pub written_at_unix_ms: Option<u64>,
41 pub sessions: HashMap<String, Vec<String>>,
43 pub worktree_expanded: HashMap<String, bool>,
45 pub project_expanded: HashMap<String, bool>,
47 pub tree_selected: usize,
49 #[serde(default)]
51 pub cursor_identity: Option<CursorIdentity>,
52 #[serde(default)]
54 pub muted_sessions: HashSet<String>,
55 #[serde(default)]
57 pub command_history: Vec<String>,
58 #[serde(default)]
60 pub active_tab: Option<String>,
61 #[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 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
95fn 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
128pub 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
157pub 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
185pub 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#[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
225pub 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
277pub 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
331pub 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 }
363 }
364 cache
365 .save(sync)
366 .err()
367 .map(|e| format!("cache save failed: {e}"))
368}
369
370pub 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 #[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 #[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}