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_with_worktrees(
174    workspace: &mut WorkspaceState,
175    config: &GlobalConfig,
176    snapshot: &Snapshot,
177    worktrees: Vec<(PathBuf, Vec<git_worktree::WorktreeEntry>)>,
178) -> Result<()> {
179    let mut worktrees_map: HashMap<PathBuf, Vec<git_worktree::WorktreeEntry>> =
180        worktrees.into_iter().collect();
181    update_project_activity(workspace, snapshot);
182    for project in &mut workspace.projects {
183        if let Some(default_branch) = worktrees_map
184            .get(&project.path)
185            .and_then(|entries| entries.iter().find(|entry| entry.is_main))
186            .filter(|entry| entry.branch != "HEAD")
187            .map(|entry| entry.branch.clone())
188        {
189            project.default_branch = default_branch;
190        }
191        let previous: HashMap<PathBuf, WorktreeState> = project
192            .worktrees
193            .iter()
194            .map(|worktree| {
195                (
196                    worktree.path.clone(),
197                    WorktreeState {
198                        git_info: worktree.git_info.clone(),
199                        git_info_fetched_at: worktree.git_info_fetched_at,
200                        expanded: worktree.expanded,
201                        sessions: worktree.sessions.clone(),
202                        last_fetched: worktree.last_fetched,
203                        fetch_failed: worktree.fetch_failed,
204                        fetch_fail_count: worktree.fetch_fail_count,
205                        fetch_fail_reason: worktree.fetch_fail_reason.clone(),
206                    },
207                )
208            })
209            .collect();
210        let aliases = config
211            .projects
212            .iter()
213            .find(|entry| entry.path == project.path)
214            .map(|entry| &entry.aliases);
215        let entries = worktrees_map.remove(&project.path).unwrap_or_default();
216        project.worktrees = entries
217            .into_iter()
218            .filter(|entry| !config.is_worktree_excluded(&entry.path))
219            .map(|entry| {
220                let old = previous.get(&entry.path);
221                Ok(WorktreeInfo {
222                    name: entry.name,
223                    branch: entry.branch.clone(),
224                    path: entry.path.clone(),
225                    is_main: entry.is_main,
226                    alias: aliases.and_then(|map| map.get(&entry.branch)).cloned(),
227                    sessions: sessions_for_worktree(
228                        snapshot,
229                        &entry.path,
230                        old.map(|state| state.sessions.as_slice())
231                            .unwrap_or_default(),
232                    )?,
233                    expanded: old.map(|state| state.expanded).unwrap_or(true),
234                    git_info: old.and_then(|state| state.git_info.clone()),
235                    fetch_failed: old.map(|state| state.fetch_failed).unwrap_or(false),
236                    fetch_fail_count: old.map(|state| state.fetch_fail_count).unwrap_or(0),
237                    fetch_fail_reason: old.and_then(|state| state.fetch_fail_reason.clone()),
238                    last_fetched: old.and_then(|state| state.last_fetched),
239                    git_info_fetched_at: old.and_then(|state| state.git_info_fetched_at),
240                })
241            })
242            .collect::<Result<Vec<_>>>()?;
243    }
244    workspace.projects.retain(|project| !project.missing);
245    for project in &mut workspace.projects {
246        project.missing = !is_git_repo(&project.path);
247    }
248    Ok(())
249}
250
251pub fn refresh_sessions_from_snapshot(
252    workspace: &mut WorkspaceState,
253    snapshot: &Snapshot,
254) -> Result<()> {
255    update_project_activity(workspace, snapshot);
256    for worktree in workspace
257        .projects
258        .iter_mut()
259        .flat_map(|project| &mut project.worktrees)
260    {
261        worktree.sessions = sessions_for_worktree(snapshot, &worktree.path, &worktree.sessions)?;
262    }
263    Ok(())
264}
265
266fn update_project_activity(workspace: &mut WorkspaceState, snapshot: &Snapshot) {
267    for project in &mut workspace.projects {
268        let runtime_project = snapshot
269            .projects
270            .iter()
271            .find(|candidate| candidate.path == project.path)
272            .or_else(|| {
273                let project_id = snapshot.worktrees.iter().find_map(|runtime_worktree| {
274                    project
275                        .worktrees
276                        .iter()
277                        .any(|worktree| worktree.path == runtime_worktree.path)
278                        .then_some(runtime_worktree.project_id)
279                })?;
280                snapshot
281                    .projects
282                    .iter()
283                    .find(|candidate| candidate.id == project_id)
284            });
285        if let Some(runtime_project) = runtime_project {
286            project.last_agent_active_unix_ms = runtime_project.last_agent_active_unix_ms;
287            project.last_terminal_active_unix_ms = runtime_project.last_terminal_active_unix_ms;
288        }
289    }
290}
291
292fn sessions_for_worktree(
293    snapshot: &Snapshot,
294    path: &Path,
295    previous: &[SessionInfo],
296) -> Result<Vec<SessionInfo>> {
297    let Some(worktree) = snapshot
298        .worktrees
299        .iter()
300        .find(|worktree| worktree.path == path)
301    else {
302        return Ok(Vec::new());
303    };
304    let previous = previous
305        .iter()
306        .map(|session| (session.session_id, session))
307        .collect::<HashMap<_, _>>();
308    let listening_ports = snapshot
309        .listening_ports
310        .iter()
311        .map(|ports| (ports.pane_id, ports.tcp.as_slice()))
312        .collect::<HashMap<_, _>>();
313    let foreground_jobs = snapshot
314        .pane_activity
315        .iter()
316        .filter_map(|activity| activity.foreground_job.then_some(activity.pane_id))
317        .collect::<HashSet<_>>();
318    snapshot
319        .sessions
320        .iter()
321        .filter(|session| session.worktree_id == worktree.id)
322        .map(|session| {
323            let focused = snapshot
324                .panes
325                .iter()
326                .find(|pane| pane.id == session.focused_pane)
327                .ok_or_else(|| anyhow!("session {} has no focused pane", session.id))?;
328            let old = previous.get(&session.id).copied();
329            let panes = session
330                .panes
331                .iter()
332                .map(|pane_id| {
333                    let pane = snapshot
334                        .panes
335                        .iter()
336                        .find(|pane| pane.id == *pane_id)
337                        .ok_or_else(|| {
338                            anyhow!("session {} references missing pane {}", session.id, pane_id)
339                        })?;
340                    Ok(PaneInfo {
341                        pane_id: pane.id,
342                        terminal_id: pane.terminal_id,
343                        label: pane.label.clone(),
344                        agent: pane.agent.as_ref().map(|agent| agent.provider.clone()),
345                        agent_status: pane
346                            .agent
347                            .as_ref()
348                            .map_or(AgentState::Unknown, |agent| agent.state),
349                        revision: pane.revision,
350                        exited: pane.exited,
351                        listening_ports: listening_ports
352                            .get(&pane.id)
353                            .copied()
354                            .unwrap_or_default()
355                            .to_vec(),
356                        foreground_job: foreground_jobs.contains(&pane.id),
357                        outcome_acknowledged: old
358                            .and_then(|session| {
359                                session
360                                    .panes
361                                    .iter()
362                                    .find(|previous| previous.terminal_id == pane.terminal_id)
363                            })
364                            .is_some_and(|previous| {
365                                previous.revision == pane.revision && previous.outcome_acknowledged
366                            }),
367                    })
368                })
369                .collect::<Result<Vec<_>>>()?;
370            let revision = session.revision;
371            let outcome_acknowledged = panes
372                .iter()
373                .find(|pane| pane.pane_id == focused.id)
374                .is_some_and(|pane| pane.outcome_acknowledged);
375            Ok(SessionInfo {
376                session_id: session.id,
377                pane_id: focused.id,
378                terminal_id: focused.terminal_id,
379                agent: focused.agent.as_ref().map(|agent| agent.provider.clone()),
380                display_name: session.label.clone(),
381                agent_status: focused
382                    .agent
383                    .as_ref()
384                    .map_or(AgentState::Unknown, |agent| agent.state),
385                revision,
386                layout: session.layout.clone(),
387                panes,
388                muted: old.is_some_and(|session| session.muted),
389                outcome_acknowledged,
390            })
391        })
392        .collect()
393}
394
395pub fn expand_path(value: &str) -> PathBuf {
396    value
397        .strip_prefix("~/")
398        .and_then(|tail| dirs::home_dir().map(|home| home.join(tail)))
399        .unwrap_or_else(|| PathBuf::from(value))
400}
401pub fn detect_default_branch(path: &Path) -> String {
402    git_info::current_branch(path).unwrap_or_else(|| "main".into())
403}
404
405pub fn register_project(path: PathBuf, config: &mut GlobalConfig) -> Result<Project> {
406    if path.as_os_str().is_empty() {
407        bail!("empty path");
408    }
409    let path = crate::config::global::normalize_project_path(&path);
410    if !path.exists() {
411        bail!("path does not exist: {}", path.display());
412    }
413    if !is_git_repo(&path) {
414        bail!("not a git repository: {}", path.display());
415    }
416    if config.projects.iter().any(|entry| entry.path == path) {
417        bail!("project already registered: {}", path.display());
418    }
419    let name = path
420        .file_name()
421        .map(|name| name.to_string_lossy().into_owned())
422        .unwrap_or_else(|| "unknown".into());
423    let project = Project {
424        name: name.clone(),
425        path: path.clone(),
426        default_branch: detect_default_branch(&path),
427        last_agent_active_unix_ms: None,
428        last_terminal_active_unix_ms: None,
429        worktrees: git_worktree::to_worktree_infos(
430            git_worktree::list_worktrees(&path).unwrap_or_default(),
431            &HashMap::new(),
432        ),
433        routines: Vec::new(),
434        routine_revision: 0,
435        routines_expanded: true,
436        config: Some(crate::config::project::load_project_config(&path)),
437        expanded: true,
438        missing: false,
439    };
440    config.add_project(name, path);
441    Ok(project)
442}
443pub fn unregister_project(path: &PathBuf, config: &mut GlobalConfig) {
444    config.remove_project(path);
445}
446
447pub fn create_worktree(
448    repo_path: &Path,
449    default_branch: &str,
450    project_config: &ProjectConfig,
451    branch: &str,
452) -> Result<(PathBuf, Option<String>)> {
453    let path = git_worktree::create_worktree(repo_path, branch, default_branch)?;
454    let mut warning = hooks::copy_env_files(repo_path, &path, project_config)
455        .err()
456        .map(|error| format!("Warning: .env copy: {error}"));
457    if let Some(command) = &project_config.post_create {
458        if let Err(error) = hooks::run_post_create(&path, command) {
459            warning = Some(format!("Warning: postCreate: {error}"));
460        }
461    }
462    Ok((path, warning))
463}
464
465#[derive(Debug)]
466pub struct CreatedWorktree {
467    pub path: PathBuf,
468    pub warning: Option<String>,
469    pub session: Option<(SessionId, String)>,
470    pub session_error: Option<String>,
471}
472
473pub fn create_configured_worktree(
474    config: &GlobalConfig,
475    project_name: &str,
476    repo_path: &Path,
477    default_branch: &str,
478    project_config: &ProjectConfig,
479    branch: &str,
480    fallback_session: WorktreeInitialSession,
481) -> Result<CreatedWorktree> {
482    let (path, warning) = create_worktree(repo_path, default_branch, project_config, branch)?;
483    let session_result = match project_config.initial_session(fallback_session) {
484        WorktreeInitialSession::Disabled => None,
485        WorktreeInitialSession::Shell => {
486            Some(create_session(config, project_name, "", &path, None, None))
487        }
488        WorktreeInitialSession::Command(command) => Some(create_session(
489            config,
490            project_name,
491            "",
492            &path,
493            None,
494            Some(command),
495        )),
496    };
497    let (session, session_error) = match session_result {
498        Some(Ok(session)) => (Some(session), None),
499        Some(Err(error)) => (None, Some(error.to_string())),
500        None => (None, None),
501    };
502    Ok(CreatedWorktree {
503        path,
504        warning,
505        session,
506        session_error,
507    })
508}
509
510pub fn delete_worktree(repo_path: &Path, wt_path: &Path, branch: &str) -> Result<()> {
511    let client = Client::local();
512    let snapshot = runtime_snapshot()?;
513    if let Some(worktree) = snapshot
514        .worktrees
515        .iter()
516        .find(|worktree| worktree.path == wt_path)
517    {
518        for session in snapshot
519            .sessions
520            .iter()
521            .filter(|session| session.worktree_id == worktree.id)
522        {
523            expect_ack(client.call(&Request::SessionClose {
524                session_id: session.id,
525                expected_revision: session.revision,
526            })?)?;
527        }
528    }
529    git_worktree::remove_worktree(repo_path, wt_path, branch)
530}
531fn snapshot_with_worktree<F>(
532    snapshot: Snapshot,
533    worktree_path: &Path,
534    synchronize_once: F,
535) -> Result<(Snapshot, WorktreeId)>
536where
537    F: FnOnce() -> Result<Snapshot>,
538{
539    if let Some(id) = snapshot
540        .worktrees
541        .iter()
542        .find(|worktree| worktree.path == worktree_path)
543        .map(|worktree| worktree.id)
544    {
545        return Ok((snapshot, id));
546    }
547    let refreshed = synchronize_once()?;
548    let id = refreshed
549        .worktrees
550        .iter()
551        .find(|worktree| worktree.path == worktree_path)
552        .map(|worktree| worktree.id)
553        .ok_or_else(|| anyhow!("worktree is not synchronized with wsx daemon"))?;
554    Ok((refreshed, id))
555}
556
557pub fn create_session(
558    config: &GlobalConfig,
559    project_name: &str,
560    _worktree_slug: &str,
561    worktree_path: &Path,
562    session_label: Option<String>,
563    command: Option<String>,
564) -> Result<(SessionId, String)> {
565    let client = Client::local();
566    let (snapshot, worktree_id) =
567        snapshot_with_worktree(runtime_snapshot()?, worktree_path, || {
568            let discovery = discover_workspace(config)?;
569            synchronize_discovery(&discovery)
570        })?;
571    let base = session_label
572        .filter(|label| !label.trim().is_empty())
573        .or_else(|| {
574            command
575                .as_ref()
576                .and_then(|command| command.split_whitespace().next().map(str::to_owned))
577        })
578        .unwrap_or_else(|| project_name.to_owned());
579    let used = snapshot
580        .sessions
581        .iter()
582        .filter(|session| session.worktree_id == worktree_id)
583        .map(|session| session.label.as_str())
584        .collect::<std::collections::HashSet<_>>();
585    let mut label = base.clone();
586    let mut suffix = 2;
587    while used.contains(label.as_str()) {
588        label = format!("{base}-{suffix}");
589        suffix += 1;
590    }
591    let response = client.call(&Request::SessionCreate {
592        worktree_id,
593        label: label.clone(),
594        command: Vec::new(),
595        initial_input: command,
596        rows: 24,
597        cols: 80,
598    })?;
599    let session_id = match response {
600        Response::Created { id, .. } => SessionId(id),
601        Response::Error(error) => bail!("{}: {}", error.code, error.message),
602        _ => bail!("wsx daemon returned an unexpected create response"),
603    };
604    Ok((session_id, label))
605}
606
607pub fn reorder_session(
608    session_id: SessionId,
609    target_session_id: SessionId,
610    placement: SessionPlacement,
611    expected_revision: u64,
612) -> Result<u64> {
613    expect_ack_revision(Client::local().call(&Request::SessionReorder {
614        session_id,
615        target_session_id,
616        placement,
617        expected_revision,
618    })?)
619}
620
621pub fn rename_session(session_id: SessionId, new_label: &str) -> Result<()> {
622    let snapshot = runtime_snapshot()?;
623    let session = snapshot
624        .sessions
625        .iter()
626        .find(|session| session.id == session_id)
627        .ok_or_else(|| anyhow!("session not found"))?;
628    expect_ack(Client::local().call(&Request::SessionRename {
629        session_id,
630        label: new_label.into(),
631        expected_revision: session.revision,
632    })?)
633}
634pub fn kill_session(session_id: SessionId) -> Result<()> {
635    let snapshot = runtime_snapshot()?;
636    let session = snapshot
637        .sessions
638        .iter()
639        .find(|session| session.id == session_id)
640        .ok_or_else(|| anyhow!("session not found"))?;
641    expect_ack(Client::local().call(&Request::SessionClose {
642        session_id,
643        expected_revision: session.revision,
644    })?)
645}
646
647fn expect_ack(response: Response) -> Result<()> {
648    expect_ack_revision(response).map(|_| ())
649}
650
651fn expect_ack_revision(response: Response) -> Result<u64> {
652    match response {
653        Response::Ack { revision } => Ok(revision),
654        Response::Error(error) => bail!("{}: {}", error.code, error.message),
655        _ => bail!("wsx daemon returned an unexpected mutation response"),
656    }
657}
658pub fn set_alias(config: &mut GlobalConfig, project_path: &PathBuf, branch: &str, alias: &str) {
659    config.set_alias(project_path, branch, alias);
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use crate::runtime::{
666        self, Capabilities, Pane, PaneId, PaneLayout, Project as RuntimeProject, ProjectId,
667        Session, TerminalId, Worktree, WorktreeId,
668    };
669
670    #[test]
671    fn projection_lists_sessions_directly_under_their_worktree() {
672        let snapshot = Snapshot {
673            protocol: runtime::PROTOCOL_VERSION,
674            epoch: 1,
675            revision: 4,
676            projects: vec![RuntimeProject {
677                id: ProjectId(1),
678                path: "/repo".into(),
679                name: "repo".into(),
680                revision: 1,
681                last_agent_active_unix_ms: Some(42),
682                last_terminal_active_unix_ms: Some(43),
683            }],
684            worktrees: vec![Worktree {
685                id: WorktreeId(2),
686                project_id: ProjectId(1),
687                path: "/repo".into(),
688                branch: "main".into(),
689                revision: 1,
690            }],
691            sessions: vec![Session {
692                id: SessionId(3),
693                worktree_id: WorktreeId(2),
694                label: "shell".into(),
695                primary_pane: PaneId(4),
696                focused_pane: PaneId(6),
697                panes: vec![PaneId(4), PaneId(6)],
698                layout: PaneLayout::Split {
699                    axis: runtime::SplitAxis::Vertical,
700                    ratio_millis: 500,
701                    first: Box::new(PaneLayout::Leaf { pane_id: PaneId(4) }),
702                    second: Box::new(PaneLayout::Leaf { pane_id: PaneId(6) }),
703                },
704                revision: 4,
705            }],
706            panes: vec![
707                Pane {
708                    id: PaneId(4),
709                    terminal_id: TerminalId(5),
710                    session_id: SessionId(3),
711                    label: "primary".into(),
712                    agent: None,
713                    exited: false,
714                    revision: 4,
715                },
716                Pane {
717                    id: PaneId(6),
718                    terminal_id: TerminalId(7),
719                    session_id: SessionId(3),
720                    label: "split".into(),
721                    agent: None,
722                    exited: false,
723                    revision: 9,
724                },
725            ],
726            listening_ports: vec![
727                runtime::PanePorts {
728                    pane_id: PaneId(4),
729                    tcp: vec![5173],
730                },
731                runtime::PanePorts {
732                    pane_id: PaneId(6),
733                    tcp: vec![3000, 5173],
734                },
735            ],
736            pane_activity: vec![runtime::PaneActivity {
737                pane_id: PaneId(6),
738                foreground_job: true,
739            }],
740            capabilities: Capabilities::default(),
741        };
742        let sessions = sessions_for_worktree(&snapshot, Path::new("/repo"), &[]).unwrap();
743        assert_eq!(sessions.len(), 1);
744        assert_eq!(sessions[0].session_id, SessionId(3));
745        assert_eq!(sessions[0].display_name, "shell");
746        assert_eq!(sessions[0].pane_id, PaneId(6));
747        assert_eq!(sessions[0].revision, 4);
748        assert_eq!(sessions[0].panes[1].revision, 9);
749        assert_eq!(sessions[0].panes.len(), 2);
750        assert_eq!(sessions[0].panes[0].label, "primary");
751        assert_eq!(sessions[0].panes[1].label, "split");
752        assert_eq!(sessions[0].listening_ports(), vec![3000, 5173]);
753        assert!(sessions[0].has_foreground_job());
754
755        let mut workspace = WorkspaceState {
756            projects: vec![Project {
757                name: "repo".into(),
758                path: "/repo".into(),
759                default_branch: "main".into(),
760                last_agent_active_unix_ms: None,
761                last_terminal_active_unix_ms: None,
762                worktrees: Vec::new(),
763                routines: Vec::new(),
764                routine_revision: 0,
765                routines_expanded: true,
766                config: None,
767                expanded: true,
768                missing: false,
769            }],
770        };
771        refresh_sessions_from_snapshot(&mut workspace, &snapshot).unwrap();
772        assert_eq!(workspace.projects[0].last_agent_active_unix_ms, Some(42));
773        assert_eq!(workspace.projects[0].last_terminal_active_unix_ms, Some(43));
774    }
775
776    #[test]
777    fn discovery_lists_each_registered_project_once() {
778        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
779            .join(".work")
780            .join(format!("discovery-{}", std::process::id()));
781        let _ = std::fs::remove_dir_all(&root);
782        let paths = [root.join("one"), root.join("two")];
783        for path in &paths {
784            std::fs::create_dir_all(path.join(".git")).unwrap();
785        }
786        let config = GlobalConfig {
787            projects: paths
788                .iter()
789                .map(|path| crate::config::global::ProjectEntry {
790                    name: path.file_name().unwrap().to_string_lossy().into_owned(),
791                    path: path.clone(),
792                    groups: Vec::new(),
793                    aliases: HashMap::new(),
794                })
795                .collect(),
796            ..GlobalConfig::default()
797        };
798        let shell = workspace_from_config(&config);
799        assert_eq!(shell.projects.len(), paths.len());
800        assert!(shell
801            .projects
802            .iter()
803            .all(|project| project.worktrees.is_empty()));
804        let calls = std::cell::Cell::new(0usize);
805
806        let discovery = discover_workspace_with(&config, |path| {
807            calls.set(calls.get() + 1);
808            Ok(vec![git_worktree::WorktreeEntry {
809                name: "main".into(),
810                path: path.to_path_buf(),
811                branch: "trunk".into(),
812                is_main: true,
813            }])
814        })
815        .unwrap();
816
817        assert_eq!(calls.get(), paths.len());
818        assert_eq!(discovery.into_worktrees().len(), paths.len());
819        let failed =
820            discover_workspace_with(&config, |_| Err(anyhow!("worktree discovery failed")));
821        assert!(failed.is_err());
822        std::fs::remove_dir_all(root).unwrap();
823    }
824
825    #[test]
826    fn session_worktree_fast_path_skips_synchronization() {
827        let (snapshot, worktree_id) = snapshot_with_worktree(
828            snapshot_with_worktree_path("/repo"),
829            Path::new("/repo"),
830            || panic!("synchronized snapshot must not refresh"),
831        )
832        .unwrap();
833
834        assert_eq!(snapshot.worktrees[0].path, Path::new("/repo"));
835        assert_eq!(worktree_id, WorktreeId(2));
836    }
837
838    #[test]
839    fn missing_session_worktree_retries_synchronization_once() {
840        let calls = std::cell::Cell::new(0usize);
841        let (snapshot, worktree_id) = snapshot_with_worktree(
842            snapshot_with_worktree_path("/other"),
843            Path::new("/repo"),
844            || {
845                calls.set(calls.get() + 1);
846                Ok(snapshot_with_worktree_path("/repo"))
847            },
848        )
849        .unwrap();
850
851        assert_eq!(calls.get(), 1);
852        assert_eq!(snapshot.worktrees[0].path, Path::new("/repo"));
853        assert_eq!(worktree_id, WorktreeId(2));
854    }
855
856    #[test]
857    fn missing_session_worktree_still_fails_after_one_synchronization() {
858        let calls = std::cell::Cell::new(0usize);
859        let error = snapshot_with_worktree(
860            snapshot_with_worktree_path("/other"),
861            Path::new("/repo"),
862            || {
863                calls.set(calls.get() + 1);
864                Ok(snapshot_with_worktree_path("/still-other"))
865            },
866        )
867        .unwrap_err();
868
869        assert_eq!(calls.get(), 1);
870        assert_eq!(
871            error.to_string(),
872            "worktree is not synchronized with wsx daemon"
873        );
874    }
875
876    fn snapshot_with_worktree_path(path: &str) -> Snapshot {
877        Snapshot {
878            protocol: runtime::PROTOCOL_VERSION,
879            epoch: 1,
880            revision: 1,
881            projects: vec![RuntimeProject {
882                id: ProjectId(1),
883                path: "/repo".into(),
884                name: "repo".into(),
885                revision: 1,
886                last_agent_active_unix_ms: None,
887                last_terminal_active_unix_ms: None,
888            }],
889            worktrees: vec![Worktree {
890                id: WorktreeId(2),
891                project_id: ProjectId(1),
892                path: path.into(),
893                branch: "main".into(),
894                revision: 1,
895            }],
896            sessions: Vec::new(),
897            panes: Vec::new(),
898            listening_ports: Vec::new(),
899            pane_activity: Vec::new(),
900            capabilities: Capabilities::default(),
901        }
902    }
903
904    #[test]
905    fn register_project_rejects_empty_paths() {
906        let mut config = GlobalConfig::default();
907        assert!(register_project(PathBuf::new(), &mut config).is_err());
908    }
909}