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}
28
29#[derive(Serialize, Deserialize, Default, Clone)]
30pub struct WorkspaceCache {
31 #[serde(default)]
33 pub written_at_unix_ms: Option<u64>,
34 pub sessions: HashMap<String, Vec<String>>,
36 pub worktree_expanded: HashMap<String, bool>,
38 pub project_expanded: HashMap<String, bool>,
40 pub tree_selected: usize,
42 #[serde(default)]
44 pub cursor_identity: Option<CursorIdentity>,
45 #[serde(default)]
47 pub muted_sessions: HashSet<String>,
48 #[serde(default)]
50 pub command_history: Vec<String>,
51 #[serde(default)]
53 pub active_tab: Option<String>,
54 #[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 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
88fn 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
121pub 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
150pub 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
178pub 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#[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
218pub 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
268pub 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
316pub 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 }
348 }
349 cache
350 .save(sync)
351 .err()
352 .map(|e| format!("cache save failed: {e}"))
353}
354
355pub 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 #[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 #[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}