1use std::collections::HashMap;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{LazyLock, Mutex};
28use std::time::{Duration, Instant};
29
30use crate::backend::obj::*;
31use crate::backend::service::{self, WebCallType, WebReturnType};
32
33use super::super::AppState;
34use super::reducer_helpers::{dispatch_to_reducer, publish_events};
35
36pub(crate) const INHERITED_KEYS: &[&str] = &["window:theme", "window:opacity"];
38
39const PENDING_TTL: Duration = Duration::from_secs(30);
42
43static SEQ: AtomicU64 = AtomicU64::new(1);
46
47struct Pending {
48 source_window_id: String,
49 after: u64,
51 at: Instant,
52}
53
54static PENDING: LazyLock<Mutex<HashMap<String, Pending>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
56
57static CREATED: LazyLock<Mutex<HashMap<String, (String, u64, Instant)>>> =
59 LazyLock::new(|| Mutex::new(HashMap::new()));
60
61fn next_seq() -> u64 {
62 SEQ.fetch_add(1, Ordering::SeqCst)
63}
64
65pub(crate) fn record_created(label: &str, window_id: &str) -> u64 {
68 let seq = next_seq();
69 let mut created = CREATED.lock().unwrap_or_else(|e| e.into_inner());
70 created.retain(|_, (_, _, at)| at.elapsed() < PENDING_TTL);
71 created.insert(label.to_string(), (window_id.to_string(), seq, Instant::now()));
72 seq
73}
74
75pub(crate) fn take_pending(label: &str, row_seq: u64) -> Option<String> {
78 let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
79 let entry = pending.get(label)?;
80 if row_seq <= entry.after {
81 return None;
82 }
83 let entry = pending.remove(label)?;
84 (entry.at.elapsed() < PENDING_TTL).then_some(entry.source_window_id)
85}
86
87fn record_pending(label: &str, source_window_id: &str, after: u64) {
88 let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
89 pending.retain(|_, p| p.at.elapsed() < PENDING_TTL);
90 pending.insert(
91 label.to_string(),
92 Pending {
93 source_window_id: source_window_id.to_string(),
94 after,
95 at: Instant::now(),
96 },
97 );
98}
99
100pub(crate) fn appearance_patch(source: &Window) -> Option<serde_json::Value> {
103 let patch: serde_json::Map<String, serde_json::Value> = INHERITED_KEYS
104 .iter()
105 .filter_map(|k| {
106 source
107 .meta
108 .get(*k)
109 .filter(|v| !v.is_null())
110 .map(|v| (k.to_string(), v.clone()))
111 })
112 .collect();
113 (!patch.is_empty()).then(|| serde_json::Value::Object(patch))
114}
115
116pub(crate) async fn apply_inheritance(
119 state: &AppState,
120 source_window_id: &str,
121 target_window_id: &str,
122) -> Vec<agentmux_common::ipc::Event> {
123 let store = &state.mstore;
124 let Some(patch) = store
125 .get::<Window>(source_window_id)
126 .ok()
127 .flatten()
128 .and_then(|w| appearance_patch(&w))
129 else {
130 return Vec::new();
131 };
132 let evs = dispatch_to_reducer(
133 state,
134 agentmux_common::ipc::Command::UpdateWindowMeta {
135 window_id: target_window_id.to_string(),
136 meta_patch: patch,
137 },
138 )
139 .await;
140 for ev in &evs {
141 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
142 tracing::warn!(
143 window_id = %target_window_id,
144 error = %e,
145 "InheritWindowAppearance: meta write failed — window keeps the defaults"
146 );
147 }
148 }
149 evs
150}
151
152pub(crate) async fn handle_mark_window_open(_state: &AppState, _call: &WebCallType) -> WebReturnType {
155 WebReturnType::success(serde_json::json!(next_seq()))
156}
157
158pub(crate) async fn handle_inherit_window_appearance(state: &AppState, call: &WebCallType) -> WebReturnType {
161 let args = &call.args;
162 let source_window_id: String = match service::get_arg(args, 0) {
163 Ok(v) => v,
164 Err(e) => return WebReturnType::error(e),
165 };
166 let label: String = match service::get_arg(args, 1) {
167 Ok(v) => v,
168 Err(e) => return WebReturnType::error(e),
169 };
170 let mark: u64 = match service::get_arg(args, 2) {
171 Ok(v) => v,
172 Err(e) => return WebReturnType::error(e),
173 };
174 if source_window_id.is_empty() || label.is_empty() {
175 return WebReturnType::error("InheritWindowAppearance: empty sourceWindowId or hostLabel".to_string());
176 }
177 record_pending(&label, &source_window_id, mark);
178 let created = CREATED.lock().unwrap_or_else(|e| e.into_inner()).get(&label).cloned();
181 if let Some((target, row_seq, _)) = created {
182 if target != source_window_id {
183 if let Some(source) = take_pending(&label, row_seq) {
184 let evs = apply_inheritance(state, &source, &target).await;
185 publish_events(state, &evs);
186 }
187 }
188 }
189 WebReturnType::success_empty()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::super::window::handle_window_service;
195 use crate::backend::obj::Window;
196 use crate::backend::service::WebCallType;
197 use crate::server::tests::test_state;
198
199 fn call(method: &str, args: Vec<serde_json::Value>) -> WebCallType {
200 WebCallType {
201 service: "window".to_string(),
202 method: method.to_string(),
203 uicontext: None,
204 args,
205 }
206 }
207
208 async fn create(state: &crate::server::AppState, label: &str) -> String {
209 let ret = handle_window_service(
210 state,
211 &call("CreateWindow", vec![serde_json::Value::Null, "".into(), label.into()]),
212 )
213 .await;
214 assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
215 ret.data
216 .as_ref()
217 .and_then(|d| d.get("oid"))
218 .and_then(|v| v.as_str())
219 .expect("CreateWindow returns the Window with oid")
220 .to_string()
221 }
222
223 async fn set_meta(state: &crate::server::AppState, window_id: &str, patch: serde_json::Value) {
224 let evs = super::dispatch_to_reducer(
225 state,
226 agentmux_common::ipc::Command::UpdateWindowMeta {
227 window_id: window_id.to_string(),
228 meta_patch: patch,
229 },
230 )
231 .await;
232 for ev in &evs {
233 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
234 }
235 }
236
237 fn meta(state: &crate::server::AppState, window_id: &str, key: &str) -> Option<serde_json::Value> {
238 state.mstore.must_get::<Window>(window_id).unwrap().meta.get(key).cloned()
239 }
240
241 async fn mark(state: &crate::server::AppState) -> u64 {
242 let ret = handle_window_service(state, &call("MarkWindowOpen", vec![])).await;
243 assert!(ret.success, "MarkWindowOpen failed: {:?}", ret.error);
244 ret.data.and_then(|d| d.as_u64()).expect("MarkWindowOpen returns a number")
245 }
246
247 async fn inherit(state: &crate::server::AppState, source: &str, label: &str, mark: u64) {
248 let ret = handle_window_service(
249 state,
250 &call("InheritWindowAppearance", vec![source.into(), label.into(), mark.into()]),
251 )
252 .await;
253 assert!(ret.success, "InheritWindowAppearance failed: {:?}", ret.error);
254 }
255
256 #[tokio::test]
258 async fn inherits_when_the_creator_calls_before_the_window_exists() {
259 let state = test_state();
260 let source = create(&state, "inherit-src-a").await;
261 set_meta(&state, &source, serde_json::json!({ "window:theme": "dracula", "window:opacity": 0.7 })).await;
262
263 let m = mark(&state).await;
264 inherit(&state, &source, "inherit-new-a", m).await;
265 let new = create(&state, "inherit-new-a").await;
266
267 assert_eq!(meta(&state, &new, "window:theme"), Some("dracula".into()));
268 assert_eq!(meta(&state, &new, "window:opacity"), Some(serde_json::json!(0.7)));
269 }
270
271 #[tokio::test]
273 async fn inherits_when_the_window_exists_before_the_creator_calls() {
274 let state = test_state();
275 let source = create(&state, "inherit-src-b").await;
276 set_meta(&state, &source, serde_json::json!({ "window:theme": "nord" })).await;
277
278 let m = mark(&state).await;
279 let new = create(&state, "inherit-new-b").await;
280 assert_eq!(meta(&state, &new, "window:theme"), None);
281 inherit(&state, &source, "inherit-new-b", m).await;
282
283 assert_eq!(meta(&state, &new, "window:theme"), Some("nord".into()));
284 assert_eq!(meta(&state, &new, "window:opacity"), None);
286 }
287
288 #[tokio::test]
291 async fn an_entry_is_used_once() {
292 let state = test_state();
293 let source = create(&state, "inherit-src-c").await;
294 set_meta(&state, &source, serde_json::json!({ "window:theme": "dracula" })).await;
295
296 let plain = create(&state, "inherit-new-c").await;
297 assert_eq!(meta(&state, &plain, "window:theme"), None);
298
299 let m = mark(&state).await;
300 inherit(&state, &source, "inherit-new-d", m).await;
301 let first = create(&state, "inherit-new-d").await;
302 let second = create(&state, "inherit-new-d").await;
303 assert_eq!(meta(&state, &first, "window:theme"), Some("dracula".into()));
304 assert_eq!(meta(&state, &second, "window:theme"), None);
305 }
306
307 #[tokio::test]
311 async fn a_row_from_an_earlier_use_of_the_label_does_not_take_it() {
312 let state = test_state();
313 let source = create(&state, "inherit-src-f").await;
314 set_meta(&state, &source, serde_json::json!({ "window:theme": "nord" })).await;
315 let earlier = create(&state, "inherit-new-f").await;
316
317 let m = mark(&state).await;
318 inherit(&state, &source, "inherit-new-f", m).await;
319 assert_eq!(meta(&state, &earlier, "window:theme"), None);
320 let new = create(&state, "inherit-new-f").await;
321 assert_eq!(meta(&state, &new, "window:theme"), Some("nord".into()));
322 }
323
324 #[tokio::test]
328 async fn a_reload_keeps_the_windows_own_appearance() {
329 let state = test_state();
330 let before = create(&state, "inherit-reload-a").await;
331 set_meta(&state, &before, serde_json::json!({ "window:theme": "nord", "window:opacity": 0.5 })).await;
332
333 let ret = handle_window_service(
334 &state,
335 &call(
336 "CreateWindow",
337 vec![
338 serde_json::Value::Null,
339 "".into(),
340 "inherit-reload-a".into(),
341 serde_json::Value::Null,
342 serde_json::Value::Null,
343 serde_json::Value::Null,
344 before.clone().into(),
345 ],
346 ),
347 )
348 .await;
349 assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
350 let after = ret.data.as_ref().and_then(|d| d.get("oid")).and_then(|v| v.as_str()).unwrap().to_string();
351 assert_ne!(after, before);
352 assert_eq!(meta(&state, &after, "window:theme"), Some("nord".into()));
353 assert_eq!(meta(&state, &after, "window:opacity"), Some(serde_json::json!(0.5)));
354 }
355
356 #[tokio::test]
358 async fn a_default_source_copies_nothing() {
359 let state = test_state();
360 let source = create(&state, "inherit-src-e").await;
361 let m = mark(&state).await;
362 inherit(&state, &source, "inherit-new-e", m).await;
363 let new = create(&state, "inherit-new-e").await;
364 assert_eq!(meta(&state, &new, "window:theme"), None);
365 assert_eq!(meta(&state, &new, "window:opacity"), None);
366 }
367
368 #[tokio::test]
369 async fn rejects_empty_or_missing_args() {
370 let state = test_state();
371 let ret = handle_window_service(&state, &call("InheritWindowAppearance", vec!["".into(), "x".into(), 1.into()])).await;
372 assert!(!ret.success);
373 let ret = handle_window_service(&state, &call("InheritWindowAppearance", vec!["w".into(), "x".into()])).await;
374 assert!(!ret.success, "a request without a mark is refused");
375 }
376}