1use crate::{AlertState, ClientFocus, DirectoryRecord, DomainSnapshot, DomainState};
2
3#[derive(Debug, Clone, PartialEq)]
4pub enum DomainEvent {
5 SnapshotLoaded(DomainSnapshot),
6 FocusChanged {
7 client_id: String,
8 focus: ClientFocus,
9 },
10 AlertChanged {
11 window_id: String,
12 alerts: AlertState,
13 },
14 OutputChanged {
15 pane_id: String,
16 },
17 DirectoriesUpdated(Vec<DirectoryRecord>),
18}
19
20pub fn reduce_domain_event(state: &mut DomainState, event: DomainEvent) {
21 match event {
22 DomainEvent::SnapshotLoaded(snapshot) => {
23 let mut previous = state.previous_session_by_client.clone();
24 for (client_id, next_focus) in &snapshot.clients {
25 if let Some(current_focus) = state.clients.get(client_id)
26 && current_focus.session_id != next_focus.session_id
27 {
28 previous.insert(client_id.clone(), current_focus.session_id.clone());
29 }
30 }
31
32 state.sessions = snapshot.sessions;
33 state.clients = snapshot.clients;
34 state.directories = snapshot.directories;
35 state.previous_session_by_client = previous;
36 state.recompute_aggregates();
37 }
38 DomainEvent::FocusChanged { client_id, focus } => {
39 if let Some(current_focus) = state.clients.get(&client_id)
40 && current_focus.session_id != focus.session_id
41 {
42 state
43 .previous_session_by_client
44 .insert(client_id.clone(), current_focus.session_id.clone());
45 }
46
47 let session_id = focus.session_id.clone();
48 let window_id = focus.window_id.clone();
49 state.clients.insert(client_id, focus);
50 if state.config.notifications.clear_on_focus {
51 state.clear_unseen_for_window(&session_id, &window_id);
52 } else {
53 state.recompute_session_aggregate(&session_id);
54 }
55 }
56 DomainEvent::AlertChanged { window_id, alerts } => {
57 if let Some(session_id) = state.session_id_for_window(&window_id) {
58 if let Some(window) = state
59 .sessions
60 .get_mut(&session_id)
61 .and_then(|session| session.windows.get_mut(&window_id))
62 {
63 window.alerts = AlertState {
64 unseen_output: window.alerts.unseen_output,
65 ..alerts
66 };
67 }
68 state.recompute_session_aggregate(&session_id);
69 }
70 }
71 DomainEvent::OutputChanged { pane_id } => {
72 if !state.config.notifications.track_unseen_output {
73 return;
74 }
75
76 if let Some((session_id, window_id)) = state.session_window_for_pane(&pane_id) {
77 let focused_session = state.focused_session_for_window(&window_id);
78 if focused_session.as_deref() == Some(session_id.as_str()) {
79 return;
80 }
81
82 if let Some(window) = state
83 .sessions
84 .get_mut(&session_id)
85 .and_then(|session| session.windows.get_mut(&window_id))
86 {
87 window.has_unseen = true;
88 window.alerts.unseen_output = true;
89 }
90 state.recompute_session_aggregate(&session_id);
91 }
92 }
93 DomainEvent::DirectoriesUpdated(entries) => {
94 state.directories = entries;
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use std::collections::BTreeMap;
102
103 use crate::{
104 AlertState, AttentionBadge, ClientFocus, DomainConfig, DomainEvent, DomainState,
105 NotificationConfig, PaneRecord, SessionRecord, SessionSortKey, WindowRecord,
106 reduce_domain_event,
107 };
108
109 fn seeded_state() -> DomainState {
110 DomainState {
111 sessions: BTreeMap::from([(
112 "alpha".to_string(),
113 SessionRecord {
114 id: "alpha".to_string(),
115 tmux_id: None,
116 name: "alpha".to_string(),
117 attached: true,
118 windows: BTreeMap::from([(
119 "alpha:1".to_string(),
120 WindowRecord {
121 id: "alpha:1".to_string(),
122 index: 1,
123 name: "shell".to_string(),
124 active: true,
125 panes: BTreeMap::from([(
126 "alpha:1.1".to_string(),
127 PaneRecord {
128 id: "alpha:1.1".to_string(),
129 index: 1,
130 title: None,
131 current_path: None,
132 current_command: None,
133 is_active: true,
134 },
135 )]),
136 alerts: AlertState::default(),
137 has_unseen: false,
138 current_path: None,
139 active_command: None,
140 },
141 )]),
142 aggregate_alerts: Default::default(),
143 has_unseen: false,
144 sort_key: SessionSortKey::default(),
145 },
146 )]),
147 clients: BTreeMap::from([(
148 "client-1".to_string(),
149 ClientFocus {
150 session_id: "alpha".to_string(),
151 window_id: "alpha:1".to_string(),
152 pane_id: Some("alpha:1.1".to_string()),
153 },
154 )]),
155 previous_session_by_client: BTreeMap::new(),
156 directories: Vec::new(),
157 config: DomainConfig {
158 notifications: NotificationConfig {
159 track_unseen_output: true,
160 clear_on_focus: true,
161 show_silence: true,
162 },
163 },
164 }
165 }
166
167 #[test]
168 fn tracks_previous_session_per_client() {
169 let mut state = seeded_state();
170 state.sessions.insert(
171 "beta".to_string(),
172 SessionRecord {
173 id: "beta".to_string(),
174 tmux_id: None,
175 name: "beta".to_string(),
176 attached: false,
177 windows: BTreeMap::from([(
178 "beta:1".to_string(),
179 WindowRecord {
180 id: "beta:1".to_string(),
181 index: 1,
182 name: "editor".to_string(),
183 active: true,
184 panes: BTreeMap::new(),
185 alerts: AlertState::default(),
186 has_unseen: false,
187 current_path: None,
188 active_command: None,
189 },
190 )]),
191 aggregate_alerts: Default::default(),
192 has_unseen: false,
193 sort_key: SessionSortKey::default(),
194 },
195 );
196
197 reduce_domain_event(
198 &mut state,
199 DomainEvent::FocusChanged {
200 client_id: "client-1".to_string(),
201 focus: ClientFocus {
202 session_id: "beta".to_string(),
203 window_id: "beta:1".to_string(),
204 pane_id: None,
205 },
206 },
207 );
208
209 assert_eq!(
210 state.previous_session_by_client.get("client-1"),
211 Some(&"alpha".to_string())
212 );
213 }
214
215 #[test]
216 fn marks_unseen_output_on_non_focused_windows() {
217 let mut state = seeded_state();
218 state.sessions.insert(
219 "beta".to_string(),
220 SessionRecord {
221 id: "beta".to_string(),
222 tmux_id: None,
223 name: "beta".to_string(),
224 attached: false,
225 windows: BTreeMap::from([(
226 "beta:1".to_string(),
227 WindowRecord {
228 id: "beta:1".to_string(),
229 index: 1,
230 name: "logs".to_string(),
231 active: true,
232 panes: BTreeMap::from([(
233 "beta:1.1".to_string(),
234 PaneRecord {
235 id: "beta:1.1".to_string(),
236 index: 1,
237 title: None,
238 current_path: None,
239 current_command: None,
240 is_active: true,
241 },
242 )]),
243 alerts: AlertState::default(),
244 has_unseen: false,
245 current_path: None,
246 active_command: None,
247 },
248 )]),
249 aggregate_alerts: Default::default(),
250 has_unseen: false,
251 sort_key: SessionSortKey::default(),
252 },
253 );
254
255 reduce_domain_event(
256 &mut state,
257 DomainEvent::OutputChanged {
258 pane_id: "beta:1.1".to_string(),
259 },
260 );
261
262 assert!(state.sessions["beta"].has_unseen);
263 assert_eq!(
264 state.sessions["beta"].aggregate_alerts.highest_priority,
265 AttentionBadge::Unseen
266 );
267 }
268
269 #[test]
270 fn clears_unseen_on_focus_when_configured() {
271 let mut state = seeded_state();
272 state.sessions.insert(
273 "beta".to_string(),
274 SessionRecord {
275 id: "beta".to_string(),
276 tmux_id: None,
277 name: "beta".to_string(),
278 attached: false,
279 windows: BTreeMap::from([(
280 "beta:1".to_string(),
281 WindowRecord {
282 id: "beta:1".to_string(),
283 index: 1,
284 name: "logs".to_string(),
285 active: true,
286 panes: BTreeMap::new(),
287 alerts: AlertState {
288 unseen_output: true,
289 ..AlertState::default()
290 },
291 has_unseen: true,
292 current_path: None,
293 active_command: None,
294 },
295 )]),
296 aggregate_alerts: Default::default(),
297 has_unseen: true,
298 sort_key: SessionSortKey::default(),
299 },
300 );
301 state.recompute_aggregates();
302
303 reduce_domain_event(
304 &mut state,
305 DomainEvent::FocusChanged {
306 client_id: "client-1".to_string(),
307 focus: ClientFocus {
308 session_id: "beta".to_string(),
309 window_id: "beta:1".to_string(),
310 pane_id: None,
311 },
312 },
313 );
314
315 assert!(!state.sessions["beta"].has_unseen);
316 }
317}