Skip to main content

wsx_core/
cache.rs

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