Skip to main content

wsx_core/
ops.rs

1//! Git/project operations projected against the wsx-owned runtime.
2// ^ [[wsx Architecture]] Git owns worktree discovery; the daemon owns sessions and panes.
3
4use crate::{
5    config::global::GlobalConfig,
6    git::{info as git_info, worktree as git_worktree},
7    hooks,
8    model::workspace::{
9        FetchFailReason, GitInfo, PaneInfo, Project, ProjectConfig, SessionInfo, WorkspaceState,
10        WorktreeInfo, WorktreeInitialSession,
11    },
12    runtime::{
13        AgentState, Client, ProjectSpec, Request, Response, SessionId, SessionPlacement, Snapshot,
14        WorktreeId, WorktreeSpec,
15    },
16};
17use anyhow::{anyhow, bail, Result};
18use std::{
19    collections::{HashMap, HashSet},
20    path::{Path, PathBuf},
21};
22
23struct WorktreeState {
24    git_info: Option<GitInfo>,
25    git_info_fetched_at: Option<std::time::Instant>,
26    expanded: bool,
27    sessions: Vec<SessionInfo>,
28    last_fetched: Option<std::time::Instant>,
29    fetch_failed: bool,
30    fetch_fail_count: u32,
31    fetch_fail_reason: Option<FetchFailReason>,
32}
33
34#[derive(Debug, Clone)]
35struct DiscoveredProject {
36    name: String,
37    path: PathBuf,
38    worktrees: Vec<git_worktree::WorktreeEntry>,
39}
40
41#[derive(Debug, Clone)]
42pub struct WorkspaceDiscovery {
43    projects: Vec<DiscoveredProject>,
44}
45
46impl WorkspaceDiscovery {
47    pub fn into_worktrees(self) -> Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)> {
48        self.projects
49            .into_iter()
50            .map(|project| (project.path, project.worktrees))
51            .collect()
52    }
53}
54
55fn is_git_repo(path: &Path) -> bool {
56    path.exists() && path.join(".git").exists()
57}
58
59pub fn runtime_snapshot() -> Result<Snapshot> {
60    match Client::local().call(&Request::Snapshot)? {
61        Response::Snapshot(snapshot) => Ok(snapshot),
62        Response::Error(error) => bail!("{}: {}", error.code, error.message),
63        _ => bail!("wsx daemon returned an unexpected snapshot response"),
64    }
65}
66
67fn synchronize(client: &Client, projects: Vec<ProjectSpec>) -> Result<Snapshot> {
68    match client.call(&Request::SynchronizeProjects { projects })? {
69        Response::Ack { .. } => {}
70        Response::Error(error) => bail!("{}: {}", error.code, error.message),
71        _ => bail!("wsx daemon returned an unexpected synchronization response"),
72    }
73    match client.call(&Request::Snapshot)? {
74        Response::Snapshot(snapshot) => Ok(snapshot),
75        Response::Error(error) => bail!("{}: {}", error.code, error.message),
76        _ => bail!("wsx daemon returned an unexpected snapshot response"),
77    }
78}
79
80pub fn workspace_from_config(config: &GlobalConfig) -> WorkspaceState {
81    WorkspaceState {
82        projects: config
83            .projects
84            .iter()
85            .filter(|entry| is_git_repo(&entry.path))
86            .map(|entry| Project {
87                name: entry.name.clone(),
88                path: entry.path.clone(),
89                default_branch: "main".into(),
90                last_agent_active_unix_ms: None,
91                last_terminal_active_unix_ms: None,
92                worktrees: Vec::new(),
93                routines: Vec::new(),
94                routine_revision: 0,
95                routines_expanded: true,
96                config: Some(crate::config::project::load_project_config(&entry.path)),
97                expanded: true,
98                missing: false,
99            })
100            .collect(),
101    }
102}
103
104pub fn discover_workspace(config: &GlobalConfig) -> Result<WorkspaceDiscovery> {
105    discover_workspace_with(config, git_worktree::list_worktrees)
106}
107
108fn discover_workspace_with<F>(
109    config: &GlobalConfig,
110    mut list_worktrees: F,
111) -> Result<WorkspaceDiscovery>
112where
113    F: FnMut(&Path) -> Result<Vec<git_worktree::WorktreeEntry>>,
114{
115    let projects = config
116        .projects
117        .iter()
118        .filter(|entry| is_git_repo(&entry.path))
119        .map(|entry| {
120            let worktrees = list_worktrees(&entry.path)?;
121            Ok(DiscoveredProject {
122                name: entry.name.clone(),
123                path: entry.path.clone(),
124                worktrees,
125            })
126        })
127        .collect::<Result<Vec<_>>>()?;
128    Ok(WorkspaceDiscovery { projects })
129}
130
131pub fn synchronize_discovery(discovery: &WorkspaceDiscovery) -> Result<Snapshot> {
132    let projects = discovery
133        .projects
134        .iter()
135        .map(|project| ProjectSpec {
136            path: project.path.clone(),
137            name: project.name.clone(),
138            worktrees: project
139                .worktrees
140                .iter()
141                .map(|worktree| WorktreeSpec {
142                    path: worktree.path.clone(),
143                    branch: worktree.branch.clone(),
144                })
145                .collect(),
146        })
147        .collect();
148    synchronize(&Client::local(), projects)
149}
150
151fn apply_discovery(
152    workspace: &mut WorkspaceState,
153    config: &GlobalConfig,
154    snapshot: &Snapshot,
155    discovery: WorkspaceDiscovery,
156) -> Result<()> {
157    let worktrees = discovery
158        .projects
159        .into_iter()
160        .map(|project| (project.path, project.worktrees))
161        .collect();
162    refresh_workspace_with_worktrees(workspace, config, snapshot, worktrees)
163}
164
165pub fn load_full_workspace(config: &GlobalConfig) -> Result<WorkspaceState> {
166    let discovery = discover_workspace(config)?;
167    let snapshot = synchronize_discovery(&discovery)?;
168    let mut workspace = workspace_from_config(config);
169    apply_discovery(&mut workspace, config, &snapshot, discovery)?;
170    Ok(workspace)
171}
172
173pub fn refresh_workspace_discovery_only(
174    workspace: &mut WorkspaceState,
175    config: &GlobalConfig,
176    worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
177) -> Result<()> {
178    refresh_workspace_with_discovery(workspace, config, None, worktrees)
179}
180
181pub fn refresh_workspace_with_worktrees(
182    workspace: &mut WorkspaceState,
183    config: &GlobalConfig,
184    snapshot: &Snapshot,
185    worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
186) -> Result<()> {
187    refresh_workspace_with_discovery(workspace, config, Some(snapshot), worktrees)
188}
189
190fn refresh_workspace_with_discovery(
191    workspace: &mut WorkspaceState,
192    config: &GlobalConfig,
193    snapshot: Option<&Snapshot>,
194    worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
195) -> Result<()> {
196    let mut worktrees_map: HashMap<PathBuf, Vec<git_worktree::WorktreeEntry>> =
197        worktrees.into_iter().collect();
198    if let Some(snapshot) = snapshot {
199        update_project_activity(workspace, snapshot);
200    }
201    for project in &mut workspace.projects {
202        if let Some(default_branch) = worktrees_map
203            .get(&project.path)
204            .and_then(|entries| entries.iter().find(|entry| entry.is_main))
205            .filter(|entry| entry.branch != "HEAD")
206            .map(|entry| entry.branch.clone())
207        {
208            project.default_branch = default_branch;
209        }
210        let previous: HashMap<PathBuf, WorktreeState> = project
211            .worktrees
212            .iter()
213            .map(|worktree| {
214                (
215                    worktree.path.clone(),
216                    WorktreeState {
217                        git_info: worktree.git_info.clone(),
218                        git_info_fetched_at: worktree.git_info_fetched_at,
219                        expanded: worktree.expanded,
220                        sessions: worktree.sessions.clone(),
221                        last_fetched: worktree.last_fetched,
222                        fetch_failed: worktree.fetch_failed,
223                        fetch_fail_count: worktree.fetch_fail_count,
224                        fetch_fail_reason: worktree.fetch_fail_reason.clone(),
225                    },
226                )
227            })
228            .collect();
229        let aliases = config
230            .projects
231            .iter()
232            .find(|entry| entry.path == project.path)
233            .map(|entry| &entry.aliases);
234        let entries = worktrees_map.remove(&project.path).unwrap_or_default();
235        project.worktrees = entries
236            .into_iter()
237            .filter(|entry| !config.is_worktree_excluded(&entry.path))
238            .map(|entry| {
239                let old = previous.get(&entry.path);
240                Ok(WorktreeInfo {
241                    name: entry.name,
242                    branch: entry.branch.clone(),
243                    path: entry.path.clone(),
244                    is_main: entry.is_main,
245                    alias: aliases.and_then(|map| map.get(&entry.branch)).cloned(),
246                    sessions: match snapshot {
247                        Some(snapshot) => sessions_for_worktree(
248                            snapshot,
249                            &entry.path,
250                            old.map(|state| state.sessions.as_slice())
251                                .unwrap_or_default(),
252                        )?,
253                        None => old.map(|state| state.sessions.clone()).unwrap_or_default(),
254                    },
255                    expanded: old.map(|state| state.expanded).unwrap_or(true),
256                    git_info: old.and_then(|state| state.git_info.clone()),
257                    fetch_failed: old.map(|state| state.fetch_failed).unwrap_or(false),
258                    fetch_fail_count: old.map(|state| state.fetch_fail_count).unwrap_or(0),
259                    fetch_fail_reason: old.and_then(|state| state.fetch_fail_reason.clone()),
260                    last_fetched: old.and_then(|state| state.last_fetched),
261                    git_info_fetched_at: old.and_then(|state| state.git_info_fetched_at),
262                })
263            })
264            .collect::<Result<Vec<_>>>()?;
265    }
266    workspace.projects.retain(|project| !project.missing);
267    for project in &mut workspace.projects {
268        project.missing = !is_git_repo(&project.path);
269    }
270    Ok(())
271}
272
273pub fn refresh_sessions_from_snapshot(
274    workspace: &mut WorkspaceState,
275    snapshot: &Snapshot,
276) -> Result<()> {
277    update_project_activity(workspace, snapshot);
278    for worktree in workspace
279        .projects
280        .iter_mut()
281        .flat_map(|project| &mut project.worktrees)
282    {
283        worktree.sessions = sessions_for_worktree(snapshot, &worktree.path, &worktree.sessions)?;
284    }
285    Ok(())
286}
287
288fn update_project_activity(workspace: &mut WorkspaceState, snapshot: &Snapshot) {
289    for project in &mut workspace.projects {
290        let runtime_project = snapshot
291            .projects
292            .iter()
293            .find(|candidate| candidate.path == project.path)
294            .or_else(|| {
295                let project_id = snapshot.worktrees.iter().find_map(|runtime_worktree| {
296                    project
297                        .worktrees
298                        .iter()
299                        .any(|worktree| worktree.path == runtime_worktree.path)
300                        .then_some(runtime_worktree.project_id)
301                })?;
302                snapshot
303                    .projects
304                    .iter()
305                    .find(|candidate| candidate.id == project_id)
306            });
307        if let Some(runtime_project) = runtime_project {
308            project.last_agent_active_unix_ms = runtime_project.last_agent_active_unix_ms;
309            project.last_terminal_active_unix_ms = runtime_project.last_terminal_active_unix_ms;
310        }
311    }
312}
313
314fn sessions_for_worktree(
315    snapshot: &Snapshot,
316    path: &Path,
317    previous: &[SessionInfo],
318) -> Result<Vec<SessionInfo>> {
319    let Some(worktree) = snapshot
320        .worktrees
321        .iter()
322        .find(|worktree| worktree.path == path)
323    else {
324        return Ok(Vec::new());
325    };
326    let previous = previous
327        .iter()
328        .map(|session| (session.session_id, session))
329        .collect::<HashMap<_, _>>();
330    let listening_ports = snapshot
331        .listening_ports
332        .iter()
333        .map(|ports| (ports.pane_id, ports.tcp.as_slice()))
334        .collect::<HashMap<_, _>>();
335    let foreground_jobs = snapshot
336        .pane_activity
337        .iter()
338        .filter_map(|activity| activity.foreground_job.then_some(activity.pane_id))
339        .collect::<HashSet<_>>();
340    snapshot
341        .sessions
342        .iter()
343        .filter(|session| session.worktree_id == worktree.id)
344        .map(|session| {
345            let focused = snapshot
346                .panes
347                .iter()
348                .find(|pane| pane.id == session.focused_pane)
349                .ok_or_else(|| anyhow!("session {} has no focused pane", session.id))?;
350            let focused_agent = focused.agent.as_ref().filter(|agent| agent.attached);
351            let old = previous.get(&session.id).copied();
352            let panes = session
353                .panes
354                .iter()
355                .map(|pane_id| {
356                    let pane = snapshot
357                        .panes
358                        .iter()
359                        .find(|pane| pane.id == *pane_id)
360                        .ok_or_else(|| {
361                            anyhow!("session {} references missing pane {}", session.id, pane_id)
362                        })?;
363                    let attached_agent = pane.agent.as_ref().filter(|agent| agent.attached);
364                    Ok(PaneInfo {
365                        pane_id: pane.id,
366                        terminal_id: pane.terminal_id,
367                        label: pane.label.clone(),
368                        agent: attached_agent.map(|agent| agent.provider.clone()),
369                        agent_status: attached_agent
370                            .map_or(AgentState::Unknown, |agent| agent.state),
371                        revision: pane.revision,
372                        exited: pane.exited,
373                        listening_ports: listening_ports
374                            .get(&pane.id)
375                            .copied()
376                            .unwrap_or_default()
377                            .to_vec(),
378                        foreground_job: foreground_jobs.contains(&pane.id),
379                        outcome_acknowledged: old
380                            .and_then(|session| {
381                                session
382                                    .panes
383                                    .iter()
384                                    .find(|previous| previous.terminal_id == pane.terminal_id)
385                            })
386                            .is_some_and(|previous| {
387                                previous.revision == pane.revision && previous.outcome_acknowledged
388                            }),
389                    })
390                })
391                .collect::<Result<Vec<_>>>()?;
392            let revision = session.revision;
393            let outcome_acknowledged = panes
394                .iter()
395                .find(|pane| pane.pane_id == focused.id)
396                .is_some_and(|pane| pane.outcome_acknowledged);
397            Ok(SessionInfo {
398                session_id: session.id,
399                pane_id: focused.id,
400                terminal_id: focused.terminal_id,
401                agent: focused_agent.map(|agent| agent.provider.clone()),
402                display_name: session.label.clone(),
403                agent_status: focused_agent.map_or(AgentState::Unknown, |agent| agent.state),
404                revision,
405                layout: session.layout.clone(),
406                panes,
407                muted: old.is_some_and(|session| session.muted),
408                outcome_acknowledged,
409            })
410        })
411        .collect()
412}
413
414pub fn expand_path(value: &str) -> PathBuf {
415    value
416        .strip_prefix("~/")
417        .and_then(|tail| dirs::home_dir().map(|home| home.join(tail)))
418        .unwrap_or_else(|| PathBuf::from(value))
419}
420pub fn detect_default_branch(path: &Path) -> String {
421    git_info::current_branch(path).unwrap_or_else(|| "main".into())
422}
423
424pub fn register_project(path: PathBuf, config: &mut GlobalConfig) -> Result<Project> {
425    if path.as_os_str().is_empty() {
426        bail!("empty path");
427    }
428    let path = crate::config::global::normalize_project_path(&path);
429    if !path.exists() {
430        bail!("path does not exist: {}", path.display());
431    }
432    if !is_git_repo(&path) {
433        bail!("not a git repository: {}", path.display());
434    }
435    if config.projects.iter().any(|entry| entry.path == path) {
436        bail!("project already registered: {}", path.display());
437    }
438    let name = path
439        .file_name()
440        .map(|name| name.to_string_lossy().into_owned())
441        .unwrap_or_else(|| "unknown".into());
442    let project = Project {
443        name: name.clone(),
444        path: path.clone(),
445        default_branch: detect_default_branch(&path),
446        last_agent_active_unix_ms: None,
447        last_terminal_active_unix_ms: None,
448        worktrees: git_worktree::to_worktree_infos(
449            git_worktree::list_worktrees(&path).unwrap_or_default(),
450            &HashMap::new(),
451        ),
452        routines: Vec::new(),
453        routine_revision: 0,
454        routines_expanded: true,
455        config: Some(crate::config::project::load_project_config(&path)),
456        expanded: true,
457        missing: false,
458    };
459    config.add_project(name, path);
460    Ok(project)
461}
462pub fn unregister_project(path: &PathBuf, config: &mut GlobalConfig) {
463    config.remove_project(path);
464}
465
466pub fn create_worktree(
467    repo_path: &Path,
468    default_branch: &str,
469    project_config: &ProjectConfig,
470    branch: &str,
471) -> Result<(PathBuf, Option<String>)> {
472    let path = git_worktree::create_worktree(repo_path, branch, default_branch)?;
473    let mut warning = hooks::copy_env_files(repo_path, &path, project_config)
474        .err()
475        .map(|error| format!("Warning: .env copy: {error}"));
476    if let Some(command) = &project_config.post_create {
477        if let Err(error) = hooks::run_post_create(&path, command) {
478            warning = Some(format!("Warning: postCreate: {error}"));
479        }
480    }
481    Ok((path, warning))
482}
483
484#[derive(Debug)]
485pub struct CreatedWorktree {
486    pub path: PathBuf,
487    pub warning: Option<String>,
488    pub session: Option<(SessionId, String)>,
489    pub session_error: Option<String>,
490}
491
492pub fn create_configured_worktree(
493    config: &GlobalConfig,
494    project_name: &str,
495    repo_path: &Path,
496    default_branch: &str,
497    project_config: &ProjectConfig,
498    branch: &str,
499    fallback_session: WorktreeInitialSession,
500) -> Result<CreatedWorktree> {
501    let (path, warning) = create_worktree(repo_path, default_branch, project_config, branch)?;
502    let session_result = match project_config.initial_session(fallback_session) {
503        WorktreeInitialSession::Disabled => None,
504        WorktreeInitialSession::Shell => {
505            Some(create_session(config, project_name, "", &path, None, None))
506        }
507        WorktreeInitialSession::Command(command) => Some(create_session(
508            config,
509            project_name,
510            "",
511            &path,
512            None,
513            Some(command),
514        )),
515    };
516    let (session, session_error) = match session_result {
517        Some(Ok(session)) => (Some(session), None),
518        Some(Err(error)) => (None, Some(error.to_string())),
519        None => (None, None),
520    };
521    Ok(CreatedWorktree {
522        path,
523        warning,
524        session,
525        session_error,
526    })
527}
528
529pub fn delete_worktree(repo_path: &Path, wt_path: &Path, branch: &str) -> Result<()> {
530    let client = Client::local();
531    let snapshot = runtime_snapshot()?;
532    if let Some(worktree) = snapshot
533        .worktrees
534        .iter()
535        .find(|worktree| worktree.path == wt_path)
536    {
537        for session in snapshot
538            .sessions
539            .iter()
540            .filter(|session| session.worktree_id == worktree.id)
541        {
542            expect_ack(client.call(&Request::SessionClose {
543                session_id: session.id,
544                expected_revision: session.revision,
545            })?)?;
546        }
547    }
548    git_worktree::remove_worktree(repo_path, wt_path, branch)
549}
550fn snapshot_with_worktree<F>(
551    snapshot: Snapshot,
552    worktree_path: &Path,
553    synchronize_once: F,
554) -> Result<(Snapshot, WorktreeId)>
555where
556    F: FnOnce() -> Result<Snapshot>,
557{
558    if let Some(id) = snapshot
559        .worktrees
560        .iter()
561        .find(|worktree| worktree.path == worktree_path)
562        .map(|worktree| worktree.id)
563    {
564        return Ok((snapshot, id));
565    }
566    let refreshed = synchronize_once()?;
567    let id = refreshed
568        .worktrees
569        .iter()
570        .find(|worktree| worktree.path == worktree_path)
571        .map(|worktree| worktree.id)
572        .ok_or_else(|| anyhow!("worktree is not synchronized with wsx daemon"))?;
573    Ok((refreshed, id))
574}
575
576pub fn create_session(
577    config: &GlobalConfig,
578    project_name: &str,
579    _worktree_slug: &str,
580    worktree_path: &Path,
581    session_label: Option<String>,
582    command: Option<String>,
583) -> Result<(SessionId, String)> {
584    let client = Client::local();
585    let (snapshot, worktree_id) =
586        snapshot_with_worktree(runtime_snapshot()?, worktree_path, || {
587            let discovery = discover_workspace(config)?;
588            synchronize_discovery(&discovery)
589        })?;
590    let base = session_label
591        .filter(|label| !label.trim().is_empty())
592        .or_else(|| {
593            command
594                .as_ref()
595                .and_then(|command| command.split_whitespace().next().map(str::to_owned))
596        })
597        .unwrap_or_else(|| project_name.to_owned());
598    let used = snapshot
599        .sessions
600        .iter()
601        .filter(|session| session.worktree_id == worktree_id)
602        .map(|session| session.label.as_str())
603        .collect::<std::collections::HashSet<_>>();
604    let mut label = base.clone();
605    let mut suffix = 2;
606    while used.contains(label.as_str()) {
607        label = format!("{base}-{suffix}");
608        suffix += 1;
609    }
610    let response = client.call(&Request::SessionCreate {
611        worktree_id,
612        label: label.clone(),
613        command: Vec::new(),
614        initial_input: command,
615        rows: 24,
616        cols: 80,
617    })?;
618    let session_id = match response {
619        Response::Created { id, .. } => SessionId(id),
620        Response::Error(error) => bail!("{}: {}", error.code, error.message),
621        _ => bail!("wsx daemon returned an unexpected create response"),
622    };
623    Ok((session_id, label))
624}
625
626pub fn reorder_session(
627    session_id: SessionId,
628    target_session_id: SessionId,
629    placement: SessionPlacement,
630    expected_revision: u64,
631) -> Result<u64> {
632    expect_ack_revision(Client::local().call(&Request::SessionReorder {
633        session_id,
634        target_session_id,
635        placement,
636        expected_revision,
637    })?)
638}
639
640pub fn rename_session(session_id: SessionId, new_label: &str) -> Result<()> {
641    let snapshot = runtime_snapshot()?;
642    let session = snapshot
643        .sessions
644        .iter()
645        .find(|session| session.id == session_id)
646        .ok_or_else(|| anyhow!("session not found"))?;
647    expect_ack(Client::local().call(&Request::SessionRename {
648        session_id,
649        label: new_label.into(),
650        expected_revision: session.revision,
651    })?)
652}
653pub fn kill_session(session_id: SessionId) -> Result<()> {
654    let snapshot = runtime_snapshot()?;
655    let session = snapshot
656        .sessions
657        .iter()
658        .find(|session| session.id == session_id)
659        .ok_or_else(|| anyhow!("session not found"))?;
660    expect_ack(Client::local().call(&Request::SessionClose {
661        session_id,
662        expected_revision: session.revision,
663    })?)
664}
665
666fn expect_ack(response: Response) -> Result<()> {
667    expect_ack_revision(response).map(|_| ())
668}
669
670fn expect_ack_revision(response: Response) -> Result<u64> {
671    match response {
672        Response::Ack { revision } => Ok(revision),
673        Response::Error(error) => bail!("{}: {}", error.code, error.message),
674        _ => bail!("wsx daemon returned an unexpected mutation response"),
675    }
676}
677pub fn set_alias(config: &mut GlobalConfig, project_path: &PathBuf, branch: &str, alias: &str) {
678    config.set_alias(project_path, branch, alias);
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use crate::runtime::{
685        self, AgentCapabilities, AgentInfo, AgentInstanceId, Capabilities, Pane, PaneId,
686        PaneLayout, Project as RuntimeProject, ProjectId, Session, TerminalId, Worktree,
687        WorktreeId,
688    };
689
690    #[test]
691    fn projection_lists_sessions_directly_under_their_worktree() {
692        let snapshot = Snapshot {
693            protocol: runtime::PROTOCOL_VERSION,
694            epoch: 1,
695            revision: 4,
696            projects: vec![RuntimeProject {
697                id: ProjectId(1),
698                path: "/repo".into(),
699                name: "repo".into(),
700                revision: 1,
701                last_agent_active_unix_ms: Some(42),
702                last_terminal_active_unix_ms: Some(43),
703            }],
704            worktrees: vec![Worktree {
705                id: WorktreeId(2),
706                project_id: ProjectId(1),
707                path: "/repo".into(),
708                branch: "main".into(),
709                revision: 1,
710            }],
711            sessions: vec![Session {
712                id: SessionId(3),
713                worktree_id: WorktreeId(2),
714                label: "shell".into(),
715                primary_pane: PaneId(4),
716                focused_pane: PaneId(6),
717                panes: vec![PaneId(4), PaneId(6)],
718                layout: PaneLayout::Split {
719                    axis: runtime::SplitAxis::Vertical,
720                    ratio_millis: 500,
721                    first: Box::new(PaneLayout::Leaf { pane_id: PaneId(4) }),
722                    second: Box::new(PaneLayout::Leaf { pane_id: PaneId(6) }),
723                },
724                revision: 4,
725            }],
726            panes: vec![
727                Pane {
728                    id: PaneId(4),
729                    terminal_id: TerminalId(5),
730                    session_id: SessionId(3),
731                    label: "primary".into(),
732                    agent: Some(AgentInfo {
733                        id: AgentInstanceId(8),
734                        provider: "pi".into(),
735                        state: AgentState::Working,
736                        attached: true,
737                        conversation_id: None,
738                        session_ref: None,
739                        capabilities: AgentCapabilities::default(),
740                        source: "test".into(),
741                    }),
742                    exited: false,
743                    revision: 4,
744                },
745                Pane {
746                    id: PaneId(6),
747                    terminal_id: TerminalId(7),
748                    session_id: SessionId(3),
749                    label: "split".into(),
750                    agent: Some(AgentInfo {
751                        id: AgentInstanceId(10),
752                        provider: "pi".into(),
753                        state: AgentState::Done,
754                        attached: false,
755                        conversation_id: Some("resumable".into()),
756                        session_ref: None,
757                        capabilities: AgentCapabilities::default(),
758                        source: "test".into(),
759                    }),
760                    exited: false,
761                    revision: 9,
762                },
763            ],
764            listening_ports: vec![
765                runtime::PanePorts {
766                    pane_id: PaneId(4),
767                    tcp: vec![5173],
768                },
769                runtime::PanePorts {
770                    pane_id: PaneId(6),
771                    tcp: vec![3000, 5173],
772                },
773            ],
774            pane_activity: vec![runtime::PaneActivity {
775                pane_id: PaneId(6),
776                foreground_job: true,
777            }],
778            plugin_sidecars: Vec::new(),
779            capabilities: Capabilities::default(),
780        };
781        let sessions = sessions_for_worktree(&snapshot, Path::new("/repo"), &[]).unwrap();
782        assert_eq!(sessions.len(), 1);
783        assert_eq!(sessions[0].session_id, SessionId(3));
784        assert_eq!(sessions[0].display_name, "shell");
785        assert_eq!(sessions[0].pane_id, PaneId(6));
786        assert_eq!(sessions[0].revision, 4);
787        assert_eq!(sessions[0].panes[1].revision, 9);
788        assert_eq!(sessions[0].panes.len(), 2);
789        assert_eq!(sessions[0].panes[0].label, "primary");
790        assert_eq!(sessions[0].panes[0].agent.as_deref(), Some("pi"));
791        assert_eq!(sessions[0].panes[0].agent_status, AgentState::Working);
792        assert_eq!(sessions[0].panes[1].label, "split");
793        assert_eq!(sessions[0].agent, None);
794        assert_eq!(sessions[0].agent_status, AgentState::Unknown);
795        assert_eq!(sessions[0].panes[1].agent, None);
796        assert_eq!(sessions[0].panes[1].agent_status, AgentState::Unknown);
797        assert_eq!(sessions[0].listening_ports(), vec![3000, 5173]);
798        assert!(sessions[0].has_foreground_job());
799
800        let mut workspace = WorkspaceState {
801            projects: vec![Project {
802                name: "repo".into(),
803                path: "/repo".into(),
804                default_branch: "main".into(),
805                last_agent_active_unix_ms: None,
806                last_terminal_active_unix_ms: None,
807                worktrees: Vec::new(),
808                routines: Vec::new(),
809                routine_revision: 0,
810                routines_expanded: true,
811                config: None,
812                expanded: true,
813                missing: false,
814            }],
815        };
816        refresh_sessions_from_snapshot(&mut workspace, &snapshot).unwrap();
817        assert_eq!(workspace.projects[0].last_agent_active_unix_ms, Some(42));
818        assert_eq!(workspace.projects[0].last_terminal_active_unix_ms, Some(43));
819    }
820
821    #[test]
822    fn discovery_only_refresh_keeps_worktrees_and_known_activity_without_daemon_state() {
823        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
824            .join(".work")
825            .join(format!("degraded-{}", std::process::id()));
826        let _ = std::fs::remove_dir_all(&root);
827        std::fs::create_dir_all(root.join(".git")).unwrap();
828        let config = GlobalConfig {
829            projects: vec![crate::config::global::ProjectEntry {
830                name: "degraded".into(),
831                path: root.clone(),
832                groups: Vec::new(),
833                aliases: HashMap::new(),
834            }],
835            ..GlobalConfig::default()
836        };
837        let mut workspace = workspace_from_config(&config);
838        workspace.projects[0].last_agent_active_unix_ms = Some(11);
839        workspace.projects[0].last_terminal_active_unix_ms = Some(12);
840        workspace.projects[0].worktrees.push(WorktreeInfo {
841            name: "main".into(),
842            branch: "main".into(),
843            path: root.clone(),
844            is_main: true,
845            alias: None,
846            sessions: vec![SessionInfo {
847                session_id: SessionId(31),
848                pane_id: PaneId(32),
849                terminal_id: TerminalId(33),
850                agent: None,
851                display_name: "preserved shell".into(),
852                agent_status: AgentState::Unknown,
853                revision: 7,
854                layout: PaneLayout::Leaf {
855                    pane_id: PaneId(32),
856                },
857                panes: vec![PaneInfo {
858                    pane_id: PaneId(32),
859                    terminal_id: TerminalId(33),
860                    label: "terminal".into(),
861                    agent: None,
862                    agent_status: AgentState::Unknown,
863                    revision: 8,
864                    exited: false,
865                    listening_ports: vec![4173],
866                    foreground_job: true,
867                    outcome_acknowledged: false,
868                }],
869                muted: true,
870                outcome_acknowledged: false,
871            }],
872            expanded: false,
873            git_info: None,
874            fetch_failed: false,
875            fetch_fail_count: 0,
876            fetch_fail_reason: None,
877            last_fetched: None,
878            git_info_fetched_at: None,
879        });
880
881        refresh_workspace_discovery_only(
882            &mut workspace,
883            &config,
884            vec![(
885                root.clone(),
886                vec![git_worktree::WorktreeEntry {
887                    name: "main".into(),
888                    path: root.clone(),
889                    branch: "main".into(),
890                    is_main: true,
891                }],
892            )],
893        )
894        .unwrap();
895
896        assert_eq!(workspace.projects[0].worktrees.len(), 1);
897        assert_eq!(workspace.projects[0].last_agent_active_unix_ms, Some(11));
898        assert_eq!(workspace.projects[0].last_terminal_active_unix_ms, Some(12));
899        let preserved = &workspace.projects[0].worktrees[0];
900        assert!(!preserved.expanded);
901        assert_eq!(preserved.sessions.len(), 1);
902        assert_eq!(preserved.sessions[0].session_id, SessionId(31));
903        assert_eq!(preserved.sessions[0].pane_id, PaneId(32));
904        assert_eq!(preserved.sessions[0].terminal_id, TerminalId(33));
905        assert_eq!(preserved.sessions[0].display_name, "preserved shell");
906        assert_eq!(preserved.sessions[0].panes[0].listening_ports, vec![4173]);
907        assert!(preserved.sessions[0].panes[0].foreground_job);
908        std::fs::remove_dir_all(root).unwrap();
909    }
910
911    #[test]
912    fn discovery_lists_each_registered_project_once() {
913        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
914            .join(".work")
915            .join(format!("discovery-{}", std::process::id()));
916        let _ = std::fs::remove_dir_all(&root);
917        let paths = [root.join("one"), root.join("two")];
918        for path in &paths {
919            std::fs::create_dir_all(path.join(".git")).unwrap();
920        }
921        let config = GlobalConfig {
922            projects: paths
923                .iter()
924                .map(|path| crate::config::global::ProjectEntry {
925                    name: path.file_name().unwrap().to_string_lossy().into_owned(),
926                    path: path.clone(),
927                    groups: Vec::new(),
928                    aliases: HashMap::new(),
929                })
930                .collect(),
931            ..GlobalConfig::default()
932        };
933        let shell = workspace_from_config(&config);
934        assert_eq!(shell.projects.len(), paths.len());
935        assert!(shell
936            .projects
937            .iter()
938            .all(|project| project.worktrees.is_empty()));
939        let calls = std::cell::Cell::new(0usize);
940
941        let discovery = discover_workspace_with(&config, |path| {
942            calls.set(calls.get() + 1);
943            Ok(vec![git_worktree::WorktreeEntry {
944                name: "main".into(),
945                path: path.to_path_buf(),
946                branch: "trunk".into(),
947                is_main: true,
948            }])
949        })
950        .unwrap();
951
952        assert_eq!(calls.get(), paths.len());
953        assert_eq!(discovery.into_worktrees().len(), paths.len());
954        let failed =
955            discover_workspace_with(&config, |_| Err(anyhow!("worktree discovery failed")));
956        assert!(failed.is_err());
957        std::fs::remove_dir_all(root).unwrap();
958    }
959
960    #[test]
961    fn session_worktree_fast_path_skips_synchronization() {
962        let (snapshot, worktree_id) = snapshot_with_worktree(
963            snapshot_with_worktree_path("/repo"),
964            Path::new("/repo"),
965            || panic!("synchronized snapshot must not refresh"),
966        )
967        .unwrap();
968
969        assert_eq!(snapshot.worktrees[0].path, Path::new("/repo"));
970        assert_eq!(worktree_id, WorktreeId(2));
971    }
972
973    #[test]
974    fn missing_session_worktree_retries_synchronization_once() {
975        let calls = std::cell::Cell::new(0usize);
976        let (snapshot, worktree_id) = snapshot_with_worktree(
977            snapshot_with_worktree_path("/other"),
978            Path::new("/repo"),
979            || {
980                calls.set(calls.get() + 1);
981                Ok(snapshot_with_worktree_path("/repo"))
982            },
983        )
984        .unwrap();
985
986        assert_eq!(calls.get(), 1);
987        assert_eq!(snapshot.worktrees[0].path, Path::new("/repo"));
988        assert_eq!(worktree_id, WorktreeId(2));
989    }
990
991    #[test]
992    fn missing_session_worktree_still_fails_after_one_synchronization() {
993        let calls = std::cell::Cell::new(0usize);
994        let error = snapshot_with_worktree(
995            snapshot_with_worktree_path("/other"),
996            Path::new("/repo"),
997            || {
998                calls.set(calls.get() + 1);
999                Ok(snapshot_with_worktree_path("/still-other"))
1000            },
1001        )
1002        .unwrap_err();
1003
1004        assert_eq!(calls.get(), 1);
1005        assert_eq!(
1006            error.to_string(),
1007            "worktree is not synchronized with wsx daemon"
1008        );
1009    }
1010
1011    fn snapshot_with_worktree_path(path: &str) -> Snapshot {
1012        Snapshot {
1013            protocol: runtime::PROTOCOL_VERSION,
1014            epoch: 1,
1015            revision: 1,
1016            projects: vec![RuntimeProject {
1017                id: ProjectId(1),
1018                path: "/repo".into(),
1019                name: "repo".into(),
1020                revision: 1,
1021                last_agent_active_unix_ms: None,
1022                last_terminal_active_unix_ms: None,
1023            }],
1024            worktrees: vec![Worktree {
1025                id: WorktreeId(2),
1026                project_id: ProjectId(1),
1027                path: path.into(),
1028                branch: "main".into(),
1029                revision: 1,
1030            }],
1031            sessions: Vec::new(),
1032            panes: Vec::new(),
1033            listening_ports: Vec::new(),
1034            pane_activity: Vec::new(),
1035            plugin_sidecars: Vec::new(),
1036            capabilities: Capabilities::default(),
1037        }
1038    }
1039
1040    #[test]
1041    fn register_project_rejects_empty_paths() {
1042        let mut config = GlobalConfig::default();
1043        assert!(register_project(PathBuf::new(), &mut config).is_err());
1044    }
1045}