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}