Skip to main content

omni_dev/
worktrees.rs

1//! The cross-window worktree registry engine.
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//! This is the standalone engine, analogous to [`crate::browser`] and
12//! [`crate::snowflake`]; the daemon adapter lives in
13//! [`crate::daemon::services::worktrees`].
14//!
15//! Like the Snowflake engine this is cheap and in-memory — no async setup, no
16//! secret persisted. The registry lives behind a [`std::sync::Mutex`] that is
17//! **never held across an `.await`** (the Snowflake rule); every op is pure CPU
18//! under the lock, so liveness reaping happens inline on each read rather than
19//! from a background task.
20
21use std::collections::{HashMap, HashSet};
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Mutex, MutexGuard, PoisonError};
25use std::time::Duration;
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use tokio::sync::watch;
30
31/// How long a window may go silent before it ages out of the registry. Three
32/// missed ~10s heartbeats; a window that crashed without firing `unregister`
33/// disappears on the next read. The resident process is what makes this
34/// liveness correct — a flat shared file could not reap stale entries.
35const DEFAULT_TTL: Duration = Duration::from_secs(30);
36
37/// Ceiling on live registry entries, so a misbehaving companion flooding
38/// `register` with distinct keys cannot grow daemon memory faster than the TTL
39/// reaps it (#1140). Far above any real window count; when a new key would
40/// exceed it, the longest-silent entry is evicted instead of rejecting the
41/// request — an evicted live window self-heals via the `heartbeat` →
42/// `{known: false}` → re-register path, so `register` stays infallible for the
43/// companion.
44const MAX_WINDOWS: usize = 256;
45
46/// A `register` request from a companion extension.
47///
48/// The companion owns its `key` (a per-`activate()` UUID) so the registry never
49/// has to reason about whether `vscode.env.sessionId` is unique per window;
50/// everything else is best-effort metadata.
51#[derive(Debug, Clone, Deserialize)]
52pub struct RegisterRequest {
53    /// Stable per-window identity, generated by the companion on activation.
54    pub key: String,
55    /// Absolute paths of the window's workspace folders.
56    #[serde(default)]
57    pub folders: Vec<PathBuf>,
58    /// Repository root or name, when the window has one.
59    #[serde(default)]
60    pub repo: Option<String>,
61    /// The window title, for display.
62    #[serde(default)]
63    pub title: Option<String>,
64    /// The reporting extension-host process id.
65    #[serde(default)]
66    pub pid: Option<u32>,
67}
68
69/// One open window's live registration. Serialized verbatim into `list` /
70/// `status` payloads; consumers compute age from `last_seen` (RFC 3339).
71#[derive(Debug, Clone, Serialize)]
72pub struct WindowEntry {
73    /// The companion-owned per-window key.
74    pub key: String,
75    /// Absolute workspace-folder paths.
76    pub folders: Vec<PathBuf>,
77    /// Repository root or name, if reported.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub repo: Option<String>,
80    /// Window title, if reported.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub title: Option<String>,
83    /// Reporting extension-host pid, if reported.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub pid: Option<u32>,
86    /// When the registry last heard from this window (register or heartbeat).
87    pub last_seen: DateTime<Utc>,
88}
89
90/// The cross-window worktree registry: the in-memory, TTL-reaped set of open
91/// windows. Hosted by
92/// [`WorktreesService`](crate::daemon::services::worktrees::WorktreesService).
93pub struct WorktreesRegistry {
94    /// Open windows keyed by their companion-owned `key`.
95    windows: Mutex<HashMap<String, WindowEntry>>,
96    /// How long an entry survives without a heartbeat.
97    ttl: Duration,
98    /// A monotonically-bumped version counter, incremented whenever the visible
99    /// set of windows changes (a `register`, a removing `unregister`, or a
100    /// mutation-driven reap that drops a stale entry). A push-subscription
101    /// consumer holds a [`watch::Receiver`] from
102    /// [`subscribe_changes`](Self::subscribe_changes) and wakes on each bump to
103    /// re-snapshot (#1267). The counter's *value* is immaterial —
104    /// only that it changed — so a burst coalesces into one wake and the
105    /// subscriber diffs the resulting snapshot to suppress duplicate frames.
106    ///
107    /// `watch` needs no runtime and never blocks, so it fits this engine's
108    /// no-async-setup posture; every [`bump`](Self::bump) happens *after* the map
109    /// guard is dropped, so the `std::Mutex`-never-across-`.await` rule is intact
110    /// (and the watch's own internal lock is never nested under the map lock).
111    changes: watch::Sender<u64>,
112    /// Window keys with a pending "close yourself" directive, set by the
113    /// `close` op (#1277) when a cross-window close must reach a window the
114    /// daemon can only *reply* to, never call. Each key is surfaced — and
115    /// cleared — on that window's next `heartbeat` (the `known:false →
116    /// re-register` precedent, riding the same reply). In-memory only, like the
117    /// window map: a daemon restart drops any pending directive (the close op
118    /// aborts and the user retries — an accepted failure mode). Behind its own
119    /// `Mutex`, taken independently of the window map's, so neither nests.
120    close_pending: Mutex<HashSet<String>>,
121    /// The daemon-backed **show/hide-closed** toggle (#1301): whether the
122    /// companion's tree view shows worktrees with no open window. A single
123    /// cross-window value carried in every `tree`/`subscribe` snapshot so all
124    /// windows read (and live-sync) the same state — `context.globalState` could
125    /// not, being read-once with no cross-window change event. Defaults to `true`
126    /// (show all, the original behavior). A lock-free [`AtomicBool`] rather than a
127    /// `Mutex`, so it is never a `.await`-holding-a-lock hazard; a flip
128    /// [`bump`](Self::bump)s the change-notify so subscribers re-push. In-memory
129    /// like the window map: a daemon restart resets it to the default, which the
130    /// next snapshot propagates to every window.
131    show_closed: AtomicBool,
132}
133
134impl WorktreesRegistry {
135    /// Creates the registry with the default liveness TTL. Cheap — no I/O.
136    #[must_use]
137    pub fn new() -> Self {
138        Self {
139            windows: Mutex::new(HashMap::new()),
140            ttl: DEFAULT_TTL,
141            changes: watch::channel(0).0,
142            close_pending: Mutex::new(HashSet::new()),
143            show_closed: AtomicBool::new(true),
144        }
145    }
146
147    /// A change-notification receiver for the push subscription: it observes a
148    /// new value each time the visible window set changes (see [`changes`] and
149    /// [`bump`]). Created with the current version already marked seen, so the
150    /// first [`watch::Receiver::changed`] resolves on the *next* change — the
151    /// subscriber sends its own initial snapshot up front and then waits for
152    /// deltas (#1267).
153    ///
154    /// [`changes`]: Self::changes
155    /// [`bump`]: Self::bump
156    #[must_use]
157    pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
158        self.changes.subscribe()
159    }
160
161    /// Signals subscribers that the visible state changed. Non-blocking and
162    /// runtime-free; called only *after* the map guard is released so the two
163    /// locks never nest. A send never fails here (the sender is owned by the
164    /// registry, which outlives every receiver, and `send_modify` bumps even
165    /// with no receivers).
166    ///
167    /// Visible outside the registry so the daemon's PR badge poller can signal a
168    /// changed CI verdict (#1337) — the tree snapshot carries more than the window
169    /// set. Callers must bump **only on a real change**: an unconditional bump
170    /// defeats the server's snapshot diff and re-pushes to every window on every
171    /// tick.
172    pub(crate) fn bump(&self) {
173        self.changes.send_modify(|v| *v = v.wrapping_add(1));
174    }
175
176    /// The current change-notify generation — the counter [`bump`](Self::bump)
177    /// increments on every visible-set change. Read (never subscribed) so a
178    /// coalescing consumer — the service's shared tree-snapshot cache (#1303) —
179    /// can tell whether the registry has changed since it last computed and
180    /// rebuild only then. The value itself is immaterial; only whether it
181    /// differs between two reads matters, so `wrapping_add` overflow is benign.
182    #[must_use]
183    pub fn change_generation(&self) -> u64 {
184        *self.changes.borrow()
185    }
186
187    /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
188    /// critical section must not wedge the whole registry).
189    fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
190        self.windows.lock().unwrap_or_else(PoisonError::into_inner)
191    }
192
193    /// Records (upserts) a window registration. Reaps stale entries first, then
194    /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
195    /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
196    /// callers validate the `key` before reaching here.
197    pub fn register(&self, req: RegisterRequest) {
198        let now = Utc::now();
199        {
200            let mut windows = self.lock();
201            reap(&mut windows, self.ttl, now);
202            // Upserts never evict; only a genuinely new key can grow the map, and
203            // never past MAX_WINDOWS.
204            if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
205                evict_oldest(&mut windows);
206            }
207            windows.insert(
208                req.key.clone(),
209                WindowEntry {
210                    key: req.key,
211                    folders: req.folders,
212                    repo: req.repo,
213                    title: req.title,
214                    pid: req.pid,
215                    last_seen: now,
216                },
217            );
218        }
219        // Always bump: a register is infrequent (once per companion `activate()`,
220        // not per heartbeat) and may add or alter a window's folders/repo. A
221        // no-op re-register with identical data is harmless — the subscriber
222        // diffs the snapshot and suppresses the duplicate frame.
223        self.bump();
224    }
225
226    /// Refreshes a window's liveness. Returns whether the key was known: a
227    /// `false` tells a window that started before the daemon — or survived a
228    /// daemon restart — to re-`register`, since the registry is in-memory and
229    /// has no record of it.
230    pub fn heartbeat(&self, key: &str) -> bool {
231        let now = Utc::now();
232        let (known, reaped) = {
233            let mut windows = self.lock();
234            let reaped = reap(&mut windows, self.ttl, now);
235            let known = match windows.get_mut(key) {
236                Some(entry) => {
237                    entry.last_seen = now;
238                    true
239                }
240                None => false,
241            };
242            (known, reaped)
243        };
244        // A heartbeat is frequent (~every 10 s per window); a pure liveness
245        // refresh does not change the visible set, so bump *only* when this
246        // heartbeat's inline reap actually aged a stale sibling out.
247        if reaped > 0 {
248            self.bump();
249        }
250        known
251    }
252
253    /// Drops a window's registration. Returns whether an entry was present.
254    pub fn unregister(&self, key: &str) -> bool {
255        let now = Utc::now();
256        let (removed, reaped) = {
257            let mut windows = self.lock();
258            let removed = windows.remove(key).is_some();
259            let reaped = reap(&mut windows, self.ttl, now);
260            (removed, reaped)
261        };
262        // The window is gone; any close directive for it is fulfilled or moot.
263        // (Keys are per-`activate()` UUIDs, never reused, so a stale directive
264        // would only ever leak a little memory — but clearing keeps it tidy.)
265        self.take_close_pending(key);
266        if removed || reaped > 0 {
267            self.bump();
268        }
269        removed
270    }
271
272    /// Records a pending "close yourself" directive for `key`, to be surfaced on
273    /// that window's next `heartbeat`. Set by the `close` op when signalling a
274    /// window it can only reply to. Idempotent; infallible.
275    pub fn mark_close_pending(&self, key: &str) {
276        self.close_pending
277            .lock()
278            .unwrap_or_else(PoisonError::into_inner)
279            .insert(key.to_string());
280    }
281
282    /// Takes (returns and clears) `key`'s pending close directive. Called on
283    /// each `heartbeat` so the directive fires exactly once; a `false` means no
284    /// close is pending.
285    pub fn take_close_pending(&self, key: &str) -> bool {
286        self.close_pending
287            .lock()
288            .unwrap_or_else(PoisonError::into_inner)
289            .remove(key)
290    }
291
292    /// The current show/hide-closed toggle: whether the tree view shows
293    /// worktrees with no open window (#1301). Read into every `tree`/`subscribe`
294    /// snapshot so every window renders the same, live-synced state.
295    #[must_use]
296    pub fn show_closed(&self) -> bool {
297        self.show_closed.load(Ordering::Relaxed)
298    }
299
300    /// Sets the show/hide-closed toggle, returning whether the value actually
301    /// changed. A real change [`bump`](Self::bump)s the change-notify so every
302    /// subscriber re-pushes a snapshot carrying the new value — the reliable
303    /// cross-window sync `context.globalState` could not provide. A no-op set
304    /// (same value) neither bumps nor wakes anyone.
305    pub fn set_show_closed(&self, show_closed: bool) -> bool {
306        let changed = self.show_closed.swap(show_closed, Ordering::Relaxed) != show_closed;
307        if changed {
308            self.bump();
309        }
310        changed
311    }
312
313    /// Reaps stale entries, then returns the live set sorted for deterministic
314    /// output. Holds the lock only for pure-CPU work.
315    ///
316    /// Like the other reads ([`open_folders`](Self::open_folders),
317    /// [`first_folder`](Self::first_folder)) this reaps but never
318    /// [`bump`](Self::bump)s: the only observer of a read-path reap is the push
319    /// subscription's own re-snapshot (or `status`/`menu`), and the
320    /// subscription's periodic tick already re-samples read-only staleness — so
321    /// bumping here would only make the subscription wake itself (#1267).
322    pub fn list(&self) -> Vec<WindowEntry> {
323        let now = Utc::now();
324        let mut windows = self.lock();
325        reap(&mut windows, self.ttl, now);
326        sorted_entries(&windows)
327    }
328
329    /// The first workspace folder of a still-live window, if it has one. Used by
330    /// the tray "focus" action to resolve a key to a folder to open. Does not
331    /// reap — a menu action races the reaper either way, and the caller handles
332    /// a `None` (the window may have closed).
333    pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
334        let windows = self.lock();
335        windows.get(key).and_then(|e| e.folders.first().cloned())
336    }
337
338    /// Snapshots the distinct workspace folders across all live windows — the
339    /// seed set the adapter resolves to repositories (each folder → its git
340    /// common dir → repo root) to enumerate every worktree per repo (#1265).
341    ///
342    /// Reaps stale entries first, then returns the folders sorted and
343    /// deduplicated. Like [`list`](Self::list) it is pure CPU under the lock:
344    /// the git resolution the "distinct repos" derivation needs is disk I/O and
345    /// stays in the adapter, off the registry lock, honouring the
346    /// `Mutex`-never-across-`.await` invariant.
347    pub fn open_folders(&self) -> Vec<PathBuf> {
348        let now = Utc::now();
349        let mut windows = self.lock();
350        reap(&mut windows, self.ttl, now);
351        let mut folders: Vec<PathBuf> = windows
352            .values()
353            .flat_map(|e| e.folders.iter().cloned())
354            .collect();
355        folders.sort();
356        folders.dedup();
357        folders
358    }
359}
360
361impl Default for WorktreesRegistry {
362    fn default() -> Self {
363        Self::new()
364    }
365}
366
367/// Removes entries last seen longer than `ttl` ago, returning how many were
368/// dropped. Pure CPU; the caller holds the registry lock but never `.await`s
369/// while holding it. The count lets a *mutation* path
370/// ([`register`](WorktreesRegistry::register) et al.) decide whether to
371/// [`bump`](WorktreesRegistry::bump) the change-notify; read paths ignore it (see
372/// [`list`](WorktreesRegistry::list)).
373fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) -> usize {
374    let max_age = ttl.as_secs() as i64;
375    let before = windows.len();
376    windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
377    before - windows.len()
378}
379
380/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
381/// for determinism). Called when a `register` of a new key would grow the
382/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
383/// [`reap`].
384fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
385    let oldest = windows
386        .values()
387        .min_by(|a, b| {
388            a.last_seen
389                .cmp(&b.last_seen)
390                .then_with(|| a.key.cmp(&b.key))
391        })
392        .map(|e| e.key.clone());
393    if let Some(key) = oldest {
394        windows.remove(&key);
395    }
396}
397
398/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
399/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
400fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
401    let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
402    entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
403    entries
404}
405
406#[cfg(test)]
407#[allow(clippy::unwrap_used, clippy::expect_used)]
408mod tests {
409    use super::*;
410
411    fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
412        RegisterRequest {
413            key: key.to_string(),
414            folders: vec![PathBuf::from(folder)],
415            repo: repo.map(str::to_string),
416            title: Some(format!("{key}-title")),
417            pid: Some(1234),
418        }
419    }
420
421    #[test]
422    fn list_is_empty_initially() {
423        let reg = WorktreesRegistry::new();
424        assert!(reg.list().is_empty());
425    }
426
427    #[test]
428    fn register_then_list_round_trips() {
429        let reg = WorktreesRegistry::new();
430        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
431        let windows = reg.list();
432        assert_eq!(windows.len(), 1);
433        assert_eq!(windows[0].key, "w1");
434        assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
435    }
436
437    #[test]
438    fn register_is_idempotent_upsert() {
439        let reg = WorktreesRegistry::new();
440        reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
441        // Re-registering the same key updates rather than duplicates.
442        reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
443        let windows = reg.list();
444        assert_eq!(windows.len(), 1);
445        assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
446    }
447
448    #[test]
449    fn heartbeat_reports_known_and_unknown() {
450        let reg = WorktreesRegistry::new();
451        // Unknown before registration: the window must re-register.
452        assert!(!reg.heartbeat("w1"));
453        reg.register(register_request("w1", None, "/tmp/a"));
454        assert!(reg.heartbeat("w1"));
455    }
456
457    #[test]
458    fn unregister_removes() {
459        let reg = WorktreesRegistry::new();
460        reg.register(register_request("w1", None, "/tmp/a"));
461        assert!(reg.unregister("w1"));
462        // Removing again is a no-op.
463        assert!(!reg.unregister("w1"));
464    }
465
466    #[test]
467    fn first_folder_returns_first_folder_or_none() {
468        let reg = WorktreesRegistry::new();
469        // No such key.
470        assert!(reg.first_folder("missing").is_none());
471        reg.register(register_request("w1", None, "/tmp/a"));
472        assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
473        // A folderless window resolves to None rather than a folder.
474        reg.register(RegisterRequest {
475            key: "w2".to_string(),
476            folders: vec![],
477            repo: None,
478            title: None,
479            pid: None,
480        });
481        assert!(reg.first_folder("w2").is_none());
482    }
483
484    #[test]
485    fn open_folders_dedups_and_sorts_across_windows() {
486        let reg = WorktreesRegistry::new();
487        assert!(reg.open_folders().is_empty());
488        // Two windows sharing a folder, plus a multi-folder window: the shared
489        // path collapses and the result is sorted.
490        reg.register(register_request("w1", Some("repo-a"), "/tmp/shared"));
491        reg.register(RegisterRequest {
492            key: "w2".to_string(),
493            folders: vec![PathBuf::from("/tmp/shared"), PathBuf::from("/tmp/b")],
494            repo: Some("repo-a".to_string()),
495            title: None,
496            pid: None,
497        });
498        reg.register(register_request("w3", Some("repo-b"), "/tmp/a"));
499        assert_eq!(
500            reg.open_folders(),
501            vec![
502                PathBuf::from("/tmp/a"),
503                PathBuf::from("/tmp/b"),
504                PathBuf::from("/tmp/shared"),
505            ]
506        );
507    }
508
509    #[test]
510    fn open_folders_reaps_stale_windows() {
511        let reg = WorktreesRegistry::new();
512        {
513            let mut windows = reg.lock();
514            windows.insert(
515                "fresh".to_string(),
516                WindowEntry {
517                    key: "fresh".to_string(),
518                    folders: vec![PathBuf::from("/tmp/fresh")],
519                    repo: None,
520                    title: None,
521                    pid: None,
522                    last_seen: Utc::now(),
523                },
524            );
525            windows.insert(
526                "stale".to_string(),
527                WindowEntry {
528                    key: "stale".to_string(),
529                    folders: vec![PathBuf::from("/tmp/stale")],
530                    repo: None,
531                    title: None,
532                    pid: None,
533                    last_seen: Utc::now() - chrono::Duration::seconds(120),
534                },
535            );
536        }
537        // The stale window's folder is reaped out of the snapshot.
538        assert_eq!(reg.open_folders(), vec![PathBuf::from("/tmp/fresh")]);
539    }
540
541    #[test]
542    fn reap_evicts_only_stale_entries() {
543        let now = Utc::now();
544        let mut windows = HashMap::new();
545        windows.insert(
546            "fresh".to_string(),
547            WindowEntry {
548                key: "fresh".to_string(),
549                folders: vec![],
550                repo: None,
551                title: None,
552                pid: None,
553                last_seen: now - chrono::Duration::seconds(5),
554            },
555        );
556        windows.insert(
557            "stale".to_string(),
558            WindowEntry {
559                key: "stale".to_string(),
560                folders: vec![],
561                repo: None,
562                title: None,
563                pid: None,
564                last_seen: now - chrono::Duration::seconds(120),
565            },
566        );
567        reap(&mut windows, DEFAULT_TTL, now);
568        assert!(windows.contains_key("fresh"));
569        assert!(!windows.contains_key("stale"));
570    }
571
572    /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
573    /// participate in eviction order.
574    fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
575        WindowEntry {
576            key: key.to_string(),
577            folders: vec![],
578            repo: None,
579            title: None,
580            pid: None,
581            last_seen,
582        }
583    }
584
585    #[test]
586    fn evict_oldest_removes_oldest_with_key_tiebreak() {
587        let now = Utc::now();
588        let mut windows = HashMap::new();
589        windows.insert("young".to_string(), entry_at("young", now));
590        windows.insert(
591            "old-b".to_string(),
592            entry_at("old-b", now - chrono::Duration::seconds(10)),
593        );
594        windows.insert(
595            "old-a".to_string(),
596            entry_at("old-a", now - chrono::Duration::seconds(10)),
597        );
598        // Oldest `last_seen` is shared by two entries; the lowest key loses.
599        evict_oldest(&mut windows);
600        assert!(!windows.contains_key("old-a"));
601        assert!(windows.contains_key("old-b"));
602        assert!(windows.contains_key("young"));
603        // Empty map is a no-op rather than a panic.
604        let mut empty: HashMap<String, WindowEntry> = HashMap::new();
605        evict_oldest(&mut empty);
606        assert!(empty.is_empty());
607    }
608
609    #[test]
610    fn register_at_cap_evicts_only_the_oldest() {
611        let reg = WorktreesRegistry::new();
612        // Seed a full registry directly (registering 256 times would work too,
613        // but sub-second timestamps may tie; explicit timestamps make the
614        // highest-numbered key unambiguously the oldest).
615        {
616            let mut windows = reg.lock();
617            let base = Utc::now();
618            for i in 0..MAX_WINDOWS {
619                let key = format!("w{i:03}");
620                windows.insert(
621                    key.clone(),
622                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
623                );
624            }
625        }
626        // A new key at the cap displaces exactly the longest-silent entry.
627        reg.register(register_request("fresh", None, "/tmp/f"));
628        let windows = reg.lock();
629        assert_eq!(windows.len(), MAX_WINDOWS);
630        assert!(windows.contains_key("fresh"));
631        assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
632        assert!(windows.contains_key("w000"));
633    }
634
635    #[test]
636    fn register_upsert_at_cap_does_not_evict() {
637        let reg = WorktreesRegistry::new();
638        {
639            let mut windows = reg.lock();
640            let base = Utc::now();
641            for i in 0..MAX_WINDOWS {
642                let key = format!("w{i:03}");
643                windows.insert(
644                    key.clone(),
645                    entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
646                );
647            }
648        }
649        // Re-registering an existing key is an upsert: nothing is displaced,
650        // not even the oldest entry.
651        let oldest = format!("w{:03}", MAX_WINDOWS - 1);
652        reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
653        let windows = reg.lock();
654        assert_eq!(windows.len(), MAX_WINDOWS);
655        assert!(windows.contains_key(&oldest));
656        assert!(windows.contains_key("w000"));
657    }
658
659    #[test]
660    fn sorted_entries_orders_by_repo_then_key() {
661        let now = Utc::now();
662        let mut windows = HashMap::new();
663        for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
664            windows.insert(
665                key.to_string(),
666                WindowEntry {
667                    key: key.to_string(),
668                    folders: vec![],
669                    repo: Some(repo.to_string()),
670                    title: None,
671                    pid: None,
672                    last_seen: now,
673                },
674            );
675        }
676        let entries = sorted_entries(&windows);
677        let ordered: Vec<(&str, &str)> = entries
678            .iter()
679            .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
680            .collect();
681        assert_eq!(
682            ordered,
683            vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
684        );
685    }
686
687    #[test]
688    fn default_constructs_an_empty_registry() {
689        let reg = WorktreesRegistry::default();
690        assert!(reg.lock().is_empty());
691    }
692
693    // --- Change-notify for the push subscription (#1267) --------------------
694
695    #[test]
696    fn subscribe_changes_starts_seen_and_register_bumps() {
697        let reg = WorktreesRegistry::new();
698        let mut rx = reg.subscribe_changes();
699        // A fresh receiver has the current version already marked seen.
700        assert!(!rx.has_changed().unwrap());
701        // A register changes the visible set → the receiver observes a new value.
702        reg.register(register_request("w1", None, "/tmp/a"));
703        assert!(rx.has_changed().unwrap(), "register should bump");
704        // Marking it seen clears the pending change.
705        rx.borrow_and_update();
706        assert!(!rx.has_changed().unwrap());
707    }
708
709    #[test]
710    fn unregister_bumps_only_when_it_removes() {
711        let reg = WorktreesRegistry::new();
712        reg.register(register_request("w1", None, "/tmp/a"));
713        // Subscribe *after* the register so its bump is already seen.
714        let rx = reg.subscribe_changes();
715        // Removing a missing key changes nothing (and reaps nothing) → no bump.
716        assert!(!reg.unregister("ghost"));
717        assert!(
718            !rx.has_changed().unwrap(),
719            "a no-op unregister must not bump"
720        );
721        // Removing a present key bumps.
722        assert!(reg.unregister("w1"));
723        assert!(
724            rx.has_changed().unwrap(),
725            "a removing unregister should bump"
726        );
727    }
728
729    #[test]
730    fn change_generation_advances_only_on_a_visible_change() {
731        let reg = WorktreesRegistry::new();
732        let g0 = reg.change_generation();
733        // A no-op (heartbeat of an unknown key) leaves the generation untouched.
734        assert!(!reg.heartbeat("ghost"));
735        assert_eq!(
736            reg.change_generation(),
737            g0,
738            "a no-op must not advance the generation"
739        );
740        // A register changes the visible set → the generation advances, so a
741        // cache keyed on it rebuilds.
742        reg.register(register_request("w1", None, "/tmp/a"));
743        assert_ne!(
744            reg.change_generation(),
745            g0,
746            "a register should advance the generation"
747        );
748    }
749
750    #[test]
751    fn heartbeat_bumps_only_when_it_reaps() {
752        let reg = WorktreesRegistry::new();
753        reg.register(register_request("w1", None, "/tmp/a"));
754        let rx = reg.subscribe_changes();
755        // A plain heartbeat refreshes liveness but changes no visible state.
756        assert!(reg.heartbeat("w1"));
757        assert!(!rx.has_changed().unwrap(), "a pure heartbeat must not bump");
758        // Seed a stale sibling directly; a heartbeat that reaps it *does* bump.
759        {
760            let mut windows = reg.lock();
761            windows.insert(
762                "stale".to_string(),
763                entry_at("stale", Utc::now() - chrono::Duration::seconds(120)),
764            );
765        }
766        assert!(reg.heartbeat("w1"));
767        assert!(
768            rx.has_changed().unwrap(),
769            "a heartbeat that reaps a stale sibling should bump"
770        );
771    }
772
773    // --- Close-pending directive (#1277) -----------------------------------
774
775    #[test]
776    fn close_pending_is_taken_once_then_cleared() {
777        let reg = WorktreesRegistry::new();
778        // No directive by default.
779        assert!(!reg.take_close_pending("w1"));
780        // Marked → the first take observes it, the next does not (fires once).
781        reg.mark_close_pending("w1");
782        assert!(reg.take_close_pending("w1"));
783        assert!(!reg.take_close_pending("w1"));
784    }
785
786    #[test]
787    fn unregister_clears_a_pending_close_directive() {
788        let reg = WorktreesRegistry::new();
789        reg.register(register_request("w1", None, "/tmp/a"));
790        reg.mark_close_pending("w1");
791        // Unregistering the window drops any pending directive with it.
792        assert!(reg.unregister("w1"));
793        assert!(!reg.take_close_pending("w1"));
794    }
795
796    // --- Show/hide-closed toggle (#1301) -----------------------------------
797
798    #[test]
799    fn show_closed_defaults_to_true() {
800        let reg = WorktreesRegistry::new();
801        assert!(reg.show_closed(), "default is show all");
802    }
803
804    #[test]
805    fn set_show_closed_reports_change_and_is_idempotent() {
806        let reg = WorktreesRegistry::new();
807        // Flipping to a new value reports a change and is observable.
808        assert!(reg.set_show_closed(false));
809        assert!(!reg.show_closed());
810        // Setting the same value again is a no-op (no change reported).
811        assert!(!reg.set_show_closed(false));
812        // Flipping back reports a change again.
813        assert!(reg.set_show_closed(true));
814        assert!(reg.show_closed());
815    }
816
817    #[test]
818    fn set_show_closed_bumps_only_on_change() {
819        let reg = WorktreesRegistry::new();
820        let rx = reg.subscribe_changes();
821        // A no-op set (already the default) does not wake subscribers.
822        assert!(!reg.set_show_closed(true));
823        assert!(
824            !rx.has_changed().unwrap(),
825            "a no-op toggle must not bump the change-notify"
826        );
827        // A real flip bumps so subscribers re-push a snapshot with the new value.
828        assert!(reg.set_show_closed(false));
829        assert!(
830            rx.has_changed().unwrap(),
831            "flipping the toggle should bump the change-notify"
832        );
833    }
834}