1use 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#[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 #[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 #[serde(default)]
56 pub muted_terminals: HashSet<String>,
57 #[serde(default)]
59 pub acknowledged_outcomes: HashMap<String, u64>,
60 #[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 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
217pub 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}