1use 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}