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 window set 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 fn bump(&self) {
167 self.changes.send_modify(|v| *v = v.wrapping_add(1));
168 }
169
170 /// The current change-notify generation — the counter [`bump`](Self::bump)
171 /// increments on every visible-set change. Read (never subscribed) so a
172 /// coalescing consumer — the service's shared tree-snapshot cache (#1303) —
173 /// can tell whether the registry has changed since it last computed and
174 /// rebuild only then. The value itself is immaterial; only whether it
175 /// differs between two reads matters, so `wrapping_add` overflow is benign.
176 #[must_use]
177 pub fn change_generation(&self) -> u64 {
178 *self.changes.borrow()
179 }
180
181 /// Locks the registry, recovering from a poisoned mutex (a panic in a prior
182 /// critical section must not wedge the whole registry).
183 fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
184 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
185 }
186
187 /// Records (upserts) a window registration. Reaps stale entries first, then
188 /// — only when a genuinely new key would grow the map past [`MAX_WINDOWS`] —
189 /// evicts the longest-silent entry. Infallible: an upsert never evicts, and
190 /// callers validate the `key` before reaching here.
191 pub fn register(&self, req: RegisterRequest) {
192 let now = Utc::now();
193 {
194 let mut windows = self.lock();
195 reap(&mut windows, self.ttl, now);
196 // Upserts never evict; only a genuinely new key can grow the map, and
197 // never past MAX_WINDOWS.
198 if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
199 evict_oldest(&mut windows);
200 }
201 windows.insert(
202 req.key.clone(),
203 WindowEntry {
204 key: req.key,
205 folders: req.folders,
206 repo: req.repo,
207 title: req.title,
208 pid: req.pid,
209 last_seen: now,
210 },
211 );
212 }
213 // Always bump: a register is infrequent (once per companion `activate()`,
214 // not per heartbeat) and may add or alter a window's folders/repo. A
215 // no-op re-register with identical data is harmless — the subscriber
216 // diffs the snapshot and suppresses the duplicate frame.
217 self.bump();
218 }
219
220 /// Refreshes a window's liveness. Returns whether the key was known: a
221 /// `false` tells a window that started before the daemon — or survived a
222 /// daemon restart — to re-`register`, since the registry is in-memory and
223 /// has no record of it.
224 pub fn heartbeat(&self, key: &str) -> bool {
225 let now = Utc::now();
226 let (known, reaped) = {
227 let mut windows = self.lock();
228 let reaped = reap(&mut windows, self.ttl, now);
229 let known = match windows.get_mut(key) {
230 Some(entry) => {
231 entry.last_seen = now;
232 true
233 }
234 None => false,
235 };
236 (known, reaped)
237 };
238 // A heartbeat is frequent (~every 10 s per window); a pure liveness
239 // refresh does not change the visible set, so bump *only* when this
240 // heartbeat's inline reap actually aged a stale sibling out.
241 if reaped > 0 {
242 self.bump();
243 }
244 known
245 }
246
247 /// Drops a window's registration. Returns whether an entry was present.
248 pub fn unregister(&self, key: &str) -> bool {
249 let now = Utc::now();
250 let (removed, reaped) = {
251 let mut windows = self.lock();
252 let removed = windows.remove(key).is_some();
253 let reaped = reap(&mut windows, self.ttl, now);
254 (removed, reaped)
255 };
256 // The window is gone; any close directive for it is fulfilled or moot.
257 // (Keys are per-`activate()` UUIDs, never reused, so a stale directive
258 // would only ever leak a little memory — but clearing keeps it tidy.)
259 self.take_close_pending(key);
260 if removed || reaped > 0 {
261 self.bump();
262 }
263 removed
264 }
265
266 /// Records a pending "close yourself" directive for `key`, to be surfaced on
267 /// that window's next `heartbeat`. Set by the `close` op when signalling a
268 /// window it can only reply to. Idempotent; infallible.
269 pub fn mark_close_pending(&self, key: &str) {
270 self.close_pending
271 .lock()
272 .unwrap_or_else(PoisonError::into_inner)
273 .insert(key.to_string());
274 }
275
276 /// Takes (returns and clears) `key`'s pending close directive. Called on
277 /// each `heartbeat` so the directive fires exactly once; a `false` means no
278 /// close is pending.
279 pub fn take_close_pending(&self, key: &str) -> bool {
280 self.close_pending
281 .lock()
282 .unwrap_or_else(PoisonError::into_inner)
283 .remove(key)
284 }
285
286 /// The current show/hide-closed toggle: whether the tree view shows
287 /// worktrees with no open window (#1301). Read into every `tree`/`subscribe`
288 /// snapshot so every window renders the same, live-synced state.
289 #[must_use]
290 pub fn show_closed(&self) -> bool {
291 self.show_closed.load(Ordering::Relaxed)
292 }
293
294 /// Sets the show/hide-closed toggle, returning whether the value actually
295 /// changed. A real change [`bump`](Self::bump)s the change-notify so every
296 /// subscriber re-pushes a snapshot carrying the new value — the reliable
297 /// cross-window sync `context.globalState` could not provide. A no-op set
298 /// (same value) neither bumps nor wakes anyone.
299 pub fn set_show_closed(&self, show_closed: bool) -> bool {
300 let changed = self.show_closed.swap(show_closed, Ordering::Relaxed) != show_closed;
301 if changed {
302 self.bump();
303 }
304 changed
305 }
306
307 /// Reaps stale entries, then returns the live set sorted for deterministic
308 /// output. Holds the lock only for pure-CPU work.
309 ///
310 /// Like the other reads ([`open_folders`](Self::open_folders),
311 /// [`first_folder`](Self::first_folder)) this reaps but never
312 /// [`bump`](Self::bump)s: the only observer of a read-path reap is the push
313 /// subscription's own re-snapshot (or `status`/`menu`), and the
314 /// subscription's periodic tick already re-samples read-only staleness — so
315 /// bumping here would only make the subscription wake itself (#1267).
316 pub fn list(&self) -> Vec<WindowEntry> {
317 let now = Utc::now();
318 let mut windows = self.lock();
319 reap(&mut windows, self.ttl, now);
320 sorted_entries(&windows)
321 }
322
323 /// The first workspace folder of a still-live window, if it has one. Used by
324 /// the tray "focus" action to resolve a key to a folder to open. Does not
325 /// reap — a menu action races the reaper either way, and the caller handles
326 /// a `None` (the window may have closed).
327 pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
328 let windows = self.lock();
329 windows.get(key).and_then(|e| e.folders.first().cloned())
330 }
331
332 /// Snapshots the distinct workspace folders across all live windows — the
333 /// seed set the adapter resolves to repositories (each folder → its git
334 /// common dir → repo root) to enumerate every worktree per repo (#1265).
335 ///
336 /// Reaps stale entries first, then returns the folders sorted and
337 /// deduplicated. Like [`list`](Self::list) it is pure CPU under the lock:
338 /// the git resolution the "distinct repos" derivation needs is disk I/O and
339 /// stays in the adapter, off the registry lock, honouring the
340 /// `Mutex`-never-across-`.await` invariant.
341 pub fn open_folders(&self) -> Vec<PathBuf> {
342 let now = Utc::now();
343 let mut windows = self.lock();
344 reap(&mut windows, self.ttl, now);
345 let mut folders: Vec<PathBuf> = windows
346 .values()
347 .flat_map(|e| e.folders.iter().cloned())
348 .collect();
349 folders.sort();
350 folders.dedup();
351 folders
352 }
353}
354
355impl Default for WorktreesRegistry {
356 fn default() -> Self {
357 Self::new()
358 }
359}
360
361/// Removes entries last seen longer than `ttl` ago, returning how many were
362/// dropped. Pure CPU; the caller holds the registry lock but never `.await`s
363/// while holding it. The count lets a *mutation* path
364/// ([`register`](WorktreesRegistry::register) et al.) decide whether to
365/// [`bump`](WorktreesRegistry::bump) the change-notify; read paths ignore it (see
366/// [`list`](WorktreesRegistry::list)).
367fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) -> usize {
368 let max_age = ttl.as_secs() as i64;
369 let before = windows.len();
370 windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
371 before - windows.len()
372}
373
374/// Removes the entry with the oldest `last_seen` (ties broken by lowest key
375/// for determinism). Called when a `register` of a new key would grow the
376/// registry past [`MAX_WINDOWS`]. Pure CPU under the registry lock, like
377/// [`reap`].
378fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
379 let oldest = windows
380 .values()
381 .min_by(|a, b| {
382 a.last_seen
383 .cmp(&b.last_seen)
384 .then_with(|| a.key.cmp(&b.key))
385 })
386 .map(|e| e.key.clone());
387 if let Some(key) = oldest {
388 windows.remove(&key);
389 }
390}
391
392/// Snapshots the registry into a stably-ordered vector (by repo, then key) so
393/// `list`/`status`/`menu` output is deterministic despite `HashMap` ordering.
394fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
395 let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
396 entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
397 entries
398}
399
400#[cfg(test)]
401#[allow(clippy::unwrap_used, clippy::expect_used)]
402mod tests {
403 use super::*;
404
405 fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
406 RegisterRequest {
407 key: key.to_string(),
408 folders: vec![PathBuf::from(folder)],
409 repo: repo.map(str::to_string),
410 title: Some(format!("{key}-title")),
411 pid: Some(1234),
412 }
413 }
414
415 #[test]
416 fn list_is_empty_initially() {
417 let reg = WorktreesRegistry::new();
418 assert!(reg.list().is_empty());
419 }
420
421 #[test]
422 fn register_then_list_round_trips() {
423 let reg = WorktreesRegistry::new();
424 reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
425 let windows = reg.list();
426 assert_eq!(windows.len(), 1);
427 assert_eq!(windows[0].key, "w1");
428 assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
429 }
430
431 #[test]
432 fn register_is_idempotent_upsert() {
433 let reg = WorktreesRegistry::new();
434 reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
435 // Re-registering the same key updates rather than duplicates.
436 reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
437 let windows = reg.list();
438 assert_eq!(windows.len(), 1);
439 assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
440 }
441
442 #[test]
443 fn heartbeat_reports_known_and_unknown() {
444 let reg = WorktreesRegistry::new();
445 // Unknown before registration: the window must re-register.
446 assert!(!reg.heartbeat("w1"));
447 reg.register(register_request("w1", None, "/tmp/a"));
448 assert!(reg.heartbeat("w1"));
449 }
450
451 #[test]
452 fn unregister_removes() {
453 let reg = WorktreesRegistry::new();
454 reg.register(register_request("w1", None, "/tmp/a"));
455 assert!(reg.unregister("w1"));
456 // Removing again is a no-op.
457 assert!(!reg.unregister("w1"));
458 }
459
460 #[test]
461 fn first_folder_returns_first_folder_or_none() {
462 let reg = WorktreesRegistry::new();
463 // No such key.
464 assert!(reg.first_folder("missing").is_none());
465 reg.register(register_request("w1", None, "/tmp/a"));
466 assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
467 // A folderless window resolves to None rather than a folder.
468 reg.register(RegisterRequest {
469 key: "w2".to_string(),
470 folders: vec![],
471 repo: None,
472 title: None,
473 pid: None,
474 });
475 assert!(reg.first_folder("w2").is_none());
476 }
477
478 #[test]
479 fn open_folders_dedups_and_sorts_across_windows() {
480 let reg = WorktreesRegistry::new();
481 assert!(reg.open_folders().is_empty());
482 // Two windows sharing a folder, plus a multi-folder window: the shared
483 // path collapses and the result is sorted.
484 reg.register(register_request("w1", Some("repo-a"), "/tmp/shared"));
485 reg.register(RegisterRequest {
486 key: "w2".to_string(),
487 folders: vec![PathBuf::from("/tmp/shared"), PathBuf::from("/tmp/b")],
488 repo: Some("repo-a".to_string()),
489 title: None,
490 pid: None,
491 });
492 reg.register(register_request("w3", Some("repo-b"), "/tmp/a"));
493 assert_eq!(
494 reg.open_folders(),
495 vec![
496 PathBuf::from("/tmp/a"),
497 PathBuf::from("/tmp/b"),
498 PathBuf::from("/tmp/shared"),
499 ]
500 );
501 }
502
503 #[test]
504 fn open_folders_reaps_stale_windows() {
505 let reg = WorktreesRegistry::new();
506 {
507 let mut windows = reg.lock();
508 windows.insert(
509 "fresh".to_string(),
510 WindowEntry {
511 key: "fresh".to_string(),
512 folders: vec![PathBuf::from("/tmp/fresh")],
513 repo: None,
514 title: None,
515 pid: None,
516 last_seen: Utc::now(),
517 },
518 );
519 windows.insert(
520 "stale".to_string(),
521 WindowEntry {
522 key: "stale".to_string(),
523 folders: vec![PathBuf::from("/tmp/stale")],
524 repo: None,
525 title: None,
526 pid: None,
527 last_seen: Utc::now() - chrono::Duration::seconds(120),
528 },
529 );
530 }
531 // The stale window's folder is reaped out of the snapshot.
532 assert_eq!(reg.open_folders(), vec![PathBuf::from("/tmp/fresh")]);
533 }
534
535 #[test]
536 fn reap_evicts_only_stale_entries() {
537 let now = Utc::now();
538 let mut windows = HashMap::new();
539 windows.insert(
540 "fresh".to_string(),
541 WindowEntry {
542 key: "fresh".to_string(),
543 folders: vec![],
544 repo: None,
545 title: None,
546 pid: None,
547 last_seen: now - chrono::Duration::seconds(5),
548 },
549 );
550 windows.insert(
551 "stale".to_string(),
552 WindowEntry {
553 key: "stale".to_string(),
554 folders: vec![],
555 repo: None,
556 title: None,
557 pid: None,
558 last_seen: now - chrono::Duration::seconds(120),
559 },
560 );
561 reap(&mut windows, DEFAULT_TTL, now);
562 assert!(windows.contains_key("fresh"));
563 assert!(!windows.contains_key("stale"));
564 }
565
566 /// A minimal entry for cap/eviction tests; only `key` and `last_seen`
567 /// participate in eviction order.
568 fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
569 WindowEntry {
570 key: key.to_string(),
571 folders: vec![],
572 repo: None,
573 title: None,
574 pid: None,
575 last_seen,
576 }
577 }
578
579 #[test]
580 fn evict_oldest_removes_oldest_with_key_tiebreak() {
581 let now = Utc::now();
582 let mut windows = HashMap::new();
583 windows.insert("young".to_string(), entry_at("young", now));
584 windows.insert(
585 "old-b".to_string(),
586 entry_at("old-b", now - chrono::Duration::seconds(10)),
587 );
588 windows.insert(
589 "old-a".to_string(),
590 entry_at("old-a", now - chrono::Duration::seconds(10)),
591 );
592 // Oldest `last_seen` is shared by two entries; the lowest key loses.
593 evict_oldest(&mut windows);
594 assert!(!windows.contains_key("old-a"));
595 assert!(windows.contains_key("old-b"));
596 assert!(windows.contains_key("young"));
597 // Empty map is a no-op rather than a panic.
598 let mut empty: HashMap<String, WindowEntry> = HashMap::new();
599 evict_oldest(&mut empty);
600 assert!(empty.is_empty());
601 }
602
603 #[test]
604 fn register_at_cap_evicts_only_the_oldest() {
605 let reg = WorktreesRegistry::new();
606 // Seed a full registry directly (registering 256 times would work too,
607 // but sub-second timestamps may tie; explicit timestamps make the
608 // highest-numbered key unambiguously the oldest).
609 {
610 let mut windows = reg.lock();
611 let base = Utc::now();
612 for i in 0..MAX_WINDOWS {
613 let key = format!("w{i:03}");
614 windows.insert(
615 key.clone(),
616 entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
617 );
618 }
619 }
620 // A new key at the cap displaces exactly the longest-silent entry.
621 reg.register(register_request("fresh", None, "/tmp/f"));
622 let windows = reg.lock();
623 assert_eq!(windows.len(), MAX_WINDOWS);
624 assert!(windows.contains_key("fresh"));
625 assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
626 assert!(windows.contains_key("w000"));
627 }
628
629 #[test]
630 fn register_upsert_at_cap_does_not_evict() {
631 let reg = WorktreesRegistry::new();
632 {
633 let mut windows = reg.lock();
634 let base = Utc::now();
635 for i in 0..MAX_WINDOWS {
636 let key = format!("w{i:03}");
637 windows.insert(
638 key.clone(),
639 entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
640 );
641 }
642 }
643 // Re-registering an existing key is an upsert: nothing is displaced,
644 // not even the oldest entry.
645 let oldest = format!("w{:03}", MAX_WINDOWS - 1);
646 reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
647 let windows = reg.lock();
648 assert_eq!(windows.len(), MAX_WINDOWS);
649 assert!(windows.contains_key(&oldest));
650 assert!(windows.contains_key("w000"));
651 }
652
653 #[test]
654 fn sorted_entries_orders_by_repo_then_key() {
655 let now = Utc::now();
656 let mut windows = HashMap::new();
657 for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
658 windows.insert(
659 key.to_string(),
660 WindowEntry {
661 key: key.to_string(),
662 folders: vec![],
663 repo: Some(repo.to_string()),
664 title: None,
665 pid: None,
666 last_seen: now,
667 },
668 );
669 }
670 let entries = sorted_entries(&windows);
671 let ordered: Vec<(&str, &str)> = entries
672 .iter()
673 .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
674 .collect();
675 assert_eq!(
676 ordered,
677 vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
678 );
679 }
680
681 #[test]
682 fn default_constructs_an_empty_registry() {
683 let reg = WorktreesRegistry::default();
684 assert!(reg.lock().is_empty());
685 }
686
687 // --- Change-notify for the push subscription (#1267) --------------------
688
689 #[test]
690 fn subscribe_changes_starts_seen_and_register_bumps() {
691 let reg = WorktreesRegistry::new();
692 let mut rx = reg.subscribe_changes();
693 // A fresh receiver has the current version already marked seen.
694 assert!(!rx.has_changed().unwrap());
695 // A register changes the visible set → the receiver observes a new value.
696 reg.register(register_request("w1", None, "/tmp/a"));
697 assert!(rx.has_changed().unwrap(), "register should bump");
698 // Marking it seen clears the pending change.
699 rx.borrow_and_update();
700 assert!(!rx.has_changed().unwrap());
701 }
702
703 #[test]
704 fn unregister_bumps_only_when_it_removes() {
705 let reg = WorktreesRegistry::new();
706 reg.register(register_request("w1", None, "/tmp/a"));
707 // Subscribe *after* the register so its bump is already seen.
708 let rx = reg.subscribe_changes();
709 // Removing a missing key changes nothing (and reaps nothing) → no bump.
710 assert!(!reg.unregister("ghost"));
711 assert!(
712 !rx.has_changed().unwrap(),
713 "a no-op unregister must not bump"
714 );
715 // Removing a present key bumps.
716 assert!(reg.unregister("w1"));
717 assert!(
718 rx.has_changed().unwrap(),
719 "a removing unregister should bump"
720 );
721 }
722
723 #[test]
724 fn change_generation_advances_only_on_a_visible_change() {
725 let reg = WorktreesRegistry::new();
726 let g0 = reg.change_generation();
727 // A no-op (heartbeat of an unknown key) leaves the generation untouched.
728 assert!(!reg.heartbeat("ghost"));
729 assert_eq!(
730 reg.change_generation(),
731 g0,
732 "a no-op must not advance the generation"
733 );
734 // A register changes the visible set → the generation advances, so a
735 // cache keyed on it rebuilds.
736 reg.register(register_request("w1", None, "/tmp/a"));
737 assert_ne!(
738 reg.change_generation(),
739 g0,
740 "a register should advance the generation"
741 );
742 }
743
744 #[test]
745 fn heartbeat_bumps_only_when_it_reaps() {
746 let reg = WorktreesRegistry::new();
747 reg.register(register_request("w1", None, "/tmp/a"));
748 let rx = reg.subscribe_changes();
749 // A plain heartbeat refreshes liveness but changes no visible state.
750 assert!(reg.heartbeat("w1"));
751 assert!(!rx.has_changed().unwrap(), "a pure heartbeat must not bump");
752 // Seed a stale sibling directly; a heartbeat that reaps it *does* bump.
753 {
754 let mut windows = reg.lock();
755 windows.insert(
756 "stale".to_string(),
757 entry_at("stale", Utc::now() - chrono::Duration::seconds(120)),
758 );
759 }
760 assert!(reg.heartbeat("w1"));
761 assert!(
762 rx.has_changed().unwrap(),
763 "a heartbeat that reaps a stale sibling should bump"
764 );
765 }
766
767 // --- Close-pending directive (#1277) -----------------------------------
768
769 #[test]
770 fn close_pending_is_taken_once_then_cleared() {
771 let reg = WorktreesRegistry::new();
772 // No directive by default.
773 assert!(!reg.take_close_pending("w1"));
774 // Marked → the first take observes it, the next does not (fires once).
775 reg.mark_close_pending("w1");
776 assert!(reg.take_close_pending("w1"));
777 assert!(!reg.take_close_pending("w1"));
778 }
779
780 #[test]
781 fn unregister_clears_a_pending_close_directive() {
782 let reg = WorktreesRegistry::new();
783 reg.register(register_request("w1", None, "/tmp/a"));
784 reg.mark_close_pending("w1");
785 // Unregistering the window drops any pending directive with it.
786 assert!(reg.unregister("w1"));
787 assert!(!reg.take_close_pending("w1"));
788 }
789
790 // --- Show/hide-closed toggle (#1301) -----------------------------------
791
792 #[test]
793 fn show_closed_defaults_to_true() {
794 let reg = WorktreesRegistry::new();
795 assert!(reg.show_closed(), "default is show all");
796 }
797
798 #[test]
799 fn set_show_closed_reports_change_and_is_idempotent() {
800 let reg = WorktreesRegistry::new();
801 // Flipping to a new value reports a change and is observable.
802 assert!(reg.set_show_closed(false));
803 assert!(!reg.show_closed());
804 // Setting the same value again is a no-op (no change reported).
805 assert!(!reg.set_show_closed(false));
806 // Flipping back reports a change again.
807 assert!(reg.set_show_closed(true));
808 assert!(reg.show_closed());
809 }
810
811 #[test]
812 fn set_show_closed_bumps_only_on_change() {
813 let reg = WorktreesRegistry::new();
814 let rx = reg.subscribe_changes();
815 // A no-op set (already the default) does not wake subscribers.
816 assert!(!reg.set_show_closed(true));
817 assert!(
818 !rx.has_changed().unwrap(),
819 "a no-op toggle must not bump the change-notify"
820 );
821 // A real flip bumps so subscribers re-push a snapshot with the new value.
822 assert!(reg.set_show_closed(false));
823 assert!(
824 rx.has_changed().unwrap(),
825 "flipping the toggle should bump the change-notify"
826 );
827 }
828}