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 routines_expanded: HashMap<String, bool>,
52 #[serde(default)]
53 pub tree_selected: usize,
54 #[serde(default)]
55 pub cursor_identity: Option<CursorIdentity>,
56 #[serde(default)]
58 pub muted_terminals: HashSet<String>,
59 #[serde(default)]
61 pub acknowledged_outcomes: HashMap<String, u64>,
62 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
64 pub dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
65 #[serde(skip)]
66 migration_needed: bool,
67}
68
69#[derive(Deserialize, Default)]
70#[serde(default)]
71struct WorkspaceCacheWire {
72 written_at_unix_ms: Option<u64>,
73 worktree_expanded: HashMap<String, bool>,
74 project_expanded: HashMap<String, bool>,
75 routines_expanded: HashMap<String, bool>,
76 tree_selected: usize,
77 cursor_identity: Option<CursorIdentity>,
78 #[serde(alias = "muted_sessions")]
79 muted_terminals: HashSet<String>,
80 acknowledged_outcomes: HashMap<String, u64>,
81 active_group: Option<toml::Value>,
82 active_groups: Option<toml::Value>,
83 active_tab: Option<toml::Value>,
84 integration_prompt_version: Option<String>,
85 dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
86}
87
88impl<'de> Deserialize<'de> for WorkspaceCache {
89 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
90 where
91 D: Deserializer<'de>,
92 {
93 let wire = WorkspaceCacheWire::deserialize(deserializer)?;
94 let migration_needed = wire.active_group.is_some()
97 || wire.active_groups.is_some()
98 || wire.active_tab.is_some();
99 Ok(Self {
100 written_at_unix_ms: wire.written_at_unix_ms,
101 worktree_expanded: wire.worktree_expanded,
102 project_expanded: wire.project_expanded,
103 routines_expanded: wire.routines_expanded,
104 tree_selected: wire.tree_selected,
105 cursor_identity: wire.cursor_identity,
106 muted_terminals: wire.muted_terminals,
107 acknowledged_outcomes: wire.acknowledged_outcomes,
108 dismissed_integration_prompts: wire.dismissed_integration_prompts,
109 migration_needed: migration_needed || wire.integration_prompt_version.is_some(),
110 })
111 }
112}
113
114impl WorkspaceCache {
115 pub fn load() -> anyhow::Result<Self> {
116 Self::load_from_paths(&cache_path(), &legacy_cache_path())
117 }
118
119 fn load_from_paths(
120 canonical: &std::path::Path,
121 legacy: &std::path::Path,
122 ) -> anyhow::Result<Self> {
123 let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
124 Ok(content) => (content, false),
125 Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
126 Ok(content) => (content, true),
127 Err(_) => return Ok(Self::default()),
128 },
129 Err(_) => return Ok(Self::default()),
130 };
131 let mut cache: Self = toml::from_str(&content).unwrap_or_default();
132 if imported_legacy || cache.migration_needed {
133 cache.save_to(canonical, false)?;
134 cache.migration_needed = false;
135 }
136 Ok(cache)
137 }
138
139 pub fn save(&self, sync: bool) -> anyhow::Result<()> {
140 self.save_to(&cache_path(), sync)
141 }
142
143 fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
144 let mut cache = self.clone();
145 cache.written_at_unix_ms = Some(now_unix_ms());
146 let text = toml::to_string(&cache)?;
147 atomic_write_private(path, text.as_bytes(), sync)?;
148 Ok(())
149 }
150}
151
152fn now_unix_ms() -> u64 {
153 SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap_or_default()
156 .as_millis()
157 .try_into()
158 .unwrap_or(u64::MAX)
159}
160
161fn cache_path() -> PathBuf {
162 dirs::cache_dir()
163 .unwrap_or_else(|| PathBuf::from("/tmp"))
164 .join("wsx")
165 .join("workspace-v2.toml")
166}
167
168fn legacy_cache_path() -> PathBuf {
169 dirs::cache_dir()
170 .unwrap_or_else(|| PathBuf::from("/tmp"))
171 .join("wsx")
172 .join("workspace.toml")
173}
174
175#[derive(Serialize, Deserialize)]
176struct GroupSelection {
177 selected: GroupKey,
178}
179
180fn group_selection_path() -> PathBuf {
181 dirs::cache_dir()
182 .unwrap_or_else(|| PathBuf::from("/tmp"))
183 .join("wsx")
184 .join("group-selection-v1.toml")
185}
186
187pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
188 load_group_selection_from(&group_selection_path())
189}
190
191fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
192 let content = match std::fs::read_to_string(path) {
193 Ok(content) => content,
194 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
195 Err(error) => return Err(error.into()),
196 };
197 Ok(toml::from_str::<GroupSelection>(&content)
198 .ok()
199 .map(|selection| selection.selected))
200}
201
202pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
203 save_group_selection_to(&group_selection_path(), selected)
204}
205
206fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
207 let text = toml::to_string(&GroupSelection {
208 selected: selected.clone(),
209 })?;
210 atomic_write_private(path, text.as_bytes(), true)?;
211 Ok(())
212}
213
214pub type AppliedCache = (
215 usize,
216 Option<CursorIdentity>,
217 HashSet<String>,
218 HashMap<String, u64>,
219 HashSet<crate::integration::IntegrationTarget>,
220);
221
222pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
224 let cache = WorkspaceCache::load()?;
225 let mut migrated_muted_terminals = HashSet::new();
226 for project in &mut workspace.projects {
227 let project_key = project.path.to_string_lossy().to_string();
228 if let Some(expanded) = cache.project_expanded.get(&project_key) {
229 project.expanded = *expanded;
230 }
231 if let Some(expanded) = cache.routines_expanded.get(&project_key) {
232 project.routines_expanded = *expanded;
233 }
234 for worktree in &mut project.worktrees {
235 let key = worktree.path.to_string_lossy().to_string();
236 if let Some(expanded) = cache.worktree_expanded.get(&key) {
237 worktree.expanded = *expanded;
238 }
239 for session in &mut worktree.sessions {
240 session.muted = cache
241 .muted_terminals
242 .contains(&session.terminal_id.to_string())
243 || cache.muted_terminals.contains(&session.pane_id.to_string());
244 if session.muted {
245 migrated_muted_terminals.insert(session.terminal_id.to_string());
246 }
247 for pane in &mut session.panes {
248 pane.outcome_acknowledged = pane.agent_status
249 == crate::runtime::AgentState::Done
250 && cache
251 .acknowledged_outcomes
252 .get(&pane.terminal_id.to_string())
253 == Some(&pane.revision);
254 }
255 session.outcome_acknowledged = session
256 .panes
257 .iter()
258 .find(|pane| pane.pane_id == session.pane_id)
259 .is_some_and(|pane| pane.outcome_acknowledged);
260 }
261 }
262 }
263 Ok((
264 cache.tree_selected,
265 cache.cursor_identity,
266 migrated_muted_terminals,
267 cache.acknowledged_outcomes,
268 cache.dismissed_integration_prompts,
269 ))
270}
271
272pub fn find_cursor_index(
273 workspace: &WorkspaceState,
274 flat: &[FlatEntry],
275 id: &CursorIdentity,
276) -> Option<usize> {
277 match id {
278 CursorIdentity::Project { path } => flat.iter().position(|entry| {
279 matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
280 }),
281 CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
282 matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
283 }),
284 CursorIdentity::Session {
285 worktree_path,
286 terminal_id,
287 pane_id,
288 } => flat.iter().position(|entry| {
289 let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
290 FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
291 (*project_idx, *worktree_idx, *session_idx, None)
292 }
293 FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
294 (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
295 }
296 _ => return false,
297 };
298 let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
299 let session = &wt.sessions[session_idx];
300 let (terminal, pane) = pane_idx
301 .and_then(|idx| session.panes.get(idx))
302 .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
303 wt.path.to_string_lossy() == worktree_path.as_str()
304 && terminal_id
305 .as_ref()
306 .map(|id| terminal.to_string() == *id)
307 .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
308 .unwrap_or(false)
309 }),
310 CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
311 matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
312 }),
313 CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
314 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)
315 }),
316 }
317}
318
319pub fn save_cache(
320 workspace: &WorkspaceState,
321 tree_selected: usize,
322 flat: &[FlatEntry],
323 dismissed_integration_prompts: &HashSet<crate::integration::IntegrationTarget>,
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 dismissed_integration_prompts: dismissed_integration_prompts.clone(),
331 ..Default::default()
332 };
333 for project in &workspace.projects {
334 let project_path = project.path.to_string_lossy().into_owned();
335 cache
336 .project_expanded
337 .insert(project_path.clone(), project.expanded);
338 cache
339 .routines_expanded
340 .insert(project_path, project.routines_expanded);
341 for worktree in &project.worktrees {
342 cache.worktree_expanded.insert(
343 worktree.path.to_string_lossy().into_owned(),
344 worktree.expanded,
345 );
346 cache.muted_terminals.extend(
347 worktree
348 .sessions
349 .iter()
350 .filter(|s| s.muted)
351 .map(|s| s.terminal_id.to_string()),
352 );
353 for session in &worktree.sessions {
354 for pane in &session.panes {
355 if pane.outcome_acknowledged {
356 cache
357 .acknowledged_outcomes
358 .insert(pane.terminal_id.to_string(), pane.revision);
359 }
360 }
361 }
362 }
363 }
364 cache
365 .save(sync)
366 .err()
367 .map(|e| format!("cache save failed: {e}"))
368}
369
370pub fn resolve_cursor_identity(
371 workspace: &WorkspaceState,
372 flat: &[FlatEntry],
373 idx: usize,
374) -> Option<CursorIdentity> {
375 match flat.get(idx)? {
376 FlatEntry::Project { idx } => Some(CursorIdentity::Project {
377 path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
378 }),
379 FlatEntry::Worktree {
380 project_idx,
381 worktree_idx,
382 } => Some(CursorIdentity::Worktree {
383 path: workspace.projects[*project_idx].worktrees[*worktree_idx]
384 .path
385 .to_string_lossy()
386 .into_owned(),
387 }),
388 FlatEntry::Session {
389 project_idx,
390 worktree_idx,
391 session_idx,
392 } => {
393 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
394 Some(CursorIdentity::Session {
395 worktree_path: wt.path.to_string_lossy().into_owned(),
396 terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
397 pane_id: None,
398 })
399 }
400 FlatEntry::Pane {
401 project_idx,
402 worktree_idx,
403 session_idx,
404 pane_idx,
405 } => {
406 let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
407 Some(CursorIdentity::Session {
408 worktree_path: wt.path.to_string_lossy().into_owned(),
409 terminal_id: Some(
410 wt.sessions[*session_idx].panes[*pane_idx]
411 .terminal_id
412 .to_string(),
413 ),
414 pane_id: None,
415 })
416 }
417 FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
418 project_path: workspace.projects[*project_idx]
419 .path
420 .to_string_lossy()
421 .into_owned(),
422 }),
423 FlatEntry::Routine {
424 project_idx,
425 routine_idx,
426 } => Some(CursorIdentity::Routine {
427 project_path: workspace.projects[*project_idx]
428 .path
429 .to_string_lossy()
430 .into_owned(),
431 routine_name: workspace.projects[*project_idx].routines[*routine_idx]
432 .routine
433 .name
434 .clone(),
435 }),
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn legacy_cache_defaults_missing_dismissed_integration_prompts() {
445 let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
446 assert!(cache.dismissed_integration_prompts.is_empty());
447 }
448
449 #[test]
450 fn dismissed_integration_prompts_round_trip_per_agent() {
451 let cache = WorkspaceCache {
452 dismissed_integration_prompts: [crate::integration::IntegrationTarget::Pi]
453 .into_iter()
454 .collect(),
455 ..Default::default()
456 };
457 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
458 assert_eq!(
459 decoded.dismissed_integration_prompts,
460 cache.dismissed_integration_prompts
461 );
462 }
463
464 #[test]
465 fn legacy_blanket_dismissal_is_migrated_away() {
466 let cache: WorkspaceCache =
467 toml::from_str("tree_selected = 2\nintegration_prompt_version = \"0.21.0\"\n").unwrap();
468 assert!(cache.dismissed_integration_prompts.is_empty());
469 assert!(cache.migration_needed);
470 }
471
472 #[test]
473 fn expansion_maps_round_trip_by_stable_path() {
474 let cache = WorkspaceCache {
475 project_expanded: HashMap::from([("/projects/app".into(), true)]),
476 worktree_expanded: HashMap::from([("/projects/app/feature".into(), false)]),
477 routines_expanded: HashMap::from([("/projects/app".into(), false)]),
478 ..Default::default()
479 };
480
481 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
482
483 assert_eq!(decoded.project_expanded, cache.project_expanded);
484 assert_eq!(decoded.worktree_expanded, cache.worktree_expanded);
485 assert_eq!(decoded.routines_expanded, cache.routines_expanded);
486 }
487
488 #[test]
489 fn legacy_cache_defaults_missing_routines_expanded_map() {
490 let cache: WorkspaceCache = toml::from_str(
491 r#"[project_expanded]
492"/projects/app" = true
493
494[worktree_expanded]
495"/projects/app/main" = false
496"#,
497 )
498 .unwrap();
499
500 assert!(cache.routines_expanded.is_empty());
501 }
502
503 #[test]
504 fn acknowledged_outcome_revisions_round_trip() {
505 let cache = WorkspaceCache {
506 acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
507 ..Default::default()
508 };
509
510 let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
511
512 assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
513 }
514
515 #[test]
516 fn legacy_tmux_and_session_fields_are_ignored() {
517 let cache: WorkspaceCache = toml::from_str(
518 r#"tmux_server_pid = 123
519sessions = { "/tmp/repo" = ["old-tmux-session"] }
520muted_sessions = ["pane-1"]
521"#,
522 )
523 .unwrap();
524 assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
525 }
526
527 #[test]
528 fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
529 let cache: WorkspaceCache = toml::from_str(
530 r#"[cursor_identity.Session]
531worktree_path = "/repo"
532pane_id = "pane-1"
533"#,
534 )
535 .unwrap();
536 assert_eq!(
537 cache.cursor_identity,
538 Some(CursorIdentity::Session {
539 worktree_path: "/repo".into(),
540 terminal_id: None,
541 pane_id: Some("pane-1".into()),
542 })
543 );
544 }
545
546 #[test]
547 fn historical_active_group_shapes_are_discarded_on_rewrite() {
548 for historical in [
549 "active_group = \"work\"\n",
550 "active_tab = \"work\"\n",
551 "active_groups = [\"work\", \"other\"]\n",
552 "active_groups = []\n",
553 ] {
554 let cache: WorkspaceCache = toml::from_str(historical).unwrap();
555 assert!(cache.migration_needed);
556 let encoded = toml::to_string(&cache).unwrap();
557 assert!(!encoded.contains("active_group"));
558 assert!(!encoded.contains("active_groups"));
559 assert!(!encoded.contains("active_tab"));
560 }
561 }
562
563 #[test]
564 fn group_selection_is_independent_and_malformed_data_defaults_absent() {
565 let unique = SystemTime::now()
566 .duration_since(UNIX_EPOCH)
567 .unwrap()
568 .as_nanos();
569 let directory = std::env::current_dir()
570 .unwrap()
571 .join(".work/group-selection-tests")
572 .join(format!("{}-{unique}", std::process::id()));
573 std::fs::create_dir_all(&directory).unwrap();
574 let path = directory.join("group-selection-v1.toml");
575
576 assert_eq!(load_group_selection_from(&path).unwrap(), None);
577 save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
578 assert_eq!(
579 load_group_selection_from(&path).unwrap(),
580 Some(GroupKey::Named("work".into()))
581 );
582 std::fs::write(&path, "selected = [\n").unwrap();
583 assert_eq!(load_group_selection_from(&path).unwrap(), None);
584 std::fs::remove_dir_all(directory).unwrap();
585 }
586
587 #[test]
588 fn workspace_cache_serialization_never_carries_group_selection() {
589 let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
590 assert!(!encoded.contains("selected_group"));
591 assert!(!encoded.contains("active_group"));
592 }
593
594 #[test]
595 fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
596 let unique = SystemTime::now()
597 .duration_since(UNIX_EPOCH)
598 .unwrap()
599 .as_nanos();
600 let directory = std::env::current_dir()
601 .unwrap()
602 .join(".work/cache-v2-tests")
603 .join(format!("{}-{unique}", std::process::id()));
604 std::fs::create_dir_all(&directory).unwrap();
605 let canonical = directory.join("workspace-v2.toml");
606 let legacy = directory.join("workspace.toml");
607 let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
608 std::fs::write(&legacy, legacy_text).unwrap();
609
610 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
611
612 assert_eq!(cache.tree_selected, 3);
613 assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
614 let canonical_text = std::fs::read_to_string(&canonical).unwrap();
615 assert!(!canonical_text.contains("active_group"));
616 assert!(!canonical_text.contains("active_tab"));
617 std::fs::remove_dir_all(directory).unwrap();
618 }
619
620 #[test]
621 fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
622 let unique = SystemTime::now()
623 .duration_since(UNIX_EPOCH)
624 .unwrap()
625 .as_nanos();
626 let directory = std::env::current_dir()
627 .unwrap()
628 .join(".work/cache-v2-tests")
629 .join(format!("malformed-{}-{unique}", std::process::id()));
630 std::fs::create_dir_all(&directory).unwrap();
631 let canonical = directory.join("workspace-v2.toml");
632 let legacy = directory.join("workspace.toml");
633 std::fs::write(&canonical, "active_group = [\n").unwrap();
634 std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
635
636 let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
637
638 assert_eq!(cache.tree_selected, 0);
639 assert_eq!(
640 std::fs::read_to_string(&canonical).unwrap(),
641 "active_group = [\n"
642 );
643 std::fs::remove_dir_all(directory).unwrap();
644 }
645
646 #[test]
647 fn cursor_identity_round_trips_through_stable_terminal_id() {
648 use crate::{
649 model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
650 runtime::{AgentState, PaneId, SessionId, TerminalId},
651 };
652 let workspace = WorkspaceState {
653 projects: vec![Project {
654 name: "repo".into(),
655 path: "/repo".into(),
656 default_branch: "main".into(),
657 last_agent_active_unix_ms: None,
658 last_terminal_active_unix_ms: None,
659 worktrees: vec![WorktreeInfo {
660 name: "main".into(),
661 branch: "main".into(),
662 path: "/repo".into(),
663 is_main: true,
664 alias: None,
665 sessions: vec![SessionInfo {
666 session_id: SessionId(1),
667 pane_id: PaneId(1),
668 terminal_id: TerminalId(1),
669 agent: None,
670 display_name: "agent".into(),
671 agent_status: AgentState::Working,
672 revision: 1,
673 layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
674 panes: vec![],
675 muted: false,
676 outcome_acknowledged: false,
677 }],
678 expanded: true,
679 git_info: None,
680 fetch_failed: false,
681 fetch_fail_count: 0,
682 fetch_fail_reason: None,
683 last_fetched: None,
684 git_info_fetched_at: None,
685 }],
686 routines: vec![],
687 routine_revision: 0,
688 routines_expanded: true,
689 config: None,
690 expanded: true,
691 missing: false,
692 }],
693 };
694 let flat = flatten_tree(&workspace);
695 let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
696 assert_eq!(
697 identity,
698 CursorIdentity::Session {
699 worktree_path: "/repo".into(),
700 terminal_id: Some("1".into()),
701 pane_id: None,
702 }
703 );
704 assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
705 }
706}