use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
pub struct ExtensionStatusMailbox {
entries: Arc<Mutex<BTreeMap<String, String>>>,
components: Arc<Mutex<BTreeMap<(u64, String), (crate::ExtensionPanel, bool)>>>,
revision: Arc<AtomicU64>,
}
impl ExtensionStatusMailbox {
pub fn new() -> Self {
Self::default()
}
pub fn set(&self, key: &str, value: &str) -> bool {
let key = key.trim();
if key.is_empty() {
return false;
}
let Ok(mut entries) = self.entries.lock() else {
return false;
};
let changed = if value.trim().is_empty() {
entries.remove(key).is_some()
} else {
entries.insert(key.to_string(), value.to_string()) != Some(value.to_string())
};
if changed {
self.revision.fetch_add(1, Ordering::SeqCst);
}
changed
}
pub fn handle(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let key = args
.get("key")
.and_then(serde_json::Value::as_str)
.ok_or("set_status requires a string `key`")?;
if args.get("panel").is_some() {
return Err("panel was removed; use UiComponent".into());
}
let value = args
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let changed = self.set(key, value);
Ok(serde_json::json!({ "ok": true, "changed": changed }))
}
pub fn component(
&self,
owner: u64,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let id = args["id"]
.as_str()
.filter(|s| !s.trim().is_empty() && s.len() <= 128)
.ok_or("invalid component id")?;
let key = (owner, id.to_owned());
let op = args["op"].as_str().ok_or("missing component operation")?;
let mut entries = self
.components
.lock()
.map_err(|_| "component registry unavailable")?;
let before = entries.get(&key).cloned();
let allowed: &[&str] = match op {
"register" => &[
"op",
"id",
"type",
"slot",
"order",
"visible",
"anchor",
"offsetX",
"offsetY",
"width",
"maxHeight",
"minScreenWidth",
"border",
],
"configure" => &[
"op",
"id",
"slot",
"order",
"anchor",
"offsetX",
"offsetY",
"width",
"maxHeight",
"minScreenWidth",
"border",
],
"update" => &["op", "id", "text", "title"],
_ => &["op", "id"],
};
if args
.as_object()
.ok_or("component payload must be an object")?
.keys()
.any(|k| !allowed.contains(&k.as_str()))
{
return Err("unexpected component field".into());
}
let next = match op {
"register" if before.is_some() => before.clone(),
"remove" => None,
"register" | "configure" | "update" | "show" | "hide" => {
let (mut spec, mut visible) = if op == "register" {
if entries.keys().filter(|(o, _)| *o == owner).count() >= 32 {
return Err("too many components".into());
}
if args.get("type").and_then(|v| v.as_str()).unwrap_or("text") != "text" {
return Err("unsupported component type".into());
}
(
serde_json::json!({"version":1,"lines":[]}),
args["visible"].as_bool().unwrap_or(true),
)
} else {
let (panel, visible) = before.clone().ok_or("component is not registered")?;
(
serde_json::to_value(panel).map_err(|e| e.to_string())?,
visible,
)
};
if op == "register" || op == "configure" {
if let Some(slot) = args.get("slot") {
let (layout, anchor) = match slot.as_str().ok_or("invalid slot")? {
"footer.after" => ("footer", "top-right"),
"sidebar.left" => ("sidebar", "top-left"),
"sidebar.right" => ("sidebar", "top-right"),
"overlay" => ("overlay", "top-right"),
_ => return Err("unsupported component slot".into()),
};
spec["layout"] = layout.into();
spec["anchor"] = anchor.into();
}
for field in [
"order",
"anchor",
"offsetX",
"offsetY",
"width",
"maxHeight",
"minScreenWidth",
"border",
] {
if let Some(v) = args.get(field) {
spec[field] = v.clone();
}
}
} else if op == "update" {
if args
.as_object()
.unwrap()
.keys()
.any(|k| !["op", "id", "text", "title"].contains(&k.as_str()))
{
return Err(
"update accepts text and title; use configure for layout".into()
);
}
if let Some(v) = args.get("text") {
spec["lines"] = serde_json::json!(v
.as_str()
.ok_or("text must be a string")?
.split('\n')
.collect::<Vec<_>>());
}
if let Some(v) = args.get("title") {
spec["title"] = v.as_str().ok_or("title must be a string")?.into();
}
} else {
visible = op == "show";
}
let panel: crate::ExtensionPanel =
serde_json::from_value(spec).map_err(|e| e.to_string())?;
panel.validate()?;
Some((panel, visible))
}
_ => return Err("unknown component operation".into()),
};
let changed = before != next;
if changed {
match next {
Some(v) => {
entries.insert(key, v);
}
None => {
entries.remove(&key);
}
}
self.revision.fetch_add(1, Ordering::SeqCst);
}
Ok(serde_json::json!({"ok":true,"changed":changed}))
}
pub fn remove_owner(&self, owner: u64) {
if let Ok(mut entries) = self.components.lock() {
let size = entries.len();
entries.retain(|(o, _), _| *o != owner);
if size != entries.len() {
self.revision.fetch_add(1, Ordering::SeqCst);
}
}
}
pub fn revision(&self) -> u64 {
self.revision.load(Ordering::SeqCst)
}
pub fn get(&self, key: &str) -> Option<String> {
self.entries.lock().ok()?.get(key).cloned()
}
pub fn panels(&self) -> BTreeMap<String, crate::ExtensionPanel> {
let mut panels = BTreeMap::new();
if let Ok(entries) = self.components.lock() {
for ((owner, id), (spec, visible)) in entries.iter() {
if *visible {
panels.insert(
format!(
"component:{:010}:{owner}:{id}",
(spec.order as i64) - (i32::MIN as i64)
),
spec.clone(),
);
}
}
}
panels
}
pub fn text_except(&self, keys: &[&str]) -> String {
self.entries
.lock()
.map(|entries| {
entries
.iter()
.filter(|(key, value)| {
!keys.contains(&key.as_str()) && !value.trim().is_empty()
})
.map(|(_, value)| value.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
pub fn text(&self) -> String {
self.entries
.lock()
.map(|entries| {
entries
.values()
.filter(|value| !value.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn components_keep_layout_content_visibility_and_owners_independent() {
let m = ExtensionStatusMailbox::new();
let register =
serde_json::json!({"op":"register","id":"stats","slot":"footer.after","visible":false});
m.component(1, register.clone()).unwrap();
m.component(2, register.clone()).unwrap();
assert!(m.panels().is_empty());
m.component(
1,
serde_json::json!({"op":"update","id":"stats","text":"TPS 252"}),
)
.unwrap();
m.component(1, serde_json::json!({"op":"show","id":"stats"}))
.unwrap();
let rev = m.revision();
m.component(1, register).unwrap();
assert_eq!(m.revision(), rev);
assert_eq!(
m.panels()["component:2147483648:1:stats"].layout,
crate::PanelLayout::Footer
);
assert!(m
.component(
1,
serde_json::json!({"op":"update","id":"stats","slot":"overlay"})
)
.is_err());
assert!(m
.component(
3,
serde_json::json!({"op":"update","id":"stats","text":"x"})
)
.is_err());
m.component(1, serde_json::json!({"op":"hide","id":"stats"}))
.unwrap();
m.component(
1,
serde_json::json!({"op":"configure","id":"stats","slot":"sidebar.left"}),
)
.unwrap();
m.component(1, serde_json::json!({"op":"show","id":"stats"}))
.unwrap();
assert_eq!(
m.panels()["component:2147483648:1:stats"].lines,
["TPS 252"]
);
m.remove_owner(1);
assert!(m.panels().is_empty());
m.component(2, serde_json::json!({"op":"show","id":"stats"}))
.unwrap();
assert_eq!(m.panels().len(), 1);
}
#[test]
fn set_and_clear_round_trip() {
let mailbox = ExtensionStatusMailbox::new();
assert_eq!(mailbox.text(), "");
assert!(mailbox.set("langfuse", "langfuse ✓"));
assert_eq!(mailbox.text(), "langfuse ✓");
assert!(!mailbox.set("langfuse", "langfuse ✓"));
assert!(mailbox.set("langfuse", "langfuse ✓ (trace sent)"));
assert_eq!(mailbox.text(), "langfuse ✓ (trace sent)");
let before = mailbox.revision();
assert!(mailbox.set("langfuse", ""));
assert_eq!(mailbox.text(), "");
assert!(mailbox.revision() > before);
assert!(!mailbox.set("langfuse", ""));
assert!(!mailbox.set(" ", "ignored"));
}
#[test]
fn multiple_extensions_render_in_key_order() {
let mailbox = ExtensionStatusMailbox::new();
mailbox.set("zeta", "z");
mailbox.set("alpha", "a");
assert_eq!(mailbox.text(), "a z");
}
#[test]
fn handle_parses_the_action_payload() {
let mailbox = ExtensionStatusMailbox::new();
let out = mailbox
.handle(serde_json::json!({"key": "langfuse", "value": "✓"}))
.expect("ok");
assert_eq!(out["ok"], serde_json::json!(true));
assert_eq!(mailbox.text(), "✓");
assert!(mailbox.handle(serde_json::json!({"value": "x"})).is_err());
}
}