ui_automata/shadow_dom.rs
1/// Shadow DOM: a cached registry of named live element handles.
2///
3/// Anchors are declared with a tier and a selector path relative to a named
4/// parent (or the desktop root). The registry holds live handles and re-queries
5/// them transparently on staleness using a walk-up strategy.
6use std::collections::HashMap;
7
8use crate::{
9 AutomataError, Browser, Desktop, Element, SelectorPath, TabHandle,
10 node_cache::{NodeCache, is_live},
11 registry::{AnchorDef, LaunchContext, LaunchWait, Tier},
12 snapshot::{SNAP_DEPTH, SnapNode},
13};
14
15// ── AnchorName / AnchorMeta ───────────────────────────────────────────────────
16
17/// Newtype key for anchor names. Prevents accidentally mixing anchor-name strings
18/// with other `String` values, and enables typed `HashMap` lookups.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20struct AnchorName(String);
21
22impl AnchorName {
23 fn new(s: impl Into<String>) -> Self {
24 AnchorName(s.into())
25 }
26}
27
28impl std::borrow::Borrow<str> for AnchorName {
29 fn borrow(&self) -> &str {
30 &self.0
31 }
32}
33
34/// Lock state captured on first resolution of a root-tier anchor.
35/// Kept in a single map so HWND and PID always travel together.
36#[derive(Debug, Clone)]
37struct AnchorMeta {
38 /// Exact window handle. Re-resolution fails if this HWND is gone rather
39 /// than drifting to a different window of the same process.
40 hwnd: Option<u64>,
41 /// PID of the owning process. Kept for `anchor_pid()` callers.
42 pid: u32,
43}
44
45// ── ShadowDom ─────────────────────────────────────────────────────────────────
46
47/// A cached registry of named live element handles. Does not own the desktop;
48/// callers pass `&D` to the methods that need to re-query the UIA tree.
49pub struct ShadowDom<D: Desktop> {
50 /// Declared topology — all registered anchor definitions.
51 defs: HashMap<String, AnchorDef>,
52 /// Anchor handles and selector find-result cache.
53 nodes: NodeCache<D::Elem>,
54 /// Last-known tree snapshot per anchor, used by `sync` to detect changes.
55 snapshots: HashMap<String, SnapNode>,
56 /// Lock state captured on first resolution of each root anchor.
57 /// Combines HWND (for drift prevention) and PID (for `anchor_pid()`)
58 /// in a single typed map.
59 locks: HashMap<AnchorName, AnchorMeta>,
60 /// Launch context set after a successful `launch:` wait. Used to filter
61 /// the first resolution of root anchors: `new_pid` filters by PID,
62 /// `new_window` excludes pre-existing HWNDs, `match_any` applies no extra filter.
63 launch_context: Option<LaunchContext>,
64 /// Current subflow depth. At depth 0 all anchors use their raw name.
65 /// At depth N, Stable/Ephemeral anchors are stored under `":".repeat(N) + name`.
66 depth: usize,
67 /// Tab anchor handles: keyed by depth-prefixed anchor name.
68 tab_handles: HashMap<String, TabHandle>,
69}
70
71impl<D: Desktop> ShadowDom<D> {
72 pub fn new() -> Self {
73 ShadowDom {
74 defs: HashMap::new(),
75 nodes: NodeCache::new(),
76 snapshots: HashMap::new(),
77 locks: HashMap::new(),
78 launch_context: None,
79 depth: 0,
80 tab_handles: HashMap::new(),
81 }
82 }
83
84 /// Set the current subflow depth. At depth 0, all anchors use their raw name.
85 /// At depth N, Stable/Ephemeral anchors are stored under `":".repeat(N) + name`.
86 pub fn set_depth(&mut self, depth: usize) {
87 self.depth = depth;
88 }
89
90 /// Get the current subflow depth.
91 pub fn depth(&self) -> usize {
92 self.depth
93 }
94
95 /// Return the depth-prefixed version of a name.
96 fn prefixed(&self, name: &str) -> String {
97 ":".repeat(self.depth) + name
98 }
99
100 /// Compute the effective storage key for `raw_name` at the current depth.
101 ///
102 /// At depth 0, returns `raw_name` unchanged.
103 /// At depth N, tries the prefixed key first; falls back to `raw_name` if the
104 /// prefixed key is not registered (allows child workflows to reference Root/Session
105 /// anchors inherited from the parent).
106 fn effective_key(&self, raw_name: &str) -> String {
107 if self.depth == 0 {
108 return raw_name.to_string();
109 }
110 let prefixed = self.prefixed(raw_name);
111 if self.defs.contains_key(&prefixed) || self.nodes.element(&prefixed).is_some() {
112 prefixed
113 } else {
114 raw_name.to_string()
115 }
116 }
117
118 /// Store the launch context so root-anchor first-resolutions use it for filtering.
119 pub fn set_launch_context(&mut self, ctx: LaunchContext) {
120 self.launch_context = Some(ctx);
121 }
122
123 /// Register anchor definitions and immediately resolve Root-tier anchors.
124 pub fn mount(&mut self, anchors: Vec<AnchorDef>, desktop: &D) -> Result<(), AutomataError> {
125 let mut new_root_keys: Vec<String> = Vec::new();
126 let mut new_browser_keys: Vec<String> = Vec::new();
127 let mut new_tab_keys: Vec<String> = Vec::new();
128
129 for mut def in anchors {
130 let key = match def.tier {
131 Tier::Root | Tier::Session | Tier::Browser => {
132 // Globally shared: if already registered under the raw name, skip (parent wins).
133 if self.defs.contains_key(&def.name) {
134 continue;
135 }
136 def.name.clone()
137 }
138 Tier::Stable | Tier::Ephemeral | Tier::Tab => {
139 // Depth-scoped.
140 self.prefixed(&def.name)
141 }
142 };
143 def.mount_depth = self.depth;
144 match def.tier {
145 Tier::Root => new_root_keys.push(key.clone()),
146 Tier::Browser => new_browser_keys.push(key.clone()),
147 Tier::Tab => new_tab_keys.push(key.clone()),
148 _ => {}
149 }
150 self.defs.insert(key, def);
151 }
152
153 // Resolve Browser anchors eagerly: call ensure() then find the browser window via UIA.
154 for key in new_browser_keys {
155 if self.nodes.element(&key).is_none() {
156 if let Err(e) = self.resolve(&key, desktop) {
157 self.defs.remove(&key);
158 return Err(e);
159 }
160 }
161 }
162
163 // Resolve newly registered Root anchors eagerly — they represent the
164 // top-level application window and must always be present.
165 // Non-root anchors (Stable, Ephemeral) are resolved lazily on first use.
166 for key in new_root_keys {
167 if self.nodes.element(&key).is_none() {
168 if let Err(e) = self.resolve(&key, desktop) {
169 // Rollback: remove the def so a subsequent mount() call can
170 // re-register and retry resolution (e.g. when the window is
171 // still appearing after launch with wait: match_any).
172 self.defs.remove(&key);
173 return Err(e);
174 }
175 }
176 }
177
178 // Mount Tab anchors: open or attach to CDP tabs.
179 for key in new_tab_keys {
180 if !self.tab_handles.contains_key(&key) {
181 let def = match self.defs.get(&key) {
182 Some(d) => d.clone(),
183 None => continue,
184 };
185 match TabHandle::mount(&def, desktop.browser()) {
186 Ok(handle) => {
187 self.tab_handles.insert(key.to_string(), handle);
188 }
189 Err(e) => {
190 self.defs.remove(&key);
191 return Err(e);
192 }
193 }
194 }
195 }
196
197 Ok(())
198 }
199
200 /// Insert an element directly into the cache (used for Ephemeral captures).
201 pub fn insert(&mut self, name: impl Into<String>, element: D::Elem) {
202 let raw_name = name.into();
203 let key = self.prefixed(&raw_name);
204 let id = self.nodes.insert(key.clone(), element);
205 log::debug!("inserted node {id} for anchor '{key}'");
206 }
207
208 /// Retrieve a live handle by name. Re-queries if the cached handle is stale.
209 ///
210 /// Returns `Err` if the anchor cannot be resolved (root gone, selector
211 /// not found after walk-up, or name not registered).
212 pub fn get(&mut self, name: &str, desktop: &D) -> Result<&D::Elem, AutomataError> {
213 let key = self.effective_key(name);
214
215 // Tab anchors have no UIA element of their own; delegate to the parent Browser anchor.
216 let parent_redirect = self
217 .defs
218 .get(&key)
219 .filter(|d| d.tier == Tier::Tab)
220 .and_then(|d| d.parent.clone());
221 if let Some(parent) = parent_redirect {
222 return self.get(&parent, desktop);
223 }
224
225 let old_id = self.nodes.node_id(&key);
226 let cached_live = self.nodes.anchor_is_live(&key);
227
228 if !cached_live {
229 if let Some(stale) = self.nodes.remove(&key) {
230 log::debug!("invalidated node {} for anchor '{key}'", stale.id);
231 }
232 self.resolve(&key, desktop)?;
233 let new_id = self.nodes.node_id(&key).unwrap_or(0);
234 match old_id {
235 Some(oid) => {
236 log::debug!("replaced node {oid} with node {new_id} for anchor '{key}'")
237 }
238 None => log::debug!("mounted node {new_id} for anchor '{key}'"),
239 }
240 }
241
242 self.nodes.element(&key).ok_or_else(|| {
243 AutomataError::Internal(format!("anchor '{name}' not found after resolve"))
244 })
245 }
246
247 /// Remove a session anchor and all stable anchors that depend on it.
248 pub fn invalidate_session(&mut self, session_name: &str) {
249 self.locks.remove(session_name);
250 if let Some(n) = self.nodes.remove(session_name) {
251 log::debug!(
252 "invalidated session anchor '{session_name}' (node {})",
253 n.id
254 );
255 }
256 self.snapshots.remove(session_name);
257 let dependents: Vec<String> = self
258 .defs
259 .values()
260 .filter(|d| self.depends_on(d, session_name))
261 .map(|d| d.name.clone())
262 .collect();
263 for dep in dependents {
264 self.locks.remove(dep.as_str());
265 if let Some(n) = self.nodes.remove(&dep) {
266 log::debug!(
267 "invalidated dependent anchor '{dep}' (node {}) due to session '{session_name}'",
268 n.id
269 );
270 }
271 self.snapshots.remove(&dep);
272 }
273 }
274
275 /// Unmount anchors by name, removing their definition, cached handle, and
276 /// snapshot. The symmetric inverse of [`mount`](Self::mount): after this
277 /// call the names are completely unknown to the registry.
278 ///
279 /// Root and Session anchors are globally shared and cannot be unmounted
280 /// this way; a warning is logged and the name is skipped.
281 ///
282 /// Any Tab anchor whose tab was opened by the workflow (`created=true`) is
283 /// closed via `desktop.browser().close_tab()` before its handle is removed.
284 pub fn unmount(&mut self, names: &[&str], desktop: &D) {
285 for name in names {
286 // Guard: Root/Session/Browser anchors are globally shared — refuse to unmount.
287 if let Some(def) = self.defs.get(*name) {
288 if matches!(def.tier, Tier::Root | Tier::Session | Tier::Browser) {
289 log::debug!("unmount: ignoring Root/Session/Browser anchor '{name}'");
290 continue;
291 }
292 }
293 let key = self.prefixed(name);
294 if let Some(handle) = self.tab_handles.remove(&key) {
295 handle.close_if_created(desktop.browser());
296 log::debug!("unmount: removed tab handle for '{name}'");
297 }
298 self.defs.remove(&key);
299 self.locks.remove(key.as_str());
300 if let Some(n) = self.nodes.remove(&key) {
301 log::debug!("unmounted node {} for anchor '{name}'", n.id);
302 }
303 self.snapshots.remove(&key);
304 }
305 }
306
307 /// Check whether a handle is currently cached and live (no re-query).
308 pub fn is_live(&self, name: &str) -> bool {
309 self.nodes.anchor_is_live(name)
310 }
311
312 /// Find a descendant element matching `selector` within `scope`, using a
313 /// stale-first strategy with partial-tree re-resolution:
314 ///
315 /// 1. **Cache hit, live** → return immediately (1 COM call, no DFS).
316 /// 2. **Cache hit, stale, step-parent live** → re-run the selector's last
317 /// step from the cached step-parent (narrow search — O(subtree) not
318 /// O(whole tree)). Update the cache on success; fall through on failure.
319 /// 3. **Cache hit, stale, step-parent also stale** → clear cache entry,
320 /// fall through to full DFS.
321 /// 4. **Cache miss** → full `find_one` traversal from the anchor root;
322 /// cache both the result and its step-parent for next time.
323 ///
324 /// The cache is cleared whenever the scope anchor is re-resolved or unmounted.
325 pub fn find_descendant(
326 &mut self,
327 scope: &str,
328 selector: &SelectorPath,
329 desktop: &D,
330 ) -> Result<Option<D::Elem>, AutomataError> {
331 let key = self.effective_key(scope);
332
333 // Tab scope: delegate to the parent Browser anchor with a document-scoped selector.
334 let tab_handle = self
335 .defs
336 .get(&key)
337 .filter(|d| d.tier == Tier::Tab)
338 .and_then(|_| self.tab_handles.get(&key).cloned());
339 if let Some(handle) = tab_handle {
340 let (parent_browser, doc_sel) = handle.scoped_selector(desktop.browser(), selector)?;
341 return self.find_descendant(&parent_browser, &doc_sel, desktop);
342 }
343
344 // Hard error: scope was never registered. Abort rather than poll forever.
345 if !self.defs.contains_key(&key) {
346 return Err(AutomataError::Internal(format!(
347 "scope '{scope}' is not mounted"
348 )));
349 }
350
351 let sel_key = selector.to_string();
352
353 if let Some(cached) = self.nodes.found(&key, &sel_key) {
354 if is_live(&cached) {
355 return Ok(Some(cached));
356 }
357 // Stale. Try narrow re-resolution from the cached step-parent.
358 if let Some(step_parent) = self.nodes.found_parent(&key, &sel_key) {
359 if is_live(&step_parent) {
360 if let Some(el) = selector.find_one_from_step_parent(&step_parent) {
361 // Re-found under the same parent — refresh the cache entry.
362 self.nodes
363 .set_found(&key, sel_key, el.clone(), Some(step_parent));
364 return Ok(Some(el));
365 }
366 // Not under the same parent anymore — element moved or gone.
367 // Clear and fall through to full DFS.
368 self.nodes.remove_found(&key, &sel_key);
369 // Fall through to full DFS in case element moved elsewhere.
370 } else {
371 // Both element and step-parent are stale.
372 self.nodes.remove_found(&key, &sel_key);
373 return Ok(None);
374 }
375 } else {
376 // No step-parent stored (root-step match) — element is gone.
377 self.nodes.remove_found(&key, &sel_key);
378 return Ok(None);
379 }
380 }
381
382 // Slow path: full traversal from the anchor root.
383 let root = match self.get(scope, desktop) {
384 Ok(el) => el.clone(),
385 Err(_) => return Ok(None),
386 };
387 let found = selector.find_one_with_parent(&root);
388 if let Some((ref el, ref parent)) = found {
389 self.nodes
390 .set_found(&key, sel_key, el.clone(), parent.clone());
391 }
392 Ok(found.map(|(el, _)| el))
393 }
394
395 // ── Internal resolution ───────────────────────────────────────────────────
396
397 /// Resolve anchor by its storage `key` (which may be prefixed for depth-scoped anchors).
398 fn resolve(&mut self, key: &str, desktop: &D) -> Result<(), AutomataError> {
399 let def = self
400 .defs
401 .get(key)
402 .ok_or_else(|| AutomataError::Internal(format!("anchor '{key}' is not registered")))?
403 .clone();
404
405 // For Browser anchors, call ensure() to start Edge with a CDP debug port before
406 // resolving the UIA window handle. This makes ensure() transparent to the resolve path.
407 if def.tier == Tier::Browser {
408 desktop
409 .browser()
410 .ensure()
411 .map_err(|e| AutomataError::Internal(format!("browser.ensure(): {e}")))?;
412 }
413
414 let found: D::Elem = match def.parent.as_deref() {
415 None => {
416 let windows = desktop.application_windows()?;
417 // After first resolution, locks[key].hwnd holds the exact window handle.
418 // Re-resolution is constrained to that HWND so the anchor cannot drift to
419 // a different window of the same process (same PID but different HWND).
420 if let Some(meta) = self.locks.get(key) {
421 let locked_hwnd = meta.hwnd;
422 windows
423 .into_iter()
424 .find(|w| w.hwnd() == locked_hwnd)
425 .ok_or_else(|| {
426 let hwnd_str = locked_hwnd
427 .map(|h| format!("0x{h:X}"))
428 .unwrap_or_else(|| "unknown".into());
429 AutomataError::Internal(format!(
430 "anchor '{key}' window (hwnd={hwnd_str}) is no longer present"
431 ))
432 })?
433 } else {
434 // First resolution: filter by user-supplied pid/process/selector,
435 // then additionally by launch_context strategy if present.
436 let effective_pid = def.pid;
437 let proc_filter = def.process_name.as_deref().map(|s| s.to_lowercase());
438 let mut candidates: Vec<_> = windows
439 .into_iter()
440 .filter(|w| {
441 effective_pid
442 .map_or(true, |pid| w.process_id().map_or(false, |p| p == pid))
443 })
444 .filter(|w| {
445 proc_filter.as_deref().map_or(true, |pf| {
446 w.process_name()
447 .map(|n| n.to_lowercase() == pf)
448 .unwrap_or(false)
449 })
450 })
451 .filter(|w| {
452 // Apply launch_context filter only when the anchor targets the
453 // same process as the launched exe (or has no process filter at
454 // all). Anchors that explicitly target a different process are
455 // left unaffected so multi-root-anchor workflows work correctly.
456 let ctx_applies = match (&self.launch_context, &proc_filter) {
457 (Some(ctx), Some(pf)) => *pf == ctx.process_name,
458 (Some(_), None) => true,
459 (None, _) => false,
460 };
461 if !ctx_applies {
462 return true;
463 }
464 match &self.launch_context {
465 Some(LaunchContext {
466 wait: LaunchWait::NewPid,
467 pid,
468 ..
469 }) => w.process_id().map_or(false, |p| p == *pid),
470 Some(LaunchContext {
471 wait: LaunchWait::NewWindow,
472 pre_hwnds,
473 ..
474 }) => w.hwnd().map_or(false, |h| !pre_hwnds.contains(&h)),
475 _ => true, // MatchAny or no launch
476 }
477 })
478 .filter(|w| def.selector.matches(w))
479 .collect();
480
481 // Sort candidates by Z-order (topmost window first) so that when
482 // a process has multiple windows the one currently on top is preferred,
483 // even if none of them are in the foreground.
484 if candidates.len() > 1 {
485 let z_order = desktop.hwnd_z_order();
486 if !z_order.is_empty() {
487 let rank = |hwnd: u64| -> usize {
488 z_order
489 .iter()
490 .position(|h| *h == hwnd)
491 .unwrap_or(usize::MAX)
492 };
493 candidates.sort_by_key(|w| w.hwnd().map_or(usize::MAX, rank));
494 }
495 }
496
497 candidates.into_iter().next().ok_or_else(|| {
498 AutomataError::Internal(format!(
499 "anchor '{key}' not found in application windows \
500 (selector: {}, pid: {:?}, process: {:?})",
501 def.selector, effective_pid, def.process_name
502 ))
503 })?
504 }
505 }
506 Some(raw_parent_name) => {
507 let raw_parent_name = raw_parent_name.to_string();
508 // Resolve parent using get(), which applies effective_key internally.
509 let parent = self
510 .get(&raw_parent_name, desktop)
511 .map_err(|_| {
512 AutomataError::Internal(format!(
513 "parent anchor '{raw_parent_name}' unavailable while resolving '{key}'"
514 ))
515 })?
516 .clone();
517
518 def.selector.find_one(&parent).ok_or_else(|| {
519 AutomataError::Internal(format!(
520 "anchor '{key}' not found under '{raw_parent_name}' \
521 (selector: {})",
522 def.selector
523 ))
524 })?
525 }
526 };
527
528 // Browser anchors must own the foreground so keyboard input reaches them.
529 if def.tier == Tier::Browser {
530 if let Err(e) = found.activate_window() {
531 log::warn!("Browser anchor '{key}': activate_window failed: {e}");
532 }
533 }
534
535 // Lock the anchor to the resolved window. HWND prevents same-PID drift;
536 // PID is kept for anchor_pid() callers.
537 if let Ok(pid) = found.process_id() {
538 self.locks.insert(
539 AnchorName::new(key),
540 AnchorMeta {
541 hwnd: found.hwnd(),
542 pid,
543 },
544 );
545 }
546
547 // Capture the initial snapshot so the first sync() call has a baseline.
548 // Render the trace log from the snapshot — no second COM traversal needed.
549 let snap = SnapNode::capture(&found, SNAP_DEPTH);
550 log::debug!("resolved anchor '{key}':\n{}", snap.format_tree(0));
551 self.snapshots.insert(key.to_string(), snap);
552 // NodeCache::insert also clears found-cache for this scope.
553 let id = self.nodes.insert(key.to_string(), found);
554 log::debug!("cached node {id} for anchor '{key}'");
555
556 Ok(())
557 }
558
559 /// Snapshot the live subtree of `name`, diff against the previous snapshot,
560 /// emit each change to the tracer, and **return** the change lines.
561 ///
562 /// The returned `Vec` is empty when nothing changed. Each line has the form:
563 /// ```text
564 /// dom: <scope>: ADDED [role "name"]
565 /// dom: <scope> > [role "name"]: REMOVED [child-role "child"]
566 /// dom: <scope>: name "old" → "new"
567 /// ```
568 ///
569 /// Primarily used by the executor's poll loop, but also directly testable.
570 pub fn sync_changes(&mut self, name: &str, desktop: &D) -> Vec<String> {
571 let key = self.effective_key(name);
572 let el = match self.get(name, desktop) {
573 Ok(e) => e.clone(),
574 Err(_) => return vec![],
575 };
576 let new_snap = SnapNode::capture(&el, SNAP_DEPTH);
577 let mut changes = Vec::new();
578 if let Some(old_snap) = self.snapshots.get(&key) {
579 old_snap.diff_into(&new_snap, name, &mut changes);
580 }
581 self.snapshots.insert(key, new_snap);
582 for line in &changes {
583 log::debug!("{line}");
584 }
585 changes
586 }
587
588 /// Like [`sync_changes`] but discards the return value.
589 /// Convenience for the executor's poll loop.
590 pub fn sync(&mut self, name: &str, desktop: &D) {
591 self.sync_changes(name, desktop);
592 }
593
594 /// Returns the effective PID for a registered anchor.
595 /// Prefers the locked PID (captured on first resolution) over the statically
596 /// declared PID, since the locked value is always the more specific one.
597 pub fn anchor_pid(&self, name: &str) -> Option<u32> {
598 let key = self.effective_key(name);
599 self.locks
600 .get(key.as_str())
601 .map(|m| m.pid)
602 .or_else(|| self.defs.get(&key)?.pid)
603 }
604
605 /// Returns the locked HWND for a registered anchor, if one was captured on first resolution.
606 pub fn anchor_hwnd(&self, name: &str) -> Option<u64> {
607 let key = self.effective_key(name);
608 self.locks.get(key.as_str()).and_then(|m| m.hwnd)
609 }
610
611 /// Remove and return all tab handles mounted at exactly `depth`.
612 fn drain_tabs_at_depth(&mut self, depth: usize) -> Vec<TabHandle> {
613 let keys: Vec<String> = self
614 .tab_handles
615 .iter()
616 .filter(|(_, h)| h.depth == depth)
617 .map(|(k, _)| k.clone())
618 .collect();
619 keys.into_iter()
620 .filter_map(|k| self.tab_handles.remove(&k))
621 .collect()
622 }
623
624 /// Return a reference to the tab handle for `name`, if present.
625 pub fn tab_handle(&self, name: &str) -> Option<&TabHandle> {
626 let key = self.effective_key(name);
627 self.tab_handles.get(&key)
628 }
629
630 /// Remove all anchors stored at exactly `depth` (keys prefixed with exactly
631 /// `":".repeat(depth)` followed by a non-colon character).
632 /// Acts as a safety net for anything not explicitly unmounted by the subflow.
633 /// Any created Tab anchors at this depth are closed via `desktop.browser().close_tab()`.
634 pub fn cleanup_depth(&mut self, depth: usize, desktop: &D) {
635 // Close and drain any tab handles at this depth.
636 let tabs = self.drain_tabs_at_depth(depth);
637 for handle in tabs {
638 handle.close_if_created(desktop.browser());
639 log::debug!(
640 "cleanup depth {depth}: removed tab handle for tab_id='{}'",
641 handle.tab_id
642 );
643 }
644
645 let prefix = ":".repeat(depth);
646 // Remove defs introduced at this depth — covers both depth-prefixed
647 // stable/ephemeral anchors AND unprefixed root anchors that were
648 // registered by a subflow (not inherited from a parent).
649 let def_keys: Vec<String> = self
650 .defs
651 .iter()
652 .filter(|(_, def)| def.mount_depth == depth)
653 .map(|(k, _)| k.clone())
654 .collect();
655 // Also sweep orphan node-cache entries (Capture ephemerals without defs)
656 // that carry the depth prefix but no def entry.
657 let node_keys: Vec<String> = self
658 .nodes
659 .anchor_names()
660 .into_iter()
661 .filter(|k| {
662 k.starts_with(&prefix)
663 && k.len() > prefix.len()
664 && !k[prefix.len()..].starts_with(':')
665 })
666 .collect();
667 for key in def_keys {
668 self.defs.remove(&key);
669 self.locks.remove(key.as_str());
670 if let Some(n) = self.nodes.remove(&key) {
671 log::debug!(
672 "cleanup depth {depth}: removed node {} for key '{key}'",
673 n.id
674 );
675 }
676 self.snapshots.remove(&key);
677 }
678 // Also remove orphan nodes (Capture ephemerals without defs).
679 for key in node_keys {
680 if self.nodes.remove(&key).is_some() {
681 log::debug!("cleanup depth {depth}: removed orphan node for key '{key}'");
682 }
683 }
684 }
685
686 /// Returns true if `def` is a (transitive) child of `session_name`.
687 fn depends_on(&self, def: &AnchorDef, session_name: &str) -> bool {
688 let mut current = def.parent.as_deref();
689 while let Some(p) = current {
690 if p == session_name {
691 return true;
692 }
693 current = self.defs.get(p).and_then(|d| d.parent.as_deref());
694 }
695 false
696 }
697}
698
699impl<D: Desktop> Default for ShadowDom<D> {
700 fn default() -> Self {
701 Self::new()
702 }
703}