use crate::SettingsUI;
use crate::section::{collapsing_section, section_matches};
use par_term_config::PluginStateConfig;
use par_term_config::status_bar::{StatusBarSection, StatusBarWidgetConfig, WidgetId};
use par_term_scripting::manifest::{
ActionContribution, DiscoveredPlugin, ENTRY_POINT_ACTION_CONTRIBUTOR, ENTRY_POINT_PANEL,
ENTRY_POINT_STATUS_BAR_WIDGET, KIND_ACTION_CONTRIBUTOR, KIND_PANEL, KIND_STATUS_BAR_WIDGET,
PluginEntryPoint, PluginManifest, SettingSchemaEntry, SettingType, discover_plugins,
};
use par_term_scripting::plugin_git;
use std::collections::HashSet;
use super::plugin_git_ui::{show_plugin_git_actions, show_plugin_git_bar};
pub(super) fn show_plugins_section(
ui: &mut egui::Ui,
settings: &mut SettingsUI,
changes_this_frame: &mut bool,
collapsed: &mut HashSet<String>,
) {
if section_matches(
&settings.search_query.trim().to_lowercase(),
"Plugins",
&[
"plugin",
"plugins",
"status bar widget",
"manifest",
"widget",
"extensions",
"plugin action",
"palette actions",
"git",
"add plugin",
"remove plugin",
"forget plugin",
"update plugin",
"install plugin",
],
) {
show_plugins_collapsing(ui, settings, changes_this_frame, collapsed);
}
}
fn show_plugins_collapsing(
ui: &mut egui::Ui,
settings: &mut SettingsUI,
changes_this_frame: &mut bool,
collapsed: &mut HashSet<String>,
) {
collapsing_section(ui, "Plugins", "automation_plugins", true, collapsed, |ui| {
ui.label(
"Local plugins run as subprocesses and publish status-bar widgets, contribute command-palette actions, or push panel content.",
);
ui.label("Plugins are disabled until enabled here. Install from a git URL below, or copy a plugin directory into the plugins folder by hand.");
ui.add_space(4.0);
if settings.poll_plugin_git_op() {
settings.automation_tab.plugin_scan = None;
}
show_plugin_git_bar(ui, settings);
if ui.small_button("Rescan").clicked() {
settings.automation_tab.plugin_scan = None;
}
if settings.automation_tab.plugin_scan.is_none() {
let root = par_term_config::Config::config_dir().join("plugins");
let scan = discover_plugins(&root);
let git_ids: HashSet<String> = scan
.0
.iter()
.map(|plugin| plugin.manifest.id.clone())
.filter(|id| plugin_git::is_git_installed(&root.join(id)))
.collect();
if let Some(pending) = settings.automation_tab.plugin_remove_pending.clone()
&& !git_ids.contains(&pending)
{
settings.automation_tab.plugin_remove_pending = None;
}
settings.automation_tab.plugin_git_ids = Some(git_ids);
settings.automation_tab.plugin_scan = Some(scan);
}
let (discovered, warnings) = settings
.automation_tab
.plugin_scan
.clone()
.unwrap_or_default();
let mut discovered = discovered;
discovered.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
for warning in &warnings {
ui.label(
egui::RichText::new(format!("skipped '{}': {}", warning.dir, warning.reason))
.small()
.color(egui::Color32::GRAY),
);
}
let mut first = true;
for plugin in &discovered {
if !first {
ui.add_space(6.0);
}
first = false;
show_plugin_row(ui, settings, changes_this_frame, plugin);
}
if discovered.is_empty() && warnings.is_empty() {
ui.label(egui::RichText::new("No plugins found.").color(egui::Color32::GRAY));
}
let missing = missing_state_ids(&settings.config, &discovered);
if !missing.is_empty() {
ui.add_space(6.0);
ui.label(egui::RichText::new("Missing plugins").strong());
for id in missing {
let enabled = settings
.config
.automation
.plugins
.iter()
.any(|state| state.id == id && state.enabled);
let suffix = if enabled { " (enabled)" } else { "" };
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("{id} — not found — state kept{suffix}"))
.small()
.color(egui::Color32::GRAY),
);
let armed =
settings.automation_tab.plugin_forget_pending.as_deref() == Some(&id);
let label = if armed { "Really forget?" } else { "Forget…" };
if ui
.small_button(label)
.on_hover_text(
"Clear this plugin's saved settings and status-bar placement",
)
.clicked()
{
if armed {
settings.automation_tab.plugin_forget_pending = None;
forget_plugin_state(&mut settings.config, &id);
settings.has_changes = true;
*changes_this_frame = true;
} else {
settings.automation_tab.plugin_forget_pending = Some(id.clone());
}
}
if armed && ui.small_button("Keep").clicked() {
settings.automation_tab.plugin_forget_pending = None;
}
});
}
}
});
}
fn show_plugin_row(
ui: &mut egui::Ui,
settings: &mut SettingsUI,
changes_this_frame: &mut bool,
plugin: &DiscoveredPlugin,
) {
let manifest = &plugin.manifest;
let state = settings
.config
.automation
.plugins
.iter()
.find(|state| state.id == manifest.id);
let is_enabled = state.is_some_and(|state| state.enabled);
let has_widget_row = settings
.config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.id == WidgetId::Plugin(manifest.id.clone()));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&manifest.name).strong());
ui.label(egui::RichText::new(format!("v{}", manifest.version)).small());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut enabled = is_enabled;
if ui.checkbox(&mut enabled, "Enabled").changed() {
set_plugin_enabled(&mut settings.config, plugin, enabled);
settings.has_changes = true;
*changes_this_frame = true;
}
});
});
let mut meta = vec![manifest.id.clone()];
if let Some(author) = &manifest.author {
meta.push(author.clone());
}
if let Some(license) = &manifest.license {
meta.push(license.clone());
}
ui.label(
egui::RichText::new(meta.join(" · "))
.small()
.color(egui::Color32::GRAY),
);
if let Some(description) = &manifest.description {
ui.label(egui::RichText::new(description).small());
}
for line in runs_lines(manifest) {
ui.label(egui::RichText::new(line).small());
}
if let Some(line) = subscription_line(&manifest.subscriptions) {
ui.label(
egui::RichText::new(line)
.small()
.color(egui::Color32::from_rgb(140, 180, 220)),
);
}
if manifest.kinds.iter().any(|k| k == KIND_ACTION_CONTRIBUTOR) {
let summary = action_summary(&manifest.actions);
if !summary.is_empty() {
ui.label(egui::RichText::new(summary).small());
}
}
if settings
.automation_tab
.plugin_git_ids
.as_ref()
.is_some_and(|ids| ids.contains(&manifest.id))
{
ui.push_id(format!("plugin_git_{}", manifest.id), |ui| {
show_plugin_git_actions(ui, settings, &manifest.id);
});
}
if manifest.kinds.iter().any(|k| k == KIND_PANEL) {
if let Some((title, content)) = settings.plugin_panels.get(&manifest.id) {
let panel_title = format!("Panel: {title}");
let panel_id = format!("plugin_panel_{}", manifest.id);
let panel_scroll_id = format!("plugin_panel_scroll_{}", manifest.id);
egui::CollapsingHeader::new(&panel_title)
.id_salt(&panel_id)
.default_open(true)
.show(ui, |ui| {
egui::ScrollArea::vertical()
.id_salt(&panel_scroll_id)
.max_height(200.0)
.show(ui, |ui| {
ui.label(
egui::RichText::new(content)
.monospace()
.small()
.color(egui::Color32::from_rgb(200, 200, 200)),
);
});
});
} else if is_enabled {
ui.label(
egui::RichText::new("Panel: no content pushed yet")
.small()
.color(egui::Color32::GRAY),
);
}
}
if let Some(widget) = manifest.status_bar_widget.as_ref()
&& !widget.schema.is_empty()
{
ui.add_space(2.0);
for entry in &widget.schema {
let current = settings
.config
.automation
.plugins
.iter()
.find(|state| state.id == manifest.id)
.and_then(|state| state.settings.get(&entry.key))
.cloned()
.unwrap_or_else(|| entry.default_value.clone());
show_setting_editor(
ui,
settings,
changes_this_frame,
&manifest.id,
current,
entry,
);
}
}
if has_widget_row {
let current = settings
.config
.status_bar
.status_bar_widgets
.iter()
.find(|w| w.id == WidgetId::Plugin(manifest.id.clone()))
.map(|w| w.section)
.unwrap_or_default();
let mut selected = current;
ui.horizontal(|ui| {
ui.label("Section:");
egui::ComboBox::from_id_salt(format!("plugin_section_{}", manifest.id))
.selected_text(section_label(selected))
.show_ui(ui, |ui| {
ui.selectable_value(&mut selected, StatusBarSection::Left, "Left");
ui.selectable_value(&mut selected, StatusBarSection::Center, "Center");
ui.selectable_value(&mut selected, StatusBarSection::Right, "Right");
});
});
if selected != current {
set_plugin_placement(&mut settings.config, &manifest.id, selected);
settings.has_changes = true;
*changes_this_frame = true;
}
}
}
fn show_setting_editor(
ui: &mut egui::Ui,
settings: &mut SettingsUI,
changes_this_frame: &mut bool,
plugin_id: &str,
current: serde_json::Value,
entry: &SettingSchemaEntry,
) {
let value = current;
ui.horizontal(|ui| {
ui.label(&entry.label);
match entry.setting_type {
SettingType::Boolean => {
let mut checked = value.as_bool().unwrap_or(false);
if ui.checkbox(&mut checked, "").changed() {
write_plugin_setting(
&mut settings.config,
plugin_id,
&entry.key,
checked.into(),
);
dirty(settings, changes_this_frame);
}
}
SettingType::Integer => {
let mut number = value
.as_i64()
.or_else(|| entry.default_value.as_i64())
.unwrap_or(0);
let response =
ui.add(egui::DragValue::new(&mut number).speed(entry.step.unwrap_or(1.0)));
let clamped = clamp_i64(number, entry.min, entry.max);
if response.changed() || clamped != number {
write_plugin_setting(
&mut settings.config,
plugin_id,
&entry.key,
clamped.into(),
);
dirty(settings, changes_this_frame);
}
}
SettingType::Number => {
let mut number = value
.as_f64()
.or_else(|| entry.default_value.as_f64())
.unwrap_or(0.0);
let response = ui.add(
egui::DragValue::new(&mut number)
.speed(entry.step.unwrap_or(0.1))
.fixed_decimals(2),
);
let clamped = clamp_f64(number, entry.min, entry.max);
if response.changed() || clamped != number {
write_plugin_setting(
&mut settings.config,
plugin_id,
&entry.key,
serde_json::json!(clamped),
);
dirty(settings, changes_this_frame);
}
}
SettingType::String => {
let mut text = value
.as_str()
.or_else(|| entry.default_value.as_str())
.unwrap_or("")
.to_string();
if ui.add(egui::TextEdit::singleline(&mut text)).changed() {
write_plugin_setting(&mut settings.config, plugin_id, &entry.key, text.into());
dirty(settings, changes_this_frame);
}
}
}
});
}
fn dirty(settings: &mut SettingsUI, changes_this_frame: &mut bool) {
settings.has_changes = true;
*changes_this_frame = true;
}
fn section_label(section: StatusBarSection) -> &'static str {
match section {
StatusBarSection::Left => "Left",
StatusBarSection::Center => "Center",
StatusBarSection::Right => "Right",
}
}
fn clamp_i64(value: i64, min: Option<f64>, max: Option<f64>) -> i64 {
let mut value = value;
if let Some(min) = min {
value = value.max(min as i64);
}
if let Some(max) = max {
value = value.min(max as i64);
}
value
}
fn clamp_f64(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
let mut value = value;
if let Some(min) = min {
value = value.max(min);
}
if let Some(max) = max {
value = value.min(max);
}
value
}
fn subscription_line(subscriptions: &[String]) -> Option<String> {
if subscriptions.is_empty() {
None
} else {
Some(format!("[{}]", subscriptions.join(", ")))
}
}
fn action_summary(actions: &[ActionContribution]) -> String {
match actions.len() {
0 => String::new(),
1 => format!("contributes 1 action: {}", actions[0].label),
n => {
let labels: Vec<&str> = actions.iter().map(|a| a.label.as_str()).collect();
format!("contributes {n} actions: {}", labels.join(", "))
}
}
}
fn entry_command(entry: &PluginEntryPoint) -> String {
let mut command = format!("./{}", entry.command);
for arg in &entry.args {
command.push(' ');
command.push_str(arg);
}
command
}
fn runs_lines(manifest: &PluginManifest) -> Vec<String> {
let has_widget = manifest.kinds.iter().any(|k| k == KIND_STATUS_BAR_WIDGET);
let has_action = manifest.kinds.iter().any(|k| k == KIND_ACTION_CONTRIBUTOR);
let has_panel = manifest.kinds.iter().any(|k| k == KIND_PANEL);
let mut lines = Vec::new();
if has_widget && let Some(entry) = manifest.entry_points.get(ENTRY_POINT_STATUS_BAR_WIDGET) {
let label = if has_action || has_panel {
"Runs widget:"
} else {
"Runs:"
};
lines.push(format!("{label} {}", entry_command(entry)));
}
if has_action && let Some(entry) = manifest.entry_points.get(ENTRY_POINT_ACTION_CONTRIBUTOR) {
let label = if has_widget || has_panel {
"Runs actions:"
} else {
"Runs:"
};
lines.push(format!("{label} {}", entry_command(entry)));
}
if has_panel && let Some(entry) = manifest.entry_points.get(ENTRY_POINT_PANEL) {
let label = if has_widget || has_action {
"Runs panel:"
} else {
"Runs:"
};
lines.push(format!("{label} {}", entry_command(entry)));
}
lines
}
fn missing_state_ids(
config: &par_term_config::Config,
discovered: &[DiscoveredPlugin],
) -> Vec<String> {
config
.automation
.plugins
.iter()
.filter(|state| !discovered.iter().any(|p| p.manifest.id == state.id))
.map(|state| state.id.clone())
.collect()
}
fn forget_plugin_state(config: &mut par_term_config::Config, plugin_id: &str) {
config
.automation
.plugins
.retain(|state| state.id != plugin_id);
let widget = WidgetId::Plugin(plugin_id.to_string());
config
.status_bar
.status_bar_widgets
.retain(|w| w.id != widget);
}
fn set_plugin_enabled(
config: &mut par_term_config::Config,
plugin: &DiscoveredPlugin,
enabled: bool,
) {
let id = &plugin.manifest.id;
match config
.automation
.plugins
.iter_mut()
.find(|state| &state.id == id)
{
Some(state) => state.enabled = enabled,
None => {
if enabled {
let defaults = plugin
.manifest
.status_bar_widget
.as_ref()
.map(|widget| widget.defaults.clone())
.unwrap_or_default();
config.automation.plugins.push(PluginStateConfig {
id: id.clone(),
enabled: true,
settings: defaults,
section: None,
});
}
}
}
if enabled {
ensure_widget_row(config, plugin);
}
}
fn ensure_widget_row(config: &mut par_term_config::Config, plugin: &DiscoveredPlugin) {
if !plugin
.manifest
.kinds
.iter()
.any(|k| k == KIND_STATUS_BAR_WIDGET)
{
return;
}
let id = WidgetId::Plugin(plugin.manifest.id.clone());
if config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.id == id)
{
return;
}
let section = config
.automation
.plugins
.iter()
.find(|state| state.id == plugin.manifest.id)
.and_then(|state| state.section)
.or_else(|| {
plugin
.manifest
.status_bar_widget
.as_ref()
.map(|widget| widget.section)
})
.unwrap_or(StatusBarSection::Right);
let order = config
.status_bar
.status_bar_widgets
.iter()
.map(|w| w.order)
.max()
.unwrap_or(0)
+ 1;
config
.status_bar
.status_bar_widgets
.push(StatusBarWidgetConfig {
id,
enabled: true,
section,
order,
format: None,
});
}
fn write_plugin_setting(
config: &mut par_term_config::Config,
plugin_id: &str,
key: &str,
value: serde_json::Value,
) {
match config
.automation
.plugins
.iter_mut()
.find(|state| state.id == plugin_id)
{
Some(state) => {
state.settings.insert(key.to_string(), value);
}
None => {
config.automation.plugins.push(PluginStateConfig {
id: plugin_id.to_string(),
enabled: false,
settings: [(key.to_string(), value)].into_iter().collect(),
section: None,
});
}
}
}
fn set_plugin_placement(
config: &mut par_term_config::Config,
plugin_id: &str,
section: StatusBarSection,
) {
if let Some(widget) = config
.status_bar
.status_bar_widgets
.iter_mut()
.find(|w| w.id == WidgetId::Plugin(plugin_id.to_string()))
{
widget.section = section;
}
if let Some(state) = config
.automation
.plugins
.iter_mut()
.find(|state| state.id == plugin_id)
{
state.section = Some(section);
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE_MANIFEST: &str = r#"{
"schemaVersion": 1,
"id": "com.example.fixture",
"name": "Fixture",
"version": "0.1.0",
"author": "test",
"license": "MIT",
"kinds": ["status-bar-widget"],
"activation": "manual",
"entryPoints": { "statusBarWidget": { "command": "fixture.sh", "args": [] } },
"statusBarWidget": {
"displayName": "Fixture",
"section": "right",
"defaults": { "format24h": true },
"schema": [
{ "key": "format24h", "type": "boolean", "label": "24-hour",
"defaultValue": true }
]
}
}"#;
fn fixture_plugin() -> DiscoveredPlugin {
let manifest: par_term_scripting::manifest::PluginManifest =
serde_json::from_str(FIXTURE_MANIFEST).expect("fixture manifest parses");
DiscoveredPlugin {
manifest,
dir: "/plugins/com.example.fixture".into(),
entry_path: "/plugins/com.example.fixture/fixture.sh".into(),
action_entry_path: None,
panel_entry_path: None,
overlay_entry_path: None,
}
}
const ACTION_ONLY_MANIFEST: &str = r#"{
"schemaVersion": 1,
"id": "com.example.actions",
"name": "Actions Fixture",
"version": "0.1.0",
"author": "test",
"license": "MIT",
"kinds": ["action-contributor"],
"activation": "manual",
"entryPoints": { "actionContributor": { "command": "actions.sh", "args": [] } },
"actions": [
{ "id": "greet", "label": "Greet" },
{ "id": "stamp", "label": "Stamp" }
]
}"#;
fn action_only_plugin() -> DiscoveredPlugin {
let manifest: par_term_scripting::manifest::PluginManifest =
serde_json::from_str(ACTION_ONLY_MANIFEST).expect("fixture manifest parses");
DiscoveredPlugin {
manifest,
dir: "/plugins/com.example.actions".into(),
entry_path: "/plugins/com.example.actions/actions.sh".into(),
action_entry_path: Some("/plugins/com.example.actions/actions.sh".into()),
panel_entry_path: None,
overlay_entry_path: None,
}
}
const BOTH_KINDS_MANIFEST: &str = r#"{
"schemaVersion": 1,
"id": "com.example.both",
"name": "Both Fixture",
"version": "0.1.0",
"author": "test",
"license": "MIT",
"kinds": ["status-bar-widget", "action-contributor"],
"activation": "manual",
"entryPoints": {
"statusBarWidget": { "command": "widget.sh", "args": [] },
"actionContributor": { "command": "actions.sh", "args": ["--demo"] }
},
"statusBarWidget": {
"displayName": "Both",
"section": "right",
"defaults": { "format24h": true },
"schema": [
{ "key": "format24h", "type": "boolean", "label": "24-hour",
"defaultValue": true }
]
},
"actions": [ { "id": "greet", "label": "Greet" } ]
}"#;
fn both_kinds_plugin() -> DiscoveredPlugin {
let manifest: par_term_scripting::manifest::PluginManifest =
serde_json::from_str(BOTH_KINDS_MANIFEST).expect("fixture manifest parses");
DiscoveredPlugin {
manifest,
dir: "/plugins/com.example.both".into(),
entry_path: "/plugins/com.example.both/widget.sh".into(),
action_entry_path: Some("/plugins/com.example.both/actions.sh".into()),
panel_entry_path: None,
overlay_entry_path: None,
}
}
const STRAY_WIDGET_ENTRY_MANIFEST: &str = r#"{
"schemaVersion": 1,
"id": "com.example.stray",
"name": "Stray Widget Entry Fixture",
"version": "0.1.0",
"author": "test",
"license": "MIT",
"kinds": ["action-contributor"],
"activation": "manual",
"entryPoints": {
"statusBarWidget": { "command": "stray.sh", "args": [] },
"actionContributor": { "command": "actions.sh", "args": [] }
},
"actions": [ { "id": "greet", "label": "Greet" } ]
}"#;
fn stray_widget_entry_plugin() -> DiscoveredPlugin {
let manifest: par_term_scripting::manifest::PluginManifest =
serde_json::from_str(STRAY_WIDGET_ENTRY_MANIFEST).expect("fixture manifest parses");
DiscoveredPlugin {
manifest,
dir: "/plugins/com.example.stray".into(),
entry_path: "/plugins/com.example.stray/actions.sh".into(),
action_entry_path: Some("/plugins/com.example.stray/actions.sh".into()),
panel_entry_path: None,
overlay_entry_path: None,
}
}
const PANEL_ONLY_MANIFEST: &str = r#"{
"schemaVersion": 1,
"id": "com.example.panel",
"name": "Panel Fixture",
"version": "0.1.0",
"kinds": ["panel"],
"entryPoints": { "panel": { "command": "notes.py", "args": [] } }
}"#;
fn panel_only_plugin() -> DiscoveredPlugin {
let manifest: par_term_scripting::manifest::PluginManifest =
serde_json::from_str(PANEL_ONLY_MANIFEST).expect("fixture manifest parses");
DiscoveredPlugin {
manifest,
dir: "/plugins/com.example.panel".into(),
entry_path: "/plugins/com.example.panel/notes.py".into(),
action_entry_path: None,
panel_entry_path: Some("/plugins/com.example.panel/notes.py".into()),
overlay_entry_path: None,
}
}
#[test]
fn runs_lines_panel_only_keeps_bare_runs_line() {
assert_eq!(
runs_lines(&panel_only_plugin().manifest),
["Runs: ./notes.py".to_string()]
);
}
#[test]
fn enable_seeds_settings_and_adds_widget_row() {
let mut config = par_term_config::Config::default();
set_plugin_enabled(&mut config, &fixture_plugin(), true);
let state = &config.automation.plugins[0];
assert_eq!(state.id, "com.example.fixture");
assert!(state.enabled);
assert_eq!(
state.settings.get("format24h"),
Some(&serde_json::json!(true)),
"first enable seeds settings from manifest defaults"
);
let row = config
.status_bar
.status_bar_widgets
.iter()
.find(|w| w.id == WidgetId::Plugin("com.example.fixture".into()))
.expect("enable adds a widget row");
assert_eq!(row.section, StatusBarSection::Right);
assert!(row.enabled);
}
#[test]
fn disable_flips_state_and_keeps_widget_row() {
let mut config = par_term_config::Config::default();
let plugin = fixture_plugin();
set_plugin_enabled(&mut config, &plugin, true);
set_plugin_enabled(&mut config, &plugin, false);
assert_eq!(config.automation.plugins.len(), 1, "state entry kept");
assert!(!config.automation.plugins[0].enabled);
assert!(
config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.id == WidgetId::Plugin("com.example.fixture".into())),
"widget row kept on disable (it self-hides)"
);
}
#[test]
fn enable_honors_state_section_over_manifest_default() {
let mut config = par_term_config::Config::default();
config.automation.plugins.push(PluginStateConfig {
id: "com.example.fixture".into(),
enabled: false,
settings: Default::default(),
section: Some(StatusBarSection::Left),
});
set_plugin_enabled(&mut config, &fixture_plugin(), true);
let row = config
.status_bar
.status_bar_widgets
.iter()
.find(|w| w.id == WidgetId::Plugin("com.example.fixture".into()))
.unwrap();
assert_eq!(row.section, StatusBarSection::Left);
}
#[test]
fn write_setting_upserts_entry_preserving_enabled() {
let mut config = par_term_config::Config::default();
write_plugin_setting(
&mut config,
"com.example.fixture",
"format24h",
false.into(),
);
let state = &config.automation.plugins[0];
assert!(!state.enabled, "configuring is not consent to run");
assert_eq!(
state.settings.get("format24h"),
Some(&serde_json::json!(false))
);
config.automation.plugins[0].enabled = true;
write_plugin_setting(&mut config, "com.example.fixture", "format24h", true.into());
let state = &config.automation.plugins[0];
assert!(state.enabled);
assert_eq!(
state.settings.get("format24h"),
Some(&serde_json::json!(true))
);
}
#[test]
fn placement_updates_row_and_state() {
let mut config = par_term_config::Config::default();
let plugin = fixture_plugin();
set_plugin_enabled(&mut config, &plugin, true);
set_plugin_placement(&mut config, "com.example.fixture", StatusBarSection::Center);
let row = config
.status_bar
.status_bar_widgets
.iter()
.find(|w| w.id == WidgetId::Plugin("com.example.fixture".into()))
.unwrap();
assert_eq!(row.section, StatusBarSection::Center);
assert_eq!(
config.automation.plugins[0].section,
Some(StatusBarSection::Center)
);
}
#[test]
fn missing_state_ids_lists_only_undiscovered() {
let mut config = par_term_config::Config::default();
config.automation.plugins.push(PluginStateConfig {
id: "com.example.fixture".into(),
enabled: false,
settings: Default::default(),
section: None,
});
config.automation.plugins.push(PluginStateConfig {
id: "com.example.gone".into(),
enabled: false,
settings: Default::default(),
section: None,
});
let discovered = vec![fixture_plugin()];
assert_eq!(
missing_state_ids(&config, &discovered),
vec!["com.example.gone".to_string()]
);
}
#[test]
fn forget_plugin_state_drops_state_and_widget_row_only_for_that_id() {
let mut config = par_term_config::Config::default();
for id in ["com.example.gone", "com.example.kept"] {
config.automation.plugins.push(PluginStateConfig {
id: id.into(),
enabled: true,
settings: Default::default(),
section: None,
});
config
.status_bar
.status_bar_widgets
.push(StatusBarWidgetConfig {
id: WidgetId::Plugin(id.into()),
enabled: true,
section: StatusBarSection::Right,
order: 99,
format: None,
});
}
let builtin_rows = config.status_bar.status_bar_widgets.len() - 2;
forget_plugin_state(&mut config, "com.example.gone");
let ids: Vec<&str> = config
.automation
.plugins
.iter()
.map(|s| s.id.as_str())
.collect();
assert_eq!(ids, vec!["com.example.kept"]);
let widgets = &config.status_bar.status_bar_widgets;
assert!(
!widgets
.iter()
.any(|w| w.id == WidgetId::Plugin("com.example.gone".into()))
);
assert!(
widgets
.iter()
.any(|w| w.id == WidgetId::Plugin("com.example.kept".into()))
);
assert_eq!(widgets.len(), builtin_rows + 1);
}
fn action(id: &str, label: &str) -> par_term_scripting::manifest::ActionContribution {
par_term_scripting::manifest::ActionContribution {
id: id.into(),
label: label.into(),
description: None,
}
}
#[test]
fn subscription_line_lists_declared_kinds() {
let subscriptions = ["bell_rang".to_string(), "command_complete".to_string()];
assert_eq!(
subscription_line(&subscriptions),
Some("[bell_rang, command_complete]".to_string())
);
}
#[test]
fn subscription_line_empty_renders_nothing() {
assert_eq!(subscription_line(&[]), None);
}
#[test]
fn action_summary_plural_lists_labels() {
let actions = [action("greet", "Greet"), action("stamp", "Stamp")];
assert_eq!(
action_summary(&actions),
"contributes 2 actions: Greet, Stamp"
);
}
#[test]
fn action_summary_singular_uses_singular_noun() {
let actions = [action("greet", "Greet")];
assert_eq!(action_summary(&actions), "contributes 1 action: Greet");
}
#[test]
fn action_summary_empty_renders_nothing() {
assert_eq!(action_summary(&[]), "");
}
#[test]
fn runs_lines_widget_only_keeps_bare_runs_line() {
assert_eq!(
runs_lines(&fixture_plugin().manifest),
["Runs: ./fixture.sh".to_string()]
);
}
#[test]
fn runs_lines_action_only_shows_action_entry_command() {
assert_eq!(
runs_lines(&action_only_plugin().manifest),
["Runs: ./actions.sh".to_string()]
);
}
#[test]
fn runs_lines_both_kinds_labels_each_entry() {
assert_eq!(
runs_lines(&both_kinds_plugin().manifest),
[
"Runs widget: ./widget.sh".to_string(),
"Runs actions: ./actions.sh --demo".to_string(),
]
);
}
#[test]
fn runs_lines_ignores_stray_widget_entry_of_action_plugin() {
assert_eq!(
runs_lines(&stray_widget_entry_plugin().manifest),
["Runs: ./actions.sh".to_string()]
);
}
#[test]
fn enable_action_only_plugin_adds_no_widget_row() {
let mut config = par_term_config::Config::default();
set_plugin_enabled(&mut config, &action_only_plugin(), true);
let state = &config.automation.plugins[0];
assert_eq!(state.id, "com.example.actions");
assert!(state.enabled, "state entry still created — actions run");
assert!(
!config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.id == WidgetId::Plugin("com.example.actions".into())),
"an action-only plugin must not gain a phantom widget row"
);
}
#[test]
fn enable_both_kinds_plugin_keeps_widget_row() {
let mut config = par_term_config::Config::default();
set_plugin_enabled(&mut config, &both_kinds_plugin(), true);
assert!(
config
.status_bar
.status_bar_widgets
.iter()
.any(|w| w.id == WidgetId::Plugin("com.example.both".into())),
"a both-kinds plugin keeps its widget row"
);
}
#[test]
fn automation_tab_matches_plugin_search() {
for query in [
"plugin",
"plugins",
"manifest",
"widget",
"extensions",
"plugin action",
"palette actions",
] {
assert!(
crate::sidebar::tab_matches_search(crate::sidebar::SettingsTab::Automation, query),
"search '{query}' should match the Automation tab"
);
}
}
}