1use std::collections::BTreeMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18
19#[derive(Clone, Default)]
24pub struct ExtensionStatusMailbox {
25 entries: Arc<Mutex<BTreeMap<String, String>>>,
26 components: Arc<Mutex<BTreeMap<(u64, String), (crate::ExtensionPanel, bool)>>>,
27 revision: Arc<AtomicU64>,
29}
30
31impl ExtensionStatusMailbox {
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn set(&self, key: &str, value: &str) -> bool {
41 let key = key.trim();
42 if key.is_empty() {
43 return false;
44 }
45 let Ok(mut entries) = self.entries.lock() else {
46 return false;
47 };
48 let changed = if value.trim().is_empty() {
49 entries.remove(key).is_some()
50 } else {
51 entries.insert(key.to_string(), value.to_string()) != Some(value.to_string())
52 };
53 if changed {
54 self.revision.fetch_add(1, Ordering::SeqCst);
55 }
56 changed
57 }
58
59 pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
61 let key = args
62 .get("key")
63 .and_then(serde_json::Value::as_str)
64 .ok_or("set_status requires a string `key`")?;
65 if args.get("panel").is_some() {
66 return Err("panel was removed; use UiComponent".into());
67 }
68 let value = args
69 .get("value")
70 .and_then(serde_json::Value::as_str)
71 .unwrap_or("");
72 let changed = self.set(key, value);
73 Ok(serde_json::json!({ "ok": true, "changed": changed }))
74 }
75
76 pub fn component(
77 &self,
78 owner: u64,
79 args: serde_json::Value,
80 ) -> Result<serde_json::Value, String> {
81 let id = args["id"]
82 .as_str()
83 .filter(|s| !s.trim().is_empty() && s.len() <= 128)
84 .ok_or("invalid component id")?;
85 let key = (owner, id.to_owned());
86 let op = args["op"].as_str().ok_or("missing component operation")?;
87 let mut entries = self
88 .components
89 .lock()
90 .map_err(|_| "component registry unavailable")?;
91 let before = entries.get(&key).cloned();
92 let allowed: &[&str] = match op {
93 "register" => &[
94 "op",
95 "id",
96 "type",
97 "slot",
98 "order",
99 "visible",
100 "anchor",
101 "offsetX",
102 "offsetY",
103 "width",
104 "maxHeight",
105 "minScreenWidth",
106 "border",
107 ],
108 "configure" => &[
109 "op",
110 "id",
111 "slot",
112 "order",
113 "anchor",
114 "offsetX",
115 "offsetY",
116 "width",
117 "maxHeight",
118 "minScreenWidth",
119 "border",
120 ],
121 "update" => &["op", "id", "text", "title"],
122 _ => &["op", "id"],
123 };
124 if args
125 .as_object()
126 .ok_or("component payload must be an object")?
127 .keys()
128 .any(|k| !allowed.contains(&k.as_str()))
129 {
130 return Err("unexpected component field".into());
131 }
132 let next = match op {
133 "register" if before.is_some() => before.clone(),
134 "remove" => None,
135 "register" | "configure" | "update" | "show" | "hide" => {
136 let (mut spec, mut visible) = if op == "register" {
137 if entries.keys().filter(|(o, _)| *o == owner).count() >= 32 {
138 return Err("too many components".into());
139 }
140 if args.get("type").and_then(|v| v.as_str()).unwrap_or("text") != "text" {
141 return Err("unsupported component type".into());
142 }
143 (
144 serde_json::json!({"version":1,"lines":[]}),
145 args["visible"].as_bool().unwrap_or(true),
146 )
147 } else {
148 let (panel, visible) = before.clone().ok_or("component is not registered")?;
149 (
150 serde_json::to_value(panel).map_err(|e| e.to_string())?,
151 visible,
152 )
153 };
154 if op == "register" || op == "configure" {
155 if let Some(slot) = args.get("slot") {
156 let (layout, anchor) = match slot.as_str().ok_or("invalid slot")? {
157 "footer.after" => ("footer", "top-right"),
158 "sidebar.left" => ("sidebar", "top-left"),
159 "sidebar.right" => ("sidebar", "top-right"),
160 "overlay" => ("overlay", "top-right"),
161 _ => return Err("unsupported component slot".into()),
162 };
163 spec["layout"] = layout.into();
164 spec["anchor"] = anchor.into();
165 }
166 for field in [
167 "order",
168 "anchor",
169 "offsetX",
170 "offsetY",
171 "width",
172 "maxHeight",
173 "minScreenWidth",
174 "border",
175 ] {
176 if let Some(v) = args.get(field) {
177 spec[field] = v.clone();
178 }
179 }
180 } else if op == "update" {
181 if args
182 .as_object()
183 .unwrap()
184 .keys()
185 .any(|k| !["op", "id", "text", "title"].contains(&k.as_str()))
186 {
187 return Err(
188 "update accepts text and title; use configure for layout".into()
189 );
190 }
191 if let Some(v) = args.get("text") {
192 spec["lines"] = serde_json::json!(v
193 .as_str()
194 .ok_or("text must be a string")?
195 .split('\n')
196 .collect::<Vec<_>>());
197 }
198 if let Some(v) = args.get("title") {
199 spec["title"] = v.as_str().ok_or("title must be a string")?.into();
200 }
201 } else {
202 visible = op == "show";
203 }
204 let panel: crate::ExtensionPanel =
205 serde_json::from_value(spec).map_err(|e| e.to_string())?;
206 panel.validate()?;
207 Some((panel, visible))
208 }
209 _ => return Err("unknown component operation".into()),
210 };
211 let changed = before != next;
212 if changed {
213 match next {
214 Some(v) => {
215 entries.insert(key, v);
216 }
217 None => {
218 entries.remove(&key);
219 }
220 }
221 self.revision.fetch_add(1, Ordering::SeqCst);
222 }
223 Ok(serde_json::json!({"ok":true,"changed":changed}))
224 }
225 pub fn remove_owner(&self, owner: u64) {
226 if let Ok(mut entries) = self.components.lock() {
227 let size = entries.len();
228 entries.retain(|(o, _), _| *o != owner);
229 if size != entries.len() {
230 self.revision.fetch_add(1, Ordering::SeqCst);
231 }
232 }
233 }
234
235 pub fn revision(&self) -> u64 {
237 self.revision.load(Ordering::SeqCst)
238 }
239
240 pub fn get(&self, key: &str) -> Option<String> {
242 self.entries.lock().ok()?.get(key).cloned()
243 }
244
245 pub fn panels(&self) -> BTreeMap<String, crate::ExtensionPanel> {
247 let mut panels = BTreeMap::new();
248 if let Ok(entries) = self.components.lock() {
249 for ((owner, id), (spec, visible)) in entries.iter() {
250 if *visible {
251 panels.insert(
252 format!(
253 "component:{:010}:{owner}:{id}",
254 (spec.order as i64) - (i32::MIN as i64)
255 ),
256 spec.clone(),
257 );
258 }
259 }
260 }
261 panels
262 }
263
264 pub fn text_except(&self, keys: &[&str]) -> String {
266 self.entries
267 .lock()
268 .map(|entries| {
269 entries
270 .iter()
271 .filter(|(key, value)| {
272 !keys.contains(&key.as_str()) && !value.trim().is_empty()
273 })
274 .map(|(_, value)| value.as_str())
275 .collect::<Vec<_>>()
276 .join(" ")
277 })
278 .unwrap_or_default()
279 }
280
281 pub fn text(&self) -> String {
283 self.entries
284 .lock()
285 .map(|entries| {
286 entries
287 .values()
288 .filter(|value| !value.trim().is_empty())
289 .cloned()
290 .collect::<Vec<_>>()
291 .join(" ")
292 })
293 .unwrap_or_default()
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn components_keep_layout_content_visibility_and_owners_independent() {
303 let m = ExtensionStatusMailbox::new();
304 let register =
305 serde_json::json!({"op":"register","id":"stats","slot":"footer.after","visible":false});
306 m.component(1, register.clone()).unwrap();
307 m.component(2, register.clone()).unwrap();
308 assert!(m.panels().is_empty());
309 m.component(
310 1,
311 serde_json::json!({"op":"update","id":"stats","text":"TPS 252"}),
312 )
313 .unwrap();
314 m.component(1, serde_json::json!({"op":"show","id":"stats"}))
315 .unwrap();
316 let rev = m.revision();
317 m.component(1, register).unwrap();
318 assert_eq!(m.revision(), rev);
319 assert_eq!(
320 m.panels()["component:2147483648:1:stats"].layout,
321 crate::PanelLayout::Footer
322 );
323 assert!(m
324 .component(
325 1,
326 serde_json::json!({"op":"update","id":"stats","slot":"overlay"})
327 )
328 .is_err());
329 assert!(m
330 .component(
331 3,
332 serde_json::json!({"op":"update","id":"stats","text":"x"})
333 )
334 .is_err());
335 m.component(1, serde_json::json!({"op":"hide","id":"stats"}))
336 .unwrap();
337 m.component(
338 1,
339 serde_json::json!({"op":"configure","id":"stats","slot":"sidebar.left"}),
340 )
341 .unwrap();
342 m.component(1, serde_json::json!({"op":"show","id":"stats"}))
343 .unwrap();
344 assert_eq!(
345 m.panels()["component:2147483648:1:stats"].lines,
346 ["TPS 252"]
347 );
348 m.remove_owner(1);
349 assert!(m.panels().is_empty());
350 m.component(2, serde_json::json!({"op":"show","id":"stats"}))
351 .unwrap();
352 assert_eq!(m.panels().len(), 1);
353 }
354 #[test]
355 fn set_and_clear_round_trip() {
356 let mailbox = ExtensionStatusMailbox::new();
357 assert_eq!(mailbox.text(), "");
358 assert!(mailbox.set("langfuse", "langfuse ✓"));
359 assert_eq!(mailbox.text(), "langfuse ✓");
360 assert!(!mailbox.set("langfuse", "langfuse ✓"));
362 assert!(mailbox.set("langfuse", "langfuse ✓ (trace sent)"));
363 assert_eq!(mailbox.text(), "langfuse ✓ (trace sent)");
364 let before = mailbox.revision();
365 assert!(mailbox.set("langfuse", ""));
367 assert_eq!(mailbox.text(), "");
368 assert!(mailbox.revision() > before);
369 assert!(!mailbox.set("langfuse", ""));
371 assert!(!mailbox.set(" ", "ignored"));
372 }
373
374 #[test]
375 fn multiple_extensions_render_in_key_order() {
376 let mailbox = ExtensionStatusMailbox::new();
377 mailbox.set("zeta", "z");
378 mailbox.set("alpha", "a");
379 assert_eq!(mailbox.text(), "a z");
380 }
381
382 #[test]
383 fn handle_parses_the_action_payload() {
384 let mailbox = ExtensionStatusMailbox::new();
385 let out = mailbox
386 .handle(serde_json::json!({"key": "langfuse", "value": "✓"}))
387 .expect("ok");
388 assert_eq!(out["ok"], serde_json::json!(true));
389 assert_eq!(mailbox.text(), "✓");
390 assert!(mailbox.handle(serde_json::json!({"value": "x"})).is_err());
391 }
392}