Skip to main content

wisp_core/
domain.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3pub type SessionId = String;
4pub type WindowId = String;
5pub type PaneId = String;
6pub type ClientId = String;
7
8#[derive(Debug, Clone, Default, PartialEq)]
9pub struct DomainState {
10    pub sessions: BTreeMap<SessionId, SessionRecord>,
11    pub clients: BTreeMap<ClientId, ClientFocus>,
12    pub previous_session_by_client: BTreeMap<ClientId, SessionId>,
13    pub directories: Vec<DirectoryRecord>,
14    pub config: DomainConfig,
15}
16
17impl DomainState {
18    pub fn recompute_aggregates(&mut self) {
19        let session_ids = self.sessions.keys().cloned().collect::<Vec<_>>();
20        for session_id in session_ids {
21            self.recompute_session_aggregate(&session_id);
22        }
23    }
24
25    #[must_use]
26    pub fn current_session_id(&self, client_id: Option<&str>) -> Option<&SessionId> {
27        match client_id {
28            Some(client_id) => self.clients.get(client_id).map(|focus| &focus.session_id),
29            None => self.clients.values().next().map(|focus| &focus.session_id),
30        }
31    }
32
33    #[must_use]
34    pub fn previous_session_id(&self, client_id: Option<&str>) -> Option<&SessionId> {
35        match client_id {
36            Some(client_id) => self.previous_session_by_client.get(client_id),
37            None => self.previous_session_by_client.values().next(),
38        }
39    }
40
41    #[must_use]
42    pub fn focused_session_for_window(&self, window_id: &str) -> Option<SessionId> {
43        self.clients.values().find_map(|focus| {
44            if focus.window_id == window_id {
45                Some(focus.session_id.clone())
46            } else {
47                None
48            }
49        })
50    }
51
52    #[must_use]
53    pub fn session_id_for_window(&self, window_id: &str) -> Option<SessionId> {
54        self.sessions.iter().find_map(|(session_id, session)| {
55            if session.windows.contains_key(window_id) {
56                Some(session_id.clone())
57            } else {
58                None
59            }
60        })
61    }
62
63    #[must_use]
64    pub fn session_window_for_pane(&self, pane_id: &str) -> Option<(SessionId, WindowId)> {
65        self.sessions.iter().find_map(|(session_id, session)| {
66            session.windows.iter().find_map(|(window_id, window)| {
67                if window.panes.contains_key(pane_id) {
68                    Some((session_id.clone(), window_id.clone()))
69                } else {
70                    None
71                }
72            })
73        })
74    }
75
76    pub fn clear_unseen_for_window(&mut self, session_id: &str, window_id: &str) {
77        if let Some(window) = self
78            .sessions
79            .get_mut(session_id)
80            .and_then(|session| session.windows.get_mut(window_id))
81        {
82            window.has_unseen = false;
83            window.alerts.unseen_output = false;
84        }
85        self.recompute_session_aggregate(session_id);
86    }
87
88    pub fn recompute_session_aggregate(&mut self, session_id: &str) {
89        let Some(session) = self.sessions.get_mut(session_id) else {
90            return;
91        };
92
93        session.has_unseen = session.windows.values().any(|window| window.has_unseen);
94        session.aggregate_alerts = aggregate_alerts(session.windows.values());
95    }
96}
97
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct DomainConfig {
100    pub notifications: NotificationConfig,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct NotificationConfig {
105    pub track_unseen_output: bool,
106    pub clear_on_focus: bool,
107    pub show_silence: bool,
108}
109
110impl Default for NotificationConfig {
111    fn default() -> Self {
112        Self {
113            track_unseen_output: true,
114            clear_on_focus: true,
115            show_silence: true,
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq)]
121pub struct DomainSnapshot {
122    pub sessions: BTreeMap<SessionId, SessionRecord>,
123    pub clients: BTreeMap<ClientId, ClientFocus>,
124    pub directories: Vec<DirectoryRecord>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct SessionRecord {
129    pub id: SessionId,
130    pub tmux_id: Option<String>,
131    pub name: String,
132    pub attached: bool,
133    pub windows: BTreeMap<WindowId, WindowRecord>,
134    pub aggregate_alerts: AlertAggregate,
135    pub has_unseen: bool,
136    pub sort_key: SessionSortKey,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Default)]
140pub struct SessionSortKey {
141    pub last_activity: Option<u64>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct WindowRecord {
146    pub id: WindowId,
147    pub index: i32,
148    pub name: String,
149    pub active: bool,
150    pub panes: BTreeMap<PaneId, PaneRecord>,
151    pub alerts: AlertState,
152    pub has_unseen: bool,
153    pub current_path: Option<PathBuf>,
154    pub active_command: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct PaneRecord {
159    pub id: PaneId,
160    pub index: i32,
161    pub title: Option<String>,
162    pub current_path: Option<PathBuf>,
163    pub current_command: Option<String>,
164    pub is_active: bool,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ClientFocus {
169    pub session_id: SessionId,
170    pub window_id: WindowId,
171    pub pane_id: Option<PaneId>,
172}
173
174#[derive(Debug, Clone, PartialEq)]
175pub struct DirectoryRecord {
176    pub path: PathBuf,
177    pub score: Option<f64>,
178    pub exists: bool,
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub struct AlertState {
183    pub activity: bool,
184    pub bell: bool,
185    pub silence: bool,
186    pub unseen_output: bool,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct AlertAggregate {
191    pub any_activity: bool,
192    pub any_bell: bool,
193    pub any_silence: bool,
194    pub any_unseen: bool,
195    pub attention_count: usize,
196    pub highest_priority: AttentionBadge,
197}
198
199impl Default for AlertAggregate {
200    fn default() -> Self {
201        Self {
202            any_activity: false,
203            any_bell: false,
204            any_silence: false,
205            any_unseen: false,
206            attention_count: 0,
207            highest_priority: AttentionBadge::None,
208        }
209    }
210}
211
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
213pub enum AttentionBadge {
214    #[default]
215    None,
216    Silence,
217    Unseen,
218    Activity,
219    Bell,
220}
221
222impl AttentionBadge {
223    #[must_use]
224    pub fn from_alerts(alerts: AlertState) -> Self {
225        if alerts.bell {
226            Self::Bell
227        } else if alerts.activity {
228            Self::Activity
229        } else if alerts.unseen_output {
230            Self::Unseen
231        } else if alerts.silence {
232            Self::Silence
233        } else {
234            Self::None
235        }
236    }
237}
238
239#[must_use]
240pub fn aggregate_alerts<'a>(windows: impl IntoIterator<Item = &'a WindowRecord>) -> AlertAggregate {
241    let mut aggregate = AlertAggregate::default();
242
243    for window in windows {
244        aggregate.any_activity |= window.alerts.activity;
245        aggregate.any_bell |= window.alerts.bell;
246        aggregate.any_silence |= window.alerts.silence;
247        aggregate.any_unseen |= window.alerts.unseen_output;
248
249        let badge = AttentionBadge::from_alerts(window.alerts);
250        if badge != AttentionBadge::None {
251            aggregate.attention_count += 1;
252            if badge > aggregate.highest_priority {
253                aggregate.highest_priority = badge;
254            }
255        }
256    }
257
258    aggregate
259}
260
261#[cfg(test)]
262mod tests {
263    use std::collections::BTreeMap;
264
265    use crate::{
266        AlertState, AttentionBadge, DomainState, PaneRecord, SessionRecord, SessionSortKey,
267        WindowRecord, aggregate_alerts,
268    };
269
270    #[test]
271    fn aggregates_alerts_using_consistent_priority() {
272        let windows = BTreeMap::from([
273            (
274                "w1".to_string(),
275                WindowRecord {
276                    id: "w1".to_string(),
277                    index: 1,
278                    name: "dev".to_string(),
279                    active: true,
280                    panes: BTreeMap::new(),
281                    alerts: AlertState {
282                        activity: true,
283                        ..AlertState::default()
284                    },
285                    has_unseen: false,
286                    current_path: None,
287                    active_command: None,
288                },
289            ),
290            (
291                "w2".to_string(),
292                WindowRecord {
293                    id: "w2".to_string(),
294                    index: 2,
295                    name: "ops".to_string(),
296                    active: false,
297                    panes: BTreeMap::from([(
298                        "p1".to_string(),
299                        PaneRecord {
300                            id: "p1".to_string(),
301                            index: 1,
302                            title: None,
303                            current_path: None,
304                            current_command: None,
305                            is_active: false,
306                        },
307                    )]),
308                    alerts: AlertState {
309                        bell: true,
310                        unseen_output: true,
311                        ..AlertState::default()
312                    },
313                    has_unseen: true,
314                    current_path: None,
315                    active_command: None,
316                },
317            ),
318        ]);
319
320        let aggregate = aggregate_alerts(windows.values());
321
322        assert_eq!(aggregate.attention_count, 2);
323        assert_eq!(aggregate.highest_priority, AttentionBadge::Bell);
324        assert!(aggregate.any_unseen);
325    }
326
327    #[test]
328    fn recomputes_session_aggregates_from_window_state() {
329        let mut state = DomainState {
330            sessions: BTreeMap::from([(
331                "alpha".to_string(),
332                SessionRecord {
333                    id: "alpha".to_string(),
334                    tmux_id: None,
335                    name: "alpha".to_string(),
336                    attached: true,
337                    windows: BTreeMap::from([(
338                        "alpha:1".to_string(),
339                        WindowRecord {
340                            id: "alpha:1".to_string(),
341                            index: 1,
342                            name: "shell".to_string(),
343                            active: true,
344                            panes: BTreeMap::new(),
345                            alerts: AlertState {
346                                unseen_output: true,
347                                ..AlertState::default()
348                            },
349                            has_unseen: true,
350                            current_path: None,
351                            active_command: None,
352                        },
353                    )]),
354                    aggregate_alerts: Default::default(),
355                    has_unseen: false,
356                    sort_key: SessionSortKey::default(),
357                },
358            )]),
359            ..DomainState::default()
360        };
361
362        state.recompute_aggregates();
363
364        assert!(state.sessions["alpha"].has_unseen);
365        assert_eq!(
366            state.sessions["alpha"].aggregate_alerts.highest_priority,
367            AttentionBadge::Unseen
368        );
369    }
370}