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
//! 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::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();
}
/// 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:
/// 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.
let tab_ids: Vec<TabId> = tabs.tabs().iter().map(|tab| tab.id).collect();
let mut desired: HashSet<(String, TabId)> = HashSet::new();
for plugin_id in self.plugins.subscription_forwarders().keys() {
for tab_id in &tab_ids {
desired.insert((plugin_id.clone(), *tab_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 Ok(terminal) = tab.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)> = 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. Only a live tab needs an explicit
// unregister.
if let Some(tab) = tabs.tabs().iter().find(|tab| tab.id == key.1)
&& let Ok(terminal) = tab.terminal.try_read()
{
terminal.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())
}
}