1use std::collections::{BTreeSet, HashMap};
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::sync::{Mutex, MutexGuard, PoisonError};
21use std::time::Duration;
22
23use anyhow::{anyhow, bail, Context, Result};
24use async_trait::async_trait;
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use serde_json::{json, Value};
28
29use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
30
31pub const SERVICE_NAME: &str = "worktrees";
33
34const DEFAULT_TTL: Duration = Duration::from_secs(30);
39
40const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
43
44#[derive(Debug, Clone, Deserialize)]
49struct RegisterRequest {
50 key: String,
52 #[serde(default)]
54 folders: Vec<PathBuf>,
55 #[serde(default)]
57 repo: Option<String>,
58 #[serde(default)]
60 title: Option<String>,
61 #[serde(default)]
63 pid: Option<u32>,
64}
65
66#[derive(Debug, Clone, Serialize)]
69struct WindowEntry {
70 key: String,
72 folders: Vec<PathBuf>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 repo: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 title: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pid: Option<u32>,
83 last_seen: DateTime<Utc>,
85}
86
87pub struct WorktreesService {
89 windows: Mutex<HashMap<String, WindowEntry>>,
91 ttl: Duration,
93}
94
95impl WorktreesService {
96 #[must_use]
98 pub fn new() -> Self {
99 Self {
100 windows: Mutex::new(HashMap::new()),
101 ttl: DEFAULT_TTL,
102 }
103 }
104
105 fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
108 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
109 }
110
111 fn live_entries(&self, now: DateTime<Utc>) -> Vec<WindowEntry> {
114 let mut windows = self.lock();
115 reap(&mut windows, self.ttl, now);
116 sorted_entries(&windows)
117 }
118}
119
120impl Default for WorktreesService {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126#[async_trait]
127impl DaemonService for WorktreesService {
128 fn name(&self) -> &'static str {
129 SERVICE_NAME
130 }
131
132 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
133 let now = Utc::now();
134 match op {
135 "register" => {
136 let req: RegisterRequest =
137 serde_json::from_value(payload).context("invalid `register` payload")?;
138 if req.key.trim().is_empty() {
139 bail!("`register` requires a non-empty `key`");
140 }
141 let mut windows = self.lock();
142 reap(&mut windows, self.ttl, now);
143 windows.insert(
144 req.key.clone(),
145 WindowEntry {
146 key: req.key,
147 folders: req.folders,
148 repo: req.repo,
149 title: req.title,
150 pid: req.pid,
151 last_seen: now,
152 },
153 );
154 Ok(json!({ "ok": true }))
155 }
156 "heartbeat" => {
157 let key = require_key(&payload, "heartbeat")?;
158 let mut windows = self.lock();
159 reap(&mut windows, self.ttl, now);
160 let known = match windows.get_mut(key) {
164 Some(entry) => {
165 entry.last_seen = now;
166 true
167 }
168 None => false,
169 };
170 Ok(json!({ "known": known }))
171 }
172 "unregister" => {
173 let key = require_key(&payload, "unregister")?;
174 let mut windows = self.lock();
175 let removed = windows.remove(key).is_some();
176 reap(&mut windows, self.ttl, now);
177 Ok(json!({ "removed": removed }))
178 }
179 "list" => Ok(json!({ "windows": self.live_entries(now) })),
180 other => bail!("unknown worktrees op: {other}"),
181 }
182 }
183
184 fn menu(&self) -> MenuSnapshot {
185 let entries = self.live_entries(Utc::now());
186 let items = if entries.is_empty() {
187 vec![MenuItem::Label("No open windows".to_string())]
188 } else {
189 window_menu_items(&entries)
190 };
191 MenuSnapshot {
192 title: "Worktrees".to_string(),
193 items,
194 }
195 }
196
197 async fn menu_action(&self, action_id: &str) -> Result<()> {
198 if let Some(key) = action_id.strip_prefix("focus:") {
199 let folder = {
202 let windows = self.lock();
203 windows.get(key).and_then(|e| e.folders.first().cloned())
204 };
205 let folder = folder
206 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
207 focus_window(&folder)?;
208 return Ok(());
209 }
210 bail!("unknown worktrees menu action: {action_id}")
211 }
212
213 async fn status(&self) -> ServiceStatus {
214 let entries = self.live_entries(Utc::now());
215 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
216 ServiceStatus {
217 name: SERVICE_NAME.to_string(),
218 healthy: true,
219 summary: format!("{} window(s) across {} repo(s)", entries.len(), repos.len()),
220 detail: json!({ "windows": entries }),
221 }
222 }
223
224 async fn shutdown(&self) {
225 }
227}
228
229fn require_key<'a>(payload: &'a Value, op: &str) -> Result<&'a str> {
232 payload
233 .get("key")
234 .and_then(Value::as_str)
235 .ok_or_else(|| anyhow!("`{op}` requires `key`"))
236}
237
238fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) {
241 let max_age = ttl.as_secs() as i64;
242 windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
243}
244
245fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
248 let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
249 entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
250 entries
251}
252
253fn display_name(entry: &WindowEntry) -> String {
256 if let Some(repo) = &entry.repo {
257 return repo.clone();
258 }
259 if let Some(folder) = entry.folders.first() {
260 return folder.file_name().map_or_else(
261 || folder.display().to_string(),
262 |n| n.to_string_lossy().into_owned(),
263 );
264 }
265 "(no folder)".to_string()
266}
267
268fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
271 let mut items = Vec::new();
272 for entry in entries {
273 let name = display_name(entry);
274 let label = match &entry.title {
275 Some(title) if title != &name => format!("{name} · {title}"),
276 _ => name,
277 };
278 items.push(MenuItem::Label(label));
279 }
280 items.push(MenuItem::Separator);
281 for entry in entries {
282 if !entry.folders.is_empty() {
284 items.push(MenuItem::Action(MenuAction {
285 id: format!("focus:{}", entry.key),
286 label: format!("Focus {}", display_name(entry)),
287 enabled: true,
288 }));
289 }
290 }
291 items
292}
293
294const CODE_BINARY_CANDIDATES: &[&str] = &[
297 "/usr/local/bin/code",
298 "/opt/homebrew/bin/code",
299 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
300 "/usr/bin/code",
301];
302
303fn focus_window(folder: &Path) -> Result<()> {
306 focus_window_with(&resolve_code_binary(), folder)
307}
308
309fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
316 if !folder.is_absolute() {
319 bail!(
320 "refusing to focus a non-absolute folder path: {}",
321 folder.display()
322 );
323 }
324 if !folder.is_dir() {
325 bail!("worktree folder no longer exists: {}", folder.display());
326 }
327 let child = Command::new(program)
330 .arg(folder)
331 .stdin(Stdio::null())
332 .stdout(Stdio::null())
333 .stderr(Stdio::null())
334 .spawn()
335 .with_context(|| {
336 format!(
337 "failed to launch `{}` to focus {}",
338 program.display(),
339 folder.display()
340 )
341 })?;
342 std::thread::spawn(move || {
344 let mut child = child;
345 let _ = child.wait();
346 });
347 Ok(())
348}
349
350fn resolve_code_binary() -> PathBuf {
355 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
356}
357
358fn resolve_code_binary_from(
361 env_override: Option<std::ffi::OsString>,
362 candidates: &[&str],
363) -> PathBuf {
364 if let Some(path) = env_override {
365 return PathBuf::from(path);
366 }
367 for candidate in candidates {
368 let path = Path::new(candidate);
369 if path.exists() {
370 return path.to_path_buf();
371 }
372 }
373 PathBuf::from("code")
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used, clippy::expect_used)]
378mod tests {
379 use super::*;
380
381 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
382 json!({
383 "key": key,
384 "folders": [folder],
385 "repo": repo,
386 "title": format!("{key}-title"),
387 "pid": 1234,
388 })
389 }
390
391 fn windows_of(payload: &Value) -> &Vec<Value> {
393 payload
394 .get("windows")
395 .and_then(Value::as_array)
396 .expect("windows array")
397 }
398
399 #[tokio::test]
400 async fn name_and_unknown_op() {
401 let svc = WorktreesService::new();
402 assert_eq!(svc.name(), "worktrees");
403 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
404 }
405
406 #[tokio::test]
407 async fn list_is_empty_initially() {
408 let svc = WorktreesService::new();
409 let payload = svc.handle("list", Value::Null).await.unwrap();
410 assert_eq!(payload, json!({ "windows": [] }));
411 }
412
413 #[tokio::test]
414 async fn register_then_list_round_trips() {
415 let svc = WorktreesService::new();
416 let reply = svc
417 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
418 .await
419 .unwrap();
420 assert_eq!(reply, json!({ "ok": true }));
421
422 let payload = svc.handle("list", Value::Null).await.unwrap();
423 let windows = windows_of(&payload);
424 assert_eq!(windows.len(), 1);
425 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
426 assert_eq!(
427 windows[0].get("repo").and_then(Value::as_str),
428 Some("repo-a")
429 );
430 assert!(windows[0].get("last_seen").is_some());
431 }
432
433 #[tokio::test]
434 async fn register_is_idempotent_upsert() {
435 let svc = WorktreesService::new();
436 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
437 .await
438 .unwrap();
439 svc.handle("register", register_payload("w1", Some("repo-b"), "/tmp/b"))
441 .await
442 .unwrap();
443 let payload = svc.handle("list", Value::Null).await.unwrap();
444 let windows = windows_of(&payload);
445 assert_eq!(windows.len(), 1);
446 assert_eq!(
447 windows[0].get("repo").and_then(Value::as_str),
448 Some("repo-b")
449 );
450 }
451
452 #[tokio::test]
453 async fn register_requires_key() {
454 let svc = WorktreesService::new();
455 assert!(svc.handle("register", json!({})).await.is_err());
456 assert!(svc
457 .handle("register", json!({ "key": " " }))
458 .await
459 .is_err());
460 }
461
462 #[tokio::test]
463 async fn heartbeat_reports_known_and_unknown() {
464 let svc = WorktreesService::new();
465 let unknown = svc
467 .handle("heartbeat", json!({ "key": "w1" }))
468 .await
469 .unwrap();
470 assert_eq!(unknown, json!({ "known": false }));
471
472 svc.handle("register", register_payload("w1", None, "/tmp/a"))
473 .await
474 .unwrap();
475 let known = svc
476 .handle("heartbeat", json!({ "key": "w1" }))
477 .await
478 .unwrap();
479 assert_eq!(known, json!({ "known": true }));
480 assert!(svc.handle("heartbeat", json!({})).await.is_err());
481 }
482
483 #[tokio::test]
484 async fn unregister_removes() {
485 let svc = WorktreesService::new();
486 svc.handle("register", register_payload("w1", None, "/tmp/a"))
487 .await
488 .unwrap();
489 let gone = svc
490 .handle("unregister", json!({ "key": "w1" }))
491 .await
492 .unwrap();
493 assert_eq!(gone, json!({ "removed": true }));
494 let again = svc
496 .handle("unregister", json!({ "key": "w1" }))
497 .await
498 .unwrap();
499 assert_eq!(again, json!({ "removed": false }));
500 assert!(svc.handle("unregister", json!({})).await.is_err());
501 }
502
503 #[test]
504 fn reap_evicts_only_stale_entries() {
505 let now = Utc::now();
506 let mut windows = HashMap::new();
507 windows.insert(
508 "fresh".to_string(),
509 WindowEntry {
510 key: "fresh".to_string(),
511 folders: vec![],
512 repo: None,
513 title: None,
514 pid: None,
515 last_seen: now - chrono::Duration::seconds(5),
516 },
517 );
518 windows.insert(
519 "stale".to_string(),
520 WindowEntry {
521 key: "stale".to_string(),
522 folders: vec![],
523 repo: None,
524 title: None,
525 pid: None,
526 last_seen: now - chrono::Duration::seconds(120),
527 },
528 );
529 reap(&mut windows, DEFAULT_TTL, now);
530 assert!(windows.contains_key("fresh"));
531 assert!(!windows.contains_key("stale"));
532 }
533
534 #[test]
535 fn sorted_entries_orders_by_repo_then_key() {
536 let now = Utc::now();
537 let mut windows = HashMap::new();
538 for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
539 windows.insert(
540 key.to_string(),
541 WindowEntry {
542 key: key.to_string(),
543 folders: vec![],
544 repo: Some(repo.to_string()),
545 title: None,
546 pid: None,
547 last_seen: now,
548 },
549 );
550 }
551 let entries = sorted_entries(&windows);
552 let ordered: Vec<(&str, &str)> = entries
553 .iter()
554 .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
555 .collect();
556 assert_eq!(
557 ordered,
558 vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
559 );
560 }
561
562 #[test]
563 fn display_name_prefers_repo_then_folder_basename() {
564 let base = WindowEntry {
565 key: "k".to_string(),
566 folders: vec![PathBuf::from("/home/me/project")],
567 repo: Some("my-repo".to_string()),
568 title: None,
569 pid: None,
570 last_seen: Utc::now(),
571 };
572 assert_eq!(display_name(&base), "my-repo");
573
574 let no_repo = WindowEntry {
575 repo: None,
576 ..base.clone()
577 };
578 assert_eq!(display_name(&no_repo), "project");
579
580 let nothing = WindowEntry {
581 repo: None,
582 folders: vec![],
583 ..base.clone()
584 };
585 assert_eq!(display_name(¬hing), "(no folder)");
586
587 let rootish = WindowEntry {
590 repo: None,
591 folders: vec![PathBuf::from("/")],
592 ..base
593 };
594 assert_eq!(display_name(&rootish), "/");
595 }
596
597 #[test]
598 fn default_constructs_an_empty_service() {
599 let svc = WorktreesService::default();
600 assert!(svc.lock().is_empty());
601 }
602
603 #[test]
604 fn window_menu_items_label_omits_redundant_title_and_skips_folderless_actions() {
605 let now = Utc::now();
606 let entries = vec![
607 WindowEntry {
609 key: "k1".to_string(),
610 folders: vec![PathBuf::from("/tmp/a")],
611 repo: Some("repo".to_string()),
612 title: Some("a branch".to_string()),
613 pid: None,
614 last_seen: now,
615 },
616 WindowEntry {
619 key: "k2".to_string(),
620 folders: vec![],
621 repo: Some("solo".to_string()),
622 title: Some("solo".to_string()),
623 pid: None,
624 last_seen: now,
625 },
626 ];
627 let items = window_menu_items(&entries);
628 let labels: Vec<&str> = items
629 .iter()
630 .filter_map(|i| match i {
631 MenuItem::Label(t) => Some(t.as_str()),
632 _ => None,
633 })
634 .collect();
635 assert!(labels.contains(&"repo · a branch"));
636 assert!(labels.contains(&"solo")); let action_ids: Vec<&str> = items
639 .iter()
640 .filter_map(|i| match i {
641 MenuItem::Action(a) => Some(a.id.as_str()),
642 _ => None,
643 })
644 .collect();
645 assert_eq!(action_ids, vec!["focus:k1"]);
647 }
648
649 #[tokio::test]
650 async fn menu_and_status_shapes() {
651 let svc = WorktreesService::new();
652 let menu = svc.menu();
654 assert_eq!(menu.title, "Worktrees");
655 assert!(matches!(
656 menu.items.first(),
657 Some(MenuItem::Label(text)) if text == "No open windows"
658 ));
659 let status = svc.status().await;
660 assert_eq!(status.name, "worktrees");
661 assert!(status.healthy);
662 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
663
664 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
666 .await
667 .unwrap();
668 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
669 .await
670 .unwrap();
671 let status = svc.status().await;
672 assert_eq!(status.summary, "2 window(s) across 1 repo(s)");
673
674 let menu = svc.menu();
675 assert!(menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
677 let action_ids: Vec<&str> = menu
678 .items
679 .iter()
680 .filter_map(|i| match i {
681 MenuItem::Action(a) => Some(a.id.as_str()),
682 _ => None,
683 })
684 .collect();
685 assert!(action_ids.contains(&"focus:w1"));
686 assert!(action_ids.contains(&"focus:w2"));
687 }
688
689 #[tokio::test]
690 async fn menu_action_rejects_unknown_and_missing_window() {
691 let svc = WorktreesService::new();
692 assert!(svc.menu_action("bogus").await.is_err());
693 assert!(svc.menu_action("focus:nope").await.is_err());
695 svc.shutdown().await;
696 }
697
698 struct VscodeBinGuard(Option<std::ffi::OsString>);
702 impl Drop for VscodeBinGuard {
703 fn drop(&mut self) {
704 match self.0.take() {
705 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
706 None => std::env::remove_var(VSCODE_BIN_ENV),
707 }
708 }
709 }
710
711 #[tokio::test]
712 async fn menu_action_focus_resolves_folder_and_spawns() {
713 let dir = tempfile::tempdir().unwrap();
714 let svc = WorktreesService::new();
715 svc.handle(
716 "register",
717 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
718 )
719 .await
720 .unwrap();
721
722 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
725 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
726 svc.menu_action("focus:w1").await.unwrap();
727 }
728
729 #[test]
730 fn focus_window_with_validates_folder_then_spawns() {
731 let dir = tempfile::tempdir().unwrap();
732 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
734 assert!(
735 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
736 );
737 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
739 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
741 }
742
743 #[test]
744 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
745 assert_eq!(
747 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
748 PathBuf::from("/custom/code")
749 );
750 let existing = tempfile::NamedTempFile::new().unwrap();
752 let existing_path = existing.path().to_str().unwrap();
753 assert_eq!(
754 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
755 PathBuf::from(existing_path)
756 );
757 assert_eq!(
759 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
760 PathBuf::from("code")
761 );
762 let _ = resolve_code_binary();
764 }
765}