Skip to main content

agentmux_srv/backend/
user_widgets.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The user's own `widgets.json`, next to `settings.json` — Pane Tab contract
5//! v1, Phase 6 (docs/specs/SPEC_PANE_TAB_CONTRACT_V1_2026_09_24.md §4).
6//!
7//! The widget bar used to come only from the `widgets.json` embedded at build
8//! time, so there was nowhere to add a third-party (`ext:`) widget. This file's
9//! entries are merged over the built-ins: a new key adds a widget, a built-in
10//! key replaces that widget. Same load-then-watch shape as `settings.json`
11//! (`config_watcher_fs`) and `browser_start_page`, on the same fs_watch pool,
12//! riding the existing full-config broadcast.
13
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, OnceLock};
17use std::time::Duration;
18
19use tokio::sync::broadcast;
20
21use super::config_watcher_fs::{broadcast_full_config, resolve_settings_dir};
22use super::eventbus::EventBus;
23use super::fs_watch::{FsWatchEvent, FsWatchEventKind, FsWatchPool};
24use super::mps::Broker;
25use super::wconfig::{self, ConfigState, WidgetConfigType};
26
27pub const USER_WIDGETS_FILE: &str = "widgets.json";
28
29pub fn user_widgets_path() -> PathBuf {
30    resolve_settings_dir().join(USER_WIDGETS_FILE)
31}
32
33/// The user's widgets; an absent or empty file is none.
34pub fn read_user_widgets(path: &Path) -> Result<HashMap<String, WidgetConfigType>, String> {
35    if !path.exists() {
36        return Ok(HashMap::new());
37    }
38    let content = std::fs::read_to_string(path).map_err(|e| format!("read widgets.json: {e}"))?;
39    if content.trim().is_empty() {
40        return Ok(HashMap::new());
41    }
42    serde_json::from_str(&content).map_err(|e| format!("parse widgets.json: {e}"))
43}
44
45/// The built-in widgets, parsed once.
46fn builtin_widgets() -> &'static HashMap<String, WidgetConfigType> {
47    static BUILTIN: OnceLock<HashMap<String, WidgetConfigType>> = OnceLock::new();
48    BUILTIN.get_or_init(|| wconfig::build_default_config().widgets)
49}
50
51/// The built-ins with the user's entries merged over them.
52pub fn merge_widgets(
53    builtin: &HashMap<String, WidgetConfigType>,
54    user: HashMap<String, WidgetConfigType>,
55) -> HashMap<String, WidgetConfigType> {
56    let mut merged = builtin.clone();
57    merged.extend(user);
58    merged
59}
60
61/// The user's widgets.json as last read, for the widget packages scan (its v1
62/// `module` entries are implied packages) and for recomputing the merge when
63/// a package changes.
64static LAST_USER: std::sync::Mutex<Option<HashMap<String, WidgetConfigType>>> = std::sync::Mutex::new(None);
65
66/// The v1 entries (`module` set) of the user's widgets.json as last read.
67pub fn last_v1_entries() -> HashMap<String, WidgetConfigType> {
68    let guard = LAST_USER.lock().unwrap_or_else(|p| p.into_inner());
69    guard
70        .as_ref()
71        .map(|u| u.iter().filter(|(_, e)| !e.module.is_empty()).map(|(k, e)| (k.clone(), e.clone())).collect())
72        .unwrap_or_default()
73}
74
75fn apply(config_watcher: &ConfigState, user: HashMap<String, WidgetConfigType>) {
76    wconfig::validate_widget_configs(&user);
77    *LAST_USER.lock().unwrap_or_else(|p| p.into_inner()) = Some(user);
78    if let Some(svc) = super::widget_packages::service() {
79        svc.rescan(&last_v1_entries());
80    }
81    recompute(config_watcher);
82}
83
84/// Merge again: the built-ins, then each approved widget package's entries
85/// (SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §5.4), then the user's own
86/// widgets.json, which replaces a package's entry when it uses the same key.
87pub fn recompute(config_watcher: &ConfigState) {
88    let user = LAST_USER.lock().unwrap_or_else(|p| p.into_inner()).clone().unwrap_or_default();
89    let mut base = builtin_widgets().clone();
90    if let Some(svc) = super::widget_packages::service() {
91        base.extend(super::widget_packages::widget_entries(&svc.list()));
92    }
93    config_watcher.update_widgets(merge_widgets(&base, user));
94}
95
96pub fn load_user_widgets_from_disk(config_watcher: &ConfigState) {
97    let path = user_widgets_path();
98    match read_user_widgets(&path) {
99        Ok(user) => {
100            tracing::info!(path = %path.display(), count = user.len(), "user widgets.json loaded");
101            apply(config_watcher, user);
102        }
103        Err(e) => {
104            tracing::warn!(path = %path.display(), error = %e, "user widgets.json failed to load (built-ins only)");
105        }
106    }
107}
108
109fn is_user_widgets_event(event: &FsWatchEvent) -> bool {
110    let is_file = event.path.file_name().map(|n| n == USER_WIDGETS_FILE).unwrap_or(false);
111    // Removed is excluded for the same reason config_watcher_fs gives: an
112    // editor's unlink+recreate save would otherwise flash the built-ins only.
113    is_file && matches!(event.kind, FsWatchEventKind::Created | FsWatchEventKind::Modified)
114}
115
116/// widgets.json changed: merge again, and tell every UI both the new config
117/// and the package list, since its v1 `module` entries are packages too.
118fn on_changed(config_watcher: &Arc<ConfigState>, event_bus: &Arc<EventBus>, broker: &Broker, user: HashMap<String, WidgetConfigType>) {
119    apply(config_watcher, user);
120    broadcast_full_config(config_watcher, event_bus);
121    let packages = super::widget_packages::service().map(|s| s.list()).unwrap_or_default();
122    super::widget_packages::publish(broker, &packages);
123}
124
125pub fn spawn_user_widgets_watcher(
126    pool: Arc<FsWatchPool>,
127    config_watcher: Arc<ConfigState>,
128    event_bus: Arc<EventBus>,
129    broker: Arc<Broker>,
130) {
131    let path = user_widgets_path();
132    let Some(dir) = path.parent() else {
133        return;
134    };
135    if !dir.exists() {
136        tracing::warn!(dir = %dir.display(), "settings directory does not exist, widgets.json watcher not started");
137        return;
138    }
139
140    // events() before subscribe_file(): a broadcast receiver only sees what is
141    // sent after it subscribes (see spawn_settings_watcher).
142    let mut events = pool.events();
143    let _ = pool.subscribe_file(&path);
144    tracing::info!(path = %path.display(), "fs_watch subscription active for user widgets.json");
145
146    let watched_path = path.clone();
147    tokio::spawn(async move {
148        loop {
149            match events.recv().await {
150                Ok(ev) if is_user_widgets_event(&ev) => {}
151                Ok(_) => continue,
152                Err(broadcast::error::RecvError::Closed) => {
153                    tracing::info!("user widgets watcher event stream closed, stopping");
154                    break;
155                }
156                Err(broadcast::error::RecvError::Lagged(skipped)) => {
157                    tracing::warn!(skipped, "user widgets watcher lagged; reloading defensively");
158                }
159            }
160            tokio::time::sleep(Duration::from_millis(300)).await;
161            while events.try_recv().is_ok() {}
162
163            match read_user_widgets(&watched_path) {
164                Ok(user) => {
165                    tracing::info!(path = %watched_path.display(), "user widgets.json changed, reloading");
166                    // A rescan hashes every package's files: off the async workers.
167                    let (c, e, b) = (config_watcher.clone(), event_bus.clone(), broker.clone());
168                    let _ = tokio::task::spawn_blocking(move || on_changed(&c, &e, &b, user)).await;
169                }
170                Err(e) => {
171                    tracing::warn!(path = %watched_path.display(), error = %e, "user widgets.json reload parse error (keeping previous widgets)");
172                }
173            }
174        }
175    });
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn widget(label: &str) -> WidgetConfigType {
183        WidgetConfigType { label: label.to_string(), ..Default::default() }
184    }
185
186    #[test]
187    fn a_change_to_widgets_json_tells_the_ui_the_package_list_too() {
188        let seen = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
189        let broker = Broker::new();
190        let log = seen.clone();
191        broker.add_observer(Arc::new(move |ev| log.lock().unwrap().push(ev.event.clone())));
192        let config = Arc::new(ConfigState::new());
193        on_changed(&config, &Arc::new(EventBus::new()), &broker, HashMap::from([("mine".to_string(), widget("Mine"))]));
194        assert!(seen.lock().unwrap().iter().any(|e| e == super::super::mps::EVENT_WIDGET_PACKAGES));
195    }
196
197    #[test]
198    fn a_missing_or_empty_file_is_no_widgets() {
199        let dir = tempfile::tempdir().unwrap();
200        let path = dir.path().join(USER_WIDGETS_FILE);
201        assert!(read_user_widgets(&path).unwrap().is_empty());
202        std::fs::write(&path, "  \n").unwrap();
203        assert!(read_user_widgets(&path).unwrap().is_empty());
204    }
205
206    #[test]
207    fn reads_an_ext_widget_with_its_module() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join(USER_WIDGETS_FILE);
210        std::fs::write(
211            &path,
212            r#"{"ext@hello":{"label":"Hello","module":"hello/index.js","blockdef":{"meta":{"view":"ext:hello"}}}}"#,
213        )
214        .unwrap();
215        let user = read_user_widgets(&path).unwrap();
216        assert_eq!(user["ext@hello"].module, "hello/index.js");
217    }
218
219    #[test]
220    fn a_malformed_file_is_an_error_not_an_empty_set() {
221        let dir = tempfile::tempdir().unwrap();
222        let path = dir.path().join(USER_WIDGETS_FILE);
223        std::fs::write(&path, "{ not json").unwrap();
224        assert!(read_user_widgets(&path).is_err());
225    }
226
227    #[test]
228    fn user_entries_add_widgets_and_replace_builtins_by_key() {
229        let builtin = HashMap::from([("defwidget@a".to_string(), widget("A")), ("defwidget@b".to_string(), widget("B"))]);
230        let user = HashMap::from([("defwidget@b".to_string(), widget("B2")), ("ext@c".to_string(), widget("C"))]);
231        let merged = merge_widgets(&builtin, user);
232        assert_eq!(merged.len(), 3);
233        assert_eq!(merged["defwidget@a"].label, "A");
234        assert_eq!(merged["defwidget@b"].label, "B2");
235        assert_eq!(merged["ext@c"].label, "C");
236    }
237
238    #[test]
239    fn the_merge_always_starts_from_the_builtins() {
240        // A reload that drops a user widget must not keep it around.
241        let builtin = HashMap::from([("defwidget@a".to_string(), widget("A"))]);
242        let first = merge_widgets(&builtin, HashMap::from([("ext@c".to_string(), widget("C"))]));
243        assert!(first.contains_key("ext@c"));
244        let second = merge_widgets(&builtin, HashMap::new());
245        assert!(!second.contains_key("ext@c"));
246    }
247}