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_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}