Skip to main content

omni_dev/daemon/services/
worktrees.rs

1//! The worktrees daemon service.
2//!
3//! Maintains the live, authoritative set of repos/worktrees open across *every*
4//! VS Code window, fed by a first-party companion extension that reports from
5//! each window over the daemon's control socket. The resident daemon is the
6//! rendezvous point the per-window extension sandbox cannot replace: each window
7//! can see only its own `workspace.workspaceFolders`, so a single process
8//! aggregating those registrations is the only cross-window source of truth.
9//! See ADR-0040.
10//!
11//! Like the Snowflake service this is a cheap, in-memory adapter — no async
12//! setup, no secret persisted. The registry lives behind a [`std::sync::Mutex`]
13//! that is **never held across an `.await`** (the Snowflake rule); every op is
14//! pure CPU under the lock, so liveness reaping happens inline on each read
15//! rather than from a background task.
16
17use 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
31/// The worktrees service name (the control-socket routing key).
32pub const SERVICE_NAME: &str = "worktrees";
33
34/// How long a window may go silent before it ages out of the registry. Three
35/// missed ~10s heartbeats; a window that crashed without firing `unregister`
36/// disappears on the next read. The resident process is what makes this
37/// liveness correct — a flat shared file could not reap stale entries.
38const DEFAULT_TTL: Duration = Duration::from_secs(30);
39
40/// Environment override for the VS Code launcher used by the "focus" tray
41/// action, for when the daemon runs under launchd with a minimal `PATH`.
42const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
43
44/// A `register` request from a companion extension. The companion owns its
45/// `key` (a per-`activate()` UUID) so the daemon never has to reason about
46/// whether `vscode.env.sessionId` is unique per window; everything else is
47/// best-effort metadata.
48#[derive(Debug, Clone, Deserialize)]
49struct RegisterRequest {
50    /// Stable per-window identity, generated by the companion on activation.
51    key: String,
52    /// Absolute paths of the window's workspace folders.
53    #[serde(default)]
54    folders: Vec<PathBuf>,
55    /// Repository root or name, when the window has one.
56    #[serde(default)]
57    repo: Option<String>,
58    /// The window title, for display.
59    #[serde(default)]
60    title: Option<String>,
61    /// The reporting extension-host process id.
62    #[serde(default)]
63    pid: Option<u32>,
64}
65
66/// One open window's live registration. Serialized verbatim into `list` /
67/// `status` payloads; consumers compute age from `last_seen` (RFC 3339).
68#[derive(Debug, Clone, Serialize)]
69struct WindowEntry {
70    /// The companion-owned per-window key.
71    key: String,
72    /// Absolute workspace-folder paths.
73    folders: Vec<PathBuf>,
74    /// Repository root or name, if reported.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    repo: Option<String>,
77    /// Window title, if reported.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    title: Option<String>,
80    /// Reporting extension-host pid, if reported.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pid: Option<u32>,
83    /// When the daemon last heard from this window (register or heartbeat).
84    last_seen: DateTime<Utc>,
85}
86
87/// Hosts the cross-window worktree registry as a [`DaemonService`].
88pub struct WorktreesService {
89    /// Open windows keyed by their companion-owned `key`.
90    windows: Mutex<HashMap<String, WindowEntry>>,
91    /// How long an entry survives without a heartbeat.
92    ttl: Duration,
93}
94
95impl WorktreesService {
96    /// Creates the service with the default liveness TTL. Cheap — no I/O.
97    #[must_use]
98    pub fn new() -> Self {
99        Self {
100            windows: Mutex::new(HashMap::new()),
101            ttl: DEFAULT_TTL,
102        }
103    }
104
105    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
106    /// critical section must not wedge the whole service).
107    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
108        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
109    }
110
111    /// Reaps stale entries, then returns the live set sorted for deterministic
112    /// output. Holds the lock only for pure-CPU work.
113    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                // `known: false` tells a window that started before the daemon —
161                // or survived a daemon restart — to re-`register`, since the
162                // registry is in-memory and has no record of it.
163                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            // Resolve the folder under the lock, then drop it before spawning —
200            // never hold the mutex across the process launch.
201            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        // In-memory only; nothing to drain or persist.
226    }
227}
228
229/// Extracts a required string `key` from an op payload, erroring with the op
230/// name when it is absent or not a string.
231fn 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
238/// Removes entries last seen longer than `ttl` ago. Pure CPU; the caller holds
239/// the registry lock but never `.await`s while holding it.
240fn 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
245/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
246/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
247fn 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
253/// A short human name for a window: its repo, else its first folder's basename,
254/// else a placeholder.
255fn 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
268/// Builds the tray items for a non-empty window list: a label per window, a
269/// separator, then a "Focus" action per window that has a folder to open.
270fn 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        // No folder means nothing for `code` to open, so omit the action.
283        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
294/// Well-known absolute locations for the VS Code launcher, tried in order so a
295/// daemon running under launchd (with a minimal `PATH`) still finds it.
296const 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
303/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
304/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`].
305fn focus_window(folder: &Path) -> Result<()> {
306    focus_window_with(&resolve_code_binary(), folder)
307}
308
309/// Spawns `program` on `folder` after validating the folder. Split out from
310/// [`focus_window`] so the validation and spawn paths are testable with an
311/// explicit launcher (no environment or installed-editor dependency).
312///
313/// Best-effort and non-blocking: the spawned child is reaped on a detached
314/// thread so a long-lived daemon does not accumulate zombies one per focus.
315fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
316    // Workspace-folder paths are absolute; requiring it also rules out a path
317    // that begins with `-` being parsed by `code` as a flag.
318    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    // Detach the launcher's stdio so its output never interleaves into the
328    // long-lived daemon's own stdout/stderr (or the test harness's).
329    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    // Reap the child without blocking so it never lingers as a zombie.
343    std::thread::spawn(move || {
344        let mut child = child;
345        let _ = child.wait();
346    });
347    Ok(())
348}
349
350/// Resolves the VS Code launcher from the real environment: the
351/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
352/// `code` on `PATH`. The pure resolution logic lives in
353/// [`resolve_code_binary_from`] for testing.
354fn resolve_code_binary() -> PathBuf {
355    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
356}
357
358/// Pure launcher resolution: `env_override` wins; otherwise the first existing
359/// `candidate`; otherwise bare `code`.
360fn 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    /// Pulls the `windows` array out of a `list`/`status` payload.
392    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        // Re-registering the same key updates rather than duplicates.
440        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        // Unknown before registration: the window must re-register.
466        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        // Removing again is a no-op success.
495        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(&nothing), "(no folder)");
586
587        // A folder with no basename (the filesystem root) falls back to its
588        // displayed path rather than panicking or yielding an empty name.
589        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            // Title differs from the repo name → "name · title".
608            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            // Title equals the display name → label is just the name (the
617            // `_ => name` arm), and no folder means no Focus action.
618            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")); // not "solo · solo"
637
638        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        // Only the folder-bearing window gets a Focus action.
646        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        // Empty.
653        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        // With two windows in the same repo.
665        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        // Two labels + a separator + two focus actions.
676        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        // A focus for a key with no registration errors rather than spawning.
694        assert!(svc.menu_action("focus:nope").await.is_err());
695        svc.shutdown().await;
696    }
697
698    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. Only this test reads the variable
699    /// (via `resolve_code_binary` → `focus_window`), so there is no cross-test
700    /// race despite the process-global mutation.
701    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        // Point the launcher at a harmless binary so the spawn deterministically
723        // succeeds and the focus path returns Ok.
724        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        // Non-absolute and missing-directory folders are rejected before spawn.
733        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        // A valid absolute directory spawns the launcher successfully.
738        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
739        // A missing launcher surfaces the spawn error (with context), not Ok.
740        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        // Env override wins outright.
746        assert_eq!(
747            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
748            PathBuf::from("/custom/code")
749        );
750        // No override: the first existing candidate is chosen.
751        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        // Nothing exists: fall back to bare `code` on PATH.
758        assert_eq!(
759            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
760            PathBuf::from("code")
761        );
762        // The real-env wrapper resolves without panicking.
763        let _ = resolve_code_binary();
764    }
765}