1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//! Per-frame plugin upkeep for the status bar.
//!
//! [`StatusBarUI::update_plugins`] lives here rather than in `mod.rs` to keep
//! that file near the 500-line warn line: discovery refresh, the enabled-set
//! reconcile, and process polling form one self-contained unit.
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use par_term_scripting::manifest::{SettingSchemaEntry, validate_settings};
use par_term_scripting::observer::ScriptEventForwarder;
use par_term_scripting::plugin_manager::{EnabledPlugin, PluginHost};
use crate::config::Config;
use crate::pane::PaneId;
use crate::tab::{TabId, TabManager};
use super::StatusBarUI;
/// How often the plugins root is re-scanned for new or changed manifests.
const PLUGIN_DISCOVERY_INTERVAL: Duration = Duration::from_secs(300);
impl StatusBarUI {
/// Per-frame plugin upkeep: periodic discovery refresh, enabled-set
/// reconcile, and process polling.
///
/// Called unconditionally from the render pipeline every frame — NOT from
/// [`StatusBarUI::render`], which only runs while the bar is visible. A
/// plugin's `SetWidget` output must keep flowing with the bar hidden.
pub(crate) fn update_plugins(&mut self, config: &Config) {
// Scan on the first call and every interval thereafter; a re-scan
// never stops or spawns anything by itself.
if self
.plugins_last_discovery
.is_none_or(|at| at.elapsed() >= PLUGIN_DISCOVERY_INTERVAL)
{
self.plugins
.refresh_discovery(&Config::config_dir().join("plugins"));
self.plugins_last_discovery = Some(Instant::now());
}
let enabled: Vec<EnabledPlugin> = config
.automation
.plugins
.iter()
.filter(|state| state.enabled)
.map(|state| EnabledPlugin {
id: state.id.clone(),
settings_json: self.validated_settings_json(state),
})
.collect();
// A plugin leaving the enabled set ends its invalid-settings episode,
// so re-enabling it warns again instead of staying silent forever.
let enabled_ids: HashSet<String> = enabled.iter().map(|e| e.id.clone()).collect();
self.plugin_settings_warned.clear_except(&enabled_ids);
self.plugins.apply_enabled(&enabled);
self.plugins.poll();
// The theme seam: one call per frame with the config-resolved theme
// catches every application site (system dark/light switch, settings
// apply, config reload) — plugins subscribed to theme_changed are
// greeted on spawn and re-notified on change, deduped by name.
self.plugins.sync_theme(&config.load_theme());
}
/// Reconcile plugin-event observer registrations against the window's
/// tabs and pump drained events into the running plugins.
///
/// Called once per event-loop wake beside the tab-script sweep — NOT
/// from [`StatusBarUI::update_plugins`], which runs on the render path
/// and never sees the tab list. Each subscribed plugin's forwarder is
/// registered as an observer on every tab terminal of this window, and
/// on every pane terminal of a mux tab (whose panes, not `terminal`,
/// carry the daemon-fed output the user sees): delivery is
/// window-wide (each event originates at exactly one terminal, so
/// nothing is duplicated; events carry no tab attribution in v1). A
/// registration that misses on a contended terminal lock retries on
/// the next wake.
pub(crate) fn pump_plugin_events(&mut self, tabs: &TabManager) {
// The common case — no subscribed plugins, nothing registered —
// exits before touching the tab list.
if self.plugins.subscription_forwarders().is_empty() && self.plugin_observer_ids.is_empty()
{
return;
}
// Pass A — the desired registration set: every live forwarder on
// every live tab's terminal and, for a mux tab, on every mirror
// pane terminal. `None` names the tab terminal; `Some(id)` a pane.
let mut desired: HashSet<(String, TabId, Option<PaneId>)> = HashSet::new();
for plugin_id in self.plugins.subscription_forwarders().keys() {
for tab in tabs.tabs() {
desired.insert((plugin_id.clone(), tab.id, None));
if tab.is_mux_tab()
&& let Some(pm) = tab.pane_manager()
{
for pane in pm.all_panes() {
desired.insert((plugin_id.clone(), tab.id, Some(pane.id)));
}
}
}
}
// Register anything desired but missing.
for key in &desired {
if self.plugin_observer_ids.contains_key(key) {
continue;
}
let Some(tab) = tabs.tabs().iter().find(|tab| tab.id == key.1) else {
continue;
};
let Some(forwarder) = self.plugins.subscription_forwarders().get(&key.0) else {
continue;
};
let terminal = match key.2 {
// A pane registration targets that pane's terminal — the
// pane may be gone already (the reconcile is per-frame);
// a missing pane simply retries or is dropped as stale.
None => &tab.terminal,
Some(pane_id) => match tab.pane_manager().and_then(|pm| pm.get_pane(pane_id)) {
Some(pane) => &pane.terminal,
None => continue,
},
};
let Ok(terminal) = terminal.try_read() else {
continue;
};
let observer_id = terminal.add_observer(forwarder.clone());
self.plugin_observer_ids.insert(key.clone(), observer_id);
}
// Unregister anything registered but no longer desired.
let stale: Vec<(String, TabId, Option<PaneId>)> = self
.plugin_observer_ids
.keys()
.filter(|key| !desired.contains(*key))
.cloned()
.collect();
for key in stale {
if let Some(observer_id) = self.plugin_observer_ids.remove(&key) {
// A closed tab's terminal (and the observer registry inside
// it) is gone with the tab — dropping the map entry is the
// whole cleanup there, and a closed pane is gone with its
// terminal the same way. Only a live terminal needs an
// explicit unregister.
if let Some(tab) = tabs.tabs().iter().find(|tab| tab.id == key.1) {
let terminal = match key.2 {
None => Some(tab.terminal.clone()),
Some(pane_id) => tab
.pane_manager()
.and_then(|pm| pm.get_pane(pane_id))
.map(|pane| pane.terminal.clone()),
};
if let Some(terminal) = terminal
&& let Ok(term) = terminal.try_read()
{
term.remove_observer(observer_id);
}
}
}
}
// Pass B — drain each forwarder and deliver to its plugin. The Arc
// clones release the host borrow before delivery mutates it.
let to_drain: Vec<(String, std::sync::Arc<ScriptEventForwarder>)> = self
.plugins
.subscription_forwarders()
.iter()
.map(|(id, forwarder)| (id.clone(), std::sync::Arc::clone(forwarder)))
.collect();
for (plugin_id, forwarder) in &to_drain {
let events = forwarder.drain_events();
if !events.is_empty() {
self.plugins.deliver_events(plugin_id, &events);
}
}
}
/// Discovery-cache size for the `plugins_loaded` ui-test operand.
pub(crate) fn plugins_discovered_count(&self) -> usize {
self.plugins.discovered_count()
}
/// Whether any plugin has published a non-empty widget text, for the
/// `plugin_widget_set` ui-test operand.
pub(crate) fn any_plugin_widget_text(&self) -> bool {
self.plugins.widget_texts().values().any(|t| !t.is_empty())
}
/// Whether any panel plugin has pushed a panel, for the
/// `plugin_panel_set` ui-test operand.
pub(crate) fn any_plugin_panel_pushed(&self) -> bool {
!self.plugins.panel_contents().is_empty()
}
/// Whether any plugin action invocation was successfully delivered
/// this session, for the `plugin_action_dispatched` ui-test operand.
pub(crate) fn any_plugin_action_dispatched(&self) -> bool {
self.plugins.actions_dispatched_count() >= 1
}
/// Mutable access to the plugin host, for keybinding dispatch of
/// plugin-contributed palette actions
/// (`plugin-action:<plugin_id>:<action_id>`).
pub(crate) fn plugin_host_mut(&mut self) -> &mut PluginHost {
&mut self.plugins
}
/// Read-only access to the plugin host, for enumerating plugin-contributed
/// palette actions when the command palette opens.
pub(crate) fn plugin_host(&self) -> &PluginHost {
&self.plugins
}
/// Persisted settings serialized as the one argv JSON string, validated
/// against the discovered manifest's schema. Invalid persisted values
/// warn (once per episode — this runs every frame) and fall back to the
/// schema defaults rather than blocking the plugin; an undiscovered
/// plugin's settings pass through untouched (the host warns on apply).
fn validated_settings_json(&mut self, state: &crate::config::PluginStateConfig) -> String {
let schema: &[SettingSchemaEntry] = self
.plugins
.discovered(&state.id)
.and_then(|found| found.manifest.status_bar_widget.as_ref())
.map(|widget| widget.schema.as_slice())
.unwrap_or(&[]);
let settings = match validate_settings(schema, &state.settings) {
Ok(valid) => {
self.plugin_settings_warned.clear(&state.id);
valid
}
Err(reason) => {
if self.plugin_settings_warned.should_warn(&state.id) {
log::warn!(
"plugin '{}' has invalid persisted settings ({}); using schema defaults",
state.id,
reason
);
}
validate_settings(schema, &HashMap::new()).unwrap_or_default()
}
};
serde_json::to_string(&settings).unwrap_or_else(|_| "{}".to_string())
}
}
impl StatusBarUI {
/// Drain plugin crash-cap events (a supervisor gave up on a crash
/// looping entry) for the window layer to surface as crash-triage
/// offers — same consent surface as a crashed pane.
pub(crate) fn drain_plugin_crash_caps(
&mut self,
) -> Vec<par_term_scripting::plugin_manager::PluginCrashCap> {
self.plugins.drain_crash_caps()
}
}