Skip to main content

rmux_core/hooks/
identity.rs

1use std::collections::{HashMap, HashSet};
2
3#[cfg(test)]
4use rmux_proto::SessionName;
5use rmux_proto::{
6    HookLifecycle, HookName, PaneId, PaneTarget, ScopeSelector, WindowId, WindowTarget,
7};
8
9use super::rules::{hook_class, root_for_hook};
10use super::types::HookClass;
11use super::{
12    HookBindingView, HookBindings, HookDispatch, HookScopeIdentity, HookSetOptions, HookStore,
13};
14
15impl HookStore {
16    /// Registers or mutates a hook at a stable, already-resolved scope.
17    pub fn set_with_identity(
18        &mut self,
19        scope: HookScopeIdentity,
20        hook: HookName,
21        command: String,
22        lifecycle: HookLifecycle,
23        options: HookSetOptions,
24    ) -> u32 {
25        self.identity_bindings_mut(&scope, hook)
26            .set(hook, command, lifecycle, options)
27    }
28
29    /// Removes a hook, or one indexed element, from a stable scope.
30    pub fn unset_with_identity(
31        &mut self,
32        scope: &HookScopeIdentity,
33        hook: HookName,
34        index: Option<u32>,
35    ) {
36        match scope {
37            HookScopeIdentity::Global => {
38                self.global_bindings_mut(root_for_hook(hook))
39                    .unset(hook, index);
40            }
41            HookScopeIdentity::Session(session_name) => {
42                let remove_scope = self.sessions.get_mut(session_name).is_some_and(|bindings| {
43                    bindings.unset(hook, index);
44                    bindings.is_empty()
45                });
46                if remove_scope {
47                    self.sessions.remove(session_name);
48                }
49            }
50            HookScopeIdentity::Window { window_id, .. } => {
51                let remove_scope = self
52                    .windows_by_id
53                    .get_mut(window_id)
54                    .is_some_and(|bindings| {
55                        bindings.unset(hook, index);
56                        bindings.is_empty()
57                    });
58                if remove_scope {
59                    self.windows_by_id.remove(window_id);
60                }
61            }
62            HookScopeIdentity::Pane { pane_id, .. } => {
63                let remove_scope = self.panes_by_id.get_mut(pane_id).is_some_and(|bindings| {
64                    bindings.unset(hook, index);
65                    bindings.is_empty()
66                });
67                if remove_scope {
68                    self.panes_by_id.remove(pane_id);
69                }
70            }
71        }
72    }
73
74    /// Returns explicit bindings stored at a stable scope.
75    #[must_use]
76    pub fn bindings_view_with_identity(
77        &self,
78        scope: &HookScopeIdentity,
79        hook: Option<HookName>,
80    ) -> Vec<HookBindingView> {
81        match scope {
82            HookScopeIdentity::Global => Vec::new(),
83            HookScopeIdentity::Session(session_name) => self
84                .sessions
85                .get(session_name)
86                .map_or_else(Vec::new, |bindings| bindings.views(hook)),
87            HookScopeIdentity::Window { window_id, .. } => self
88                .windows_by_id
89                .get(window_id)
90                .map_or_else(Vec::new, |bindings| bindings.views(hook)),
91            HookScopeIdentity::Pane { pane_id, .. } => self
92                .panes_by_id
93                .get(pane_id)
94                .map_or_else(Vec::new, |bindings| bindings.views(hook)),
95        }
96    }
97
98    /// Resolves and consumes a hook using stable window and pane identities.
99    #[must_use]
100    pub fn dispatch_with_identity(
101        &mut self,
102        scope: &HookScopeIdentity,
103        hook: HookName,
104    ) -> Vec<HookDispatch> {
105        match hook_class(hook) {
106            HookClass::Session => self.dispatch_identity_session(scope, hook),
107            HookClass::Window => self.dispatch_identity_window(scope, hook),
108            HookClass::Pane => self.dispatch_identity_pane(scope, hook),
109        }
110    }
111
112    /// Dispatches through stable identity storage while retaining compatibility
113    /// with callers that populated the legacy slot-keyed API directly.
114    #[must_use]
115    pub fn dispatch_with_identity_or_scope(
116        &mut self,
117        identity: &HookScopeIdentity,
118        scope: &ScopeSelector,
119        hook: HookName,
120    ) -> Vec<HookDispatch> {
121        let (resolved_identity, _) = self.resolved_identity_binding(identity, hook);
122        if !matches!(resolved_identity, HookScopeIdentity::Global) {
123            return self.dispatch_with_identity(identity, hook);
124        }
125        let (resolved_scope, _) = self.resolved_dispatch_binding(scope, hook);
126        if !matches!(resolved_scope, ScopeSelector::Global) {
127            return self.dispatch(scope, hook);
128        }
129        self.dispatch_with_identity(identity, hook)
130    }
131
132    /// Dispatches from a pre-mutation snapshot and commits one-shot
133    /// consumption to the live identity-keyed store.
134    pub fn dispatch_deferred_with_identity(
135        &mut self,
136        snapshot: &mut Self,
137        scope: &HookScopeIdentity,
138        hook: HookName,
139    ) -> Vec<HookDispatch> {
140        let (resolved_scope, one_shots) = snapshot.resolved_identity_binding(scope, hook);
141        let dispatches = snapshot.dispatch_with_identity(scope, hook);
142        if dispatches.is_empty() {
143            return dispatches;
144        }
145        for index in one_shots {
146            self.unset_with_identity(&resolved_scope, hook, Some(index));
147        }
148        dispatches
149    }
150
151    /// Deferred counterpart of [`Self::dispatch_with_identity_or_scope`].
152    pub fn dispatch_deferred_with_identity_or_scope(
153        &mut self,
154        snapshot: &mut Self,
155        identity: &HookScopeIdentity,
156        scope: &ScopeSelector,
157        hook: HookName,
158    ) -> Vec<HookDispatch> {
159        let (resolved_identity, _) = snapshot.resolved_identity_binding(identity, hook);
160        if !matches!(resolved_identity, HookScopeIdentity::Global) {
161            return self.dispatch_deferred_with_identity(snapshot, identity, hook);
162        }
163        let (resolved_scope, _) = snapshot.resolved_dispatch_binding(scope, hook);
164        if !matches!(resolved_scope, ScopeSelector::Global) {
165            return self.dispatch_deferred_from(snapshot, scope, hook);
166        }
167        self.dispatch_deferred_with_identity(snapshot, identity, hook)
168    }
169
170    /// Drops identity-keyed bindings whose owning objects no longer exist.
171    pub fn retain_identities(
172        &mut self,
173        window_ids: &HashSet<WindowId>,
174        pane_ids: &HashSet<PaneId>,
175    ) {
176        self.windows_by_id
177            .retain(|window_id, _| window_ids.contains(window_id));
178        self.panes_by_id
179            .retain(|pane_id, _| pane_ids.contains(pane_id));
180    }
181
182    /// Atomically replaces slot aliases used by the compatibility API and
183    /// purges bindings for identities that are no longer live.
184    pub fn replace_identity_aliases(
185        &mut self,
186        window_aliases: HashMap<WindowTarget, WindowId>,
187        pane_aliases: HashMap<PaneTarget, (WindowId, PaneId)>,
188    ) {
189        let legacy_windows = std::mem::take(&mut self.windows);
190        for (target, bindings) in legacy_windows {
191            if let Some(window_id) = window_aliases.get(&target) {
192                self.windows_by_id.entry(*window_id).or_insert(bindings);
193            } else {
194                self.windows.insert(target, bindings);
195            }
196        }
197        let legacy_panes = std::mem::take(&mut self.panes);
198        for (target, bindings) in legacy_panes {
199            if let Some((_, pane_id)) = pane_aliases.get(&target) {
200                self.panes_by_id.entry(*pane_id).or_insert(bindings);
201            } else {
202                self.panes.insert(target, bindings);
203            }
204        }
205
206        let window_ids = window_aliases.values().copied().collect::<HashSet<_>>();
207        let pane_ids = pane_aliases
208            .values()
209            .map(|(_, pane_id)| *pane_id)
210            .collect::<HashSet<_>>();
211        self.retain_identities(&window_ids, &pane_ids);
212        self.window_aliases = window_aliases;
213        self.pane_aliases = pane_aliases;
214    }
215
216    /// Returns the first exact identity-keyed window command.
217    #[must_use]
218    pub fn window_command_by_id(&self, window_id: WindowId, hook: HookName) -> Option<&str> {
219        self.windows_by_id
220            .get(&window_id)
221            .and_then(|bindings| bindings.command(hook))
222    }
223
224    /// Returns the first exact identity-keyed pane command.
225    #[must_use]
226    pub fn pane_command_by_id(&self, pane_id: PaneId, hook: HookName) -> Option<&str> {
227        self.panes_by_id
228            .get(&pane_id)
229            .and_then(|bindings| bindings.command(hook))
230    }
231
232    fn identity_bindings_mut(
233        &mut self,
234        scope: &HookScopeIdentity,
235        hook: HookName,
236    ) -> &mut HookBindings {
237        match scope {
238            HookScopeIdentity::Global => self.global_bindings_mut(root_for_hook(hook)),
239            HookScopeIdentity::Session(session_name) => {
240                self.sessions.entry(session_name.clone()).or_default()
241            }
242            HookScopeIdentity::Window { window_id, .. } => {
243                self.windows_by_id.entry(*window_id).or_default()
244            }
245            HookScopeIdentity::Pane { pane_id, .. } => {
246                self.panes_by_id.entry(*pane_id).or_default()
247            }
248        }
249    }
250
251    fn dispatch_identity_session(
252        &mut self,
253        scope: &HookScopeIdentity,
254        hook: HookName,
255    ) -> Vec<HookDispatch> {
256        if let Some(session_name) = scope.session_name() {
257            let (dispatches, remove_scope) =
258                self.sessions
259                    .get_mut(session_name)
260                    .map_or((Vec::new(), false), |bindings| {
261                        let dispatches = bindings.dispatch(hook);
262                        (dispatches, bindings.is_empty())
263                    });
264            if remove_scope {
265                self.sessions.remove(session_name);
266            }
267            if !dispatches.is_empty() {
268                return dispatches;
269            }
270        }
271        self.session_global.dispatch(hook)
272    }
273
274    fn dispatch_identity_window(
275        &mut self,
276        scope: &HookScopeIdentity,
277        hook: HookName,
278    ) -> Vec<HookDispatch> {
279        let window_id = match scope {
280            HookScopeIdentity::Window { window_id, .. }
281            | HookScopeIdentity::Pane { window_id, .. } => Some(*window_id),
282            HookScopeIdentity::Global | HookScopeIdentity::Session(_) => None,
283        };
284        if let Some(window_id) = window_id {
285            let (dispatches, remove_scope) =
286                self.windows_by_id
287                    .get_mut(&window_id)
288                    .map_or((Vec::new(), false), |bindings| {
289                        let dispatches = bindings.dispatch(hook);
290                        (dispatches, bindings.is_empty())
291                    });
292            if remove_scope {
293                self.windows_by_id.remove(&window_id);
294            }
295            if !dispatches.is_empty() {
296                return dispatches;
297            }
298        }
299        self.window_global.dispatch(hook)
300    }
301
302    fn dispatch_identity_pane(
303        &mut self,
304        scope: &HookScopeIdentity,
305        hook: HookName,
306    ) -> Vec<HookDispatch> {
307        if let HookScopeIdentity::Pane { pane_id, .. } = scope {
308            let (dispatches, remove_scope) =
309                self.panes_by_id
310                    .get_mut(pane_id)
311                    .map_or((Vec::new(), false), |bindings| {
312                        let dispatches = bindings.dispatch(hook);
313                        (dispatches, bindings.is_empty())
314                    });
315            if remove_scope {
316                self.panes_by_id.remove(pane_id);
317            }
318            if !dispatches.is_empty() {
319                return dispatches;
320            }
321        }
322        self.dispatch_identity_window(scope, hook)
323    }
324
325    fn resolved_identity_binding(
326        &self,
327        scope: &HookScopeIdentity,
328        hook: HookName,
329    ) -> (HookScopeIdentity, Vec<u32>) {
330        if hook_class(hook) == HookClass::Pane {
331            if let HookScopeIdentity::Pane {
332                session_name,
333                window_id,
334                ..
335            } = scope
336            {
337                if self.identity_scope_has_binding(scope, hook) {
338                    return (
339                        scope.clone(),
340                        self.one_shot_indices_for_identity(scope, hook),
341                    );
342                }
343                let window_scope = HookScopeIdentity::Window {
344                    session_name: session_name.clone(),
345                    window_id: *window_id,
346                };
347                if self.identity_scope_has_binding(&window_scope, hook) {
348                    let indices = self.one_shot_indices_for_identity(&window_scope, hook);
349                    return (window_scope, indices);
350                }
351            }
352            let global = HookScopeIdentity::Global;
353            let indices = self.one_shot_indices_for_identity(&global, hook);
354            return (global, indices);
355        }
356
357        let local_scope = match hook_class(hook) {
358            HookClass::Session => scope
359                .session_name()
360                .cloned()
361                .map(HookScopeIdentity::Session),
362            HookClass::Window => match scope {
363                HookScopeIdentity::Window {
364                    session_name,
365                    window_id,
366                }
367                | HookScopeIdentity::Pane {
368                    session_name,
369                    window_id,
370                    ..
371                } => Some(HookScopeIdentity::Window {
372                    session_name: session_name.clone(),
373                    window_id: *window_id,
374                }),
375                HookScopeIdentity::Global | HookScopeIdentity::Session(_) => None,
376            },
377            HookClass::Pane => unreachable!("pane hooks handled above"),
378        };
379
380        if let Some(local_scope) = local_scope {
381            let indices = self.one_shot_indices_for_identity(&local_scope, hook);
382            if self.identity_scope_has_binding(&local_scope, hook) {
383                return (local_scope, indices);
384            }
385        }
386
387        let global = HookScopeIdentity::Global;
388        let indices = self.one_shot_indices_for_identity(&global, hook);
389        (global, indices)
390    }
391
392    fn identity_scope_has_binding(&self, scope: &HookScopeIdentity, hook: HookName) -> bool {
393        match scope {
394            HookScopeIdentity::Global => self.global_bindings(root_for_hook(hook)),
395            HookScopeIdentity::Session(session_name) => match self.sessions.get(session_name) {
396                Some(bindings) => bindings,
397                None => return false,
398            },
399            HookScopeIdentity::Window { window_id, .. } => {
400                match self.windows_by_id.get(window_id) {
401                    Some(bindings) => bindings,
402                    None => return false,
403                }
404            }
405            HookScopeIdentity::Pane { pane_id, .. } => match self.panes_by_id.get(pane_id) {
406                Some(bindings) => bindings,
407                None => return false,
408            },
409        }
410        .command(hook)
411        .is_some()
412    }
413
414    fn one_shot_indices_for_identity(&self, scope: &HookScopeIdentity, hook: HookName) -> Vec<u32> {
415        match scope {
416            HookScopeIdentity::Global => self
417                .global_bindings(root_for_hook(hook))
418                .one_shot_indices(hook),
419            HookScopeIdentity::Session(session_name) => self
420                .sessions
421                .get(session_name)
422                .map_or_else(Vec::new, |bindings| bindings.one_shot_indices(hook)),
423            HookScopeIdentity::Window { window_id, .. } => self
424                .windows_by_id
425                .get(window_id)
426                .map_or_else(Vec::new, |bindings| bindings.one_shot_indices(hook)),
427            HookScopeIdentity::Pane { pane_id, .. } => self
428                .panes_by_id
429                .get(pane_id)
430                .map_or_else(Vec::new, |bindings| bindings.one_shot_indices(hook)),
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    fn session_name(value: &str) -> SessionName {
440        SessionName::new(value).expect("valid session name")
441    }
442
443    fn pane_scope(session: &str) -> HookScopeIdentity {
444        HookScopeIdentity::Pane {
445            session_name: session_name(session),
446            window_id: WindowId::new(7),
447            pane_id: PaneId::new(11),
448        }
449    }
450
451    #[test]
452    fn aliases_share_one_pane_binding_and_one_shot_consumption() {
453        let mut store = HookStore::new();
454        store.set_with_identity(
455            pane_scope("owner"),
456            HookName::PaneExited,
457            "set-option @once yes".to_owned(),
458            HookLifecycle::OneShot,
459            HookSetOptions::default(),
460        );
461
462        let first = store.dispatch_with_identity(&pane_scope("peer"), HookName::PaneExited);
463        let second = store.dispatch_with_identity(&pane_scope("owner"), HookName::PaneExited);
464
465        assert_eq!(first.len(), 1);
466        assert!(second.is_empty());
467    }
468
469    #[test]
470    fn window_aliases_share_bindings_but_session_hooks_do_not() {
471        let owner = HookScopeIdentity::Window {
472            session_name: session_name("owner"),
473            window_id: WindowId::new(7),
474        };
475        let peer = HookScopeIdentity::Window {
476            session_name: session_name("peer"),
477            window_id: WindowId::new(7),
478        };
479        let mut store = HookStore::new();
480        store.set_with_identity(
481            owner,
482            HookName::WindowLayoutChanged,
483            "set-option @window yes".to_owned(),
484            HookLifecycle::Persistent,
485            HookSetOptions::default(),
486        );
487        store.set_with_identity(
488            HookScopeIdentity::Session(session_name("owner")),
489            HookName::ClientAttached,
490            "set-option @session yes".to_owned(),
491            HookLifecycle::Persistent,
492            HookSetOptions::default(),
493        );
494
495        assert_eq!(
496            store
497                .dispatch_with_identity(&peer, HookName::WindowLayoutChanged)
498                .len(),
499            1
500        );
501        assert!(store
502            .dispatch_with_identity(&peer, HookName::ClientAttached)
503            .is_empty());
504    }
505
506    #[test]
507    fn retaining_live_ids_purges_destroyed_objects_only() {
508        let mut store = HookStore::new();
509        for pane_id in [PaneId::new(1), PaneId::new(2)] {
510            store.set_with_identity(
511                HookScopeIdentity::Pane {
512                    session_name: session_name("owner"),
513                    window_id: WindowId::new(pane_id.as_u32()),
514                    pane_id,
515                },
516                HookName::PaneExited,
517                format!("set-option @pane{} yes", pane_id.as_u32()),
518                HookLifecycle::Persistent,
519                HookSetOptions::default(),
520            );
521        }
522
523        store.retain_identities(
524            &HashSet::from([WindowId::new(2)]),
525            &HashSet::from([PaneId::new(2)]),
526        );
527
528        assert_eq!(
529            store.pane_command_by_id(PaneId::new(1), HookName::PaneExited),
530            None
531        );
532        assert!(store
533            .pane_command_by_id(PaneId::new(2), HookName::PaneExited)
534            .is_some());
535    }
536}