use std::path::Path;
use super::*;
use crate::module::context::Target;
use crate::module::manifest::ModuleManifest;
use crate::module::runtime::{ModuleCommandLog, ModuleStatus};
use crate::module::{
context, paths, registry, runtime, settings, InstalledModule, ModulePaneRecord,
};
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ModuleMenuAction {
pub module_id: String,
pub action_id: String,
pub title: String,
}
impl App {
pub fn module_link_with(
&mut self,
path: &Path,
enabled: bool,
source: Option<String>,
) -> Result<String, String> {
let root = path
.canonicalize()
.map_err(|e| format!("cannot resolve {}: {e}", path.display()))?;
let manifest = ModuleManifest::load(&root)?;
let id = manifest.id.clone();
if self.modules.find(&id).is_some() {
return Err(format!("module {id} is already registered"));
}
self.modules.modules.push(InstalledModule {
id: id.clone(),
root,
enabled,
source,
manifest,
warning: None,
});
registry::save(&self.modules);
self.run_module_startup_hooks();
Ok(id)
}
pub fn module_uninstall(&mut self, id: &str) -> Result<(), String> {
let root = self
.modules
.find(id)
.map(|m| m.root.clone())
.ok_or_else(|| format!("no module {id}"))?;
if !crate::module::install::is_removable(&root) {
return Err(format!(
"{id} is a linked module (its files aren't managed by bohay) — use `module unlink`"
));
}
let dock_ids = self.module_dock_ids(id);
self.modules.modules.retain(|m| m.id != id);
registry::save(&self.modules);
let _ = std::fs::remove_dir_all(&root);
self.remove_module_docks(&dock_ids);
Ok(())
}
pub fn module_unlink(&mut self, id: &str) -> Result<(), String> {
let dock_ids = self.module_dock_ids(id);
let before = self.modules.modules.len();
self.modules.modules.retain(|m| m.id != id);
if self.modules.modules.len() == before {
return Err(format!("no module {id}"));
}
registry::save(&self.modules);
self.remove_module_docks(&dock_ids);
Ok(())
}
pub fn module_set_enabled(&mut self, id: &str, on: bool) -> Result<(), String> {
let m = self
.modules
.find_mut(id)
.ok_or_else(|| format!("no module {id}"))?;
m.enabled = on;
registry::save(&self.modules);
if !on {
let dock_ids = self.module_dock_ids(id);
self.remove_module_docks(&dock_ids);
self.module_startup_done.remove(id);
} else {
self.run_module_startup_hooks();
}
Ok(())
}
fn module_dock_ids(&self, id: &str) -> Vec<String> {
self.modules
.find(id)
.map(|m| m.manifest.docks.iter().map(|d| d.id.clone()).collect())
.unwrap_or_default()
}
pub fn module_owning_dock(&self, dock_id: &str) -> Option<String> {
self.modules
.modules
.iter()
.find(|m| m.manifest.docks.iter().any(|d| d.id == dock_id))
.map(|m| m.id.clone())
}
pub fn module_config_dir(&self, id: &str) -> Result<std::path::PathBuf, String> {
if self.modules.find(id).is_none() {
return Err(format!("no module {id}"));
}
let dir = paths::config_dir(id);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
Ok(dir)
}
pub fn module_menu_actions(&self, context: &str) -> Vec<ModuleMenuAction> {
self.modules
.modules
.iter()
.filter(|m| m.is_runnable())
.flat_map(|m| {
m.manifest
.actions_for_context(context)
.into_iter()
.map(move |a| ModuleMenuAction {
module_id: m.id.clone(),
action_id: a.id.clone(),
title: a.title.clone(),
})
})
.collect()
}
pub fn run_module_menu_action(&mut self, context: &str, a: ModuleMenuAction, target: Target) {
let argv = self
.modules
.find(&a.module_id)
.filter(|m| m.is_runnable())
.and_then(|m| m.manifest.action(&a.action_id).map(|x| x.command.clone()));
let Some(argv) = argv else {
self.show_toast(format!("{} is unavailable", a.module_id));
return;
};
let extra = vec![("BOHAY_MODULE_ACTION_ID".to_string(), a.action_id.clone())];
let label = format!("action:{}", a.action_id);
let source = format!("menu:{context}");
if let Err(e) =
self.run_module_command_for(&a.module_id, argv, label, extra, &source, target)
{
self.show_toast(e);
} else {
self.show_toast(a.title);
}
}
pub fn module_settings(&self, id: &str) -> Result<serde_json::Map<String, Value>, String> {
let m = self
.modules
.find(id)
.ok_or_else(|| format!("no module {id}"))?;
Ok(settings::effective(&m.manifest, id))
}
pub fn module_set_setting(&mut self, id: &str, key: &str, v: Value) -> Result<Value, String> {
let m = self
.modules
.find(id)
.ok_or_else(|| format!("no module {id}"))?;
settings::set(&m.manifest, id, key, v)
}
pub fn run_module_startup_hooks(&mut self) {
let pending: Vec<(String, Vec<Vec<String>>)> = self
.modules
.modules
.iter()
.filter(|m| m.is_runnable() && !self.module_startup_done.contains(&m.id))
.map(|m| {
let cmds = m
.manifest
.startup
.iter()
.filter(|s| crate::module::manifest::allowed_on(s.platforms.as_ref()))
.map(|s| s.command.clone())
.collect();
(m.id.clone(), cmds)
})
.collect();
for (id, cmds) in pending {
self.module_startup_done.insert(id.clone());
for argv in cmds {
let extra = vec![("BOHAY_MODULE_EVENT".to_string(), "startup".to_string())];
let _ = self.run_module_command(&id, argv, "startup".to_string(), extra, "startup");
}
}
}
pub fn module_invoke_action(
&mut self,
action_id: &str,
module_filter: Option<&str>,
source: &str,
) -> Result<u64, String> {
self.module_invoke_action_with(action_id, module_filter, source, Vec::new())
}
pub fn module_invoke_dock_action(
&mut self,
action_id: &str,
module_filter: Option<&str>,
row_env: Vec<(String, String)>,
) -> Result<u64, String> {
self.module_invoke_action_with(action_id, module_filter, "dock", row_env)
}
pub fn module_invoke_action_with(
&mut self,
action_id: &str,
module_filter: Option<&str>,
source: &str,
extra_env: Vec<(String, String)>,
) -> Result<u64, String> {
if let Some(mid) = module_filter {
match self.modules.find(mid) {
None => return Err(format!("no module {mid}")),
Some(m) if !m.is_runnable() => {
return Err(m
.warning
.clone()
.unwrap_or_else(|| format!("module {mid} is disabled")))
}
Some(m) if m.manifest.action(action_id).is_none() => {
return Err(format!("module {mid} has no action {action_id}"))
}
_ => {}
}
}
let matches: Vec<(String, Vec<String>)> = self
.modules
.modules
.iter()
.filter(|m| m.is_runnable())
.filter(|m| module_filter.is_none_or(|f| m.id == f))
.filter_map(|m| {
m.manifest
.action(action_id)
.map(|a| (m.id.clone(), a.command.clone()))
})
.collect();
let (module_id, argv) = match matches.len() {
0 => return Err(format!("no runnable module has action {action_id}")),
1 => matches.into_iter().next().unwrap(),
_ => {
return Err(format!(
"action {action_id} is ambiguous — pass a module id"
))
}
};
let mut extra = vec![("BOHAY_MODULE_ACTION_ID".to_string(), action_id.to_string())];
extra.extend(extra_env);
self.run_module_command(
&module_id,
argv,
format!("action:{action_id}"),
extra,
source,
)
}
pub fn emit_event(&mut self, name: &str, data: serde_json::Value) {
let event_json = data.to_string();
api::publish(
&self.events,
json!({ "event": name, "data": data }).to_string(),
);
let mut targets: Vec<(String, Vec<String>)> = Vec::new();
for m in &self.modules.modules {
if !m.is_runnable() {
continue;
}
for e in &m.manifest.events {
let matches = e.on == name
|| e.on
.strip_prefix("node.")
.is_some_and(|suffix| name.strip_prefix("workspace.") == Some(suffix));
if matches && crate::module::manifest::allowed_on(e.platforms.as_ref()) {
targets.push((m.id.clone(), e.command.clone()));
}
}
}
for (module_id, argv) in targets {
let extra = vec![
("BOHAY_MODULE_EVENT".to_string(), name.to_string()),
("BOHAY_MODULE_EVENT_JSON".to_string(), event_json.clone()),
];
let _ =
self.run_module_command(&module_id, argv, format!("event:{name}"), extra, "event");
}
}
pub fn module_open_pane(
&mut self,
module_id: &str,
entrypoint: &str,
placement: Option<&str>,
source: &str,
) -> Result<PaneId, String> {
let argv = {
let m = self
.modules
.find(module_id)
.ok_or_else(|| format!("no module {module_id}"))?;
if !m.is_runnable() {
return Err(m
.warning
.clone()
.unwrap_or_else(|| format!("module {module_id} is disabled")));
}
m.manifest
.panes
.iter()
.find(|p| {
p.id == entrypoint && crate::module::manifest::allowed_on(p.platforms.as_ref())
})
.map(|p| p.command.clone())
.ok_or_else(|| format!("module {module_id} has no pane {entrypoint}"))?
};
let placement = placement.unwrap_or("split");
let ctx = context::build(self, source);
let (root, mut env) = {
let m = self.modules.find(module_id).unwrap();
(m.root.clone(), runtime::base_env(m, &ctx))
};
env.push((
"BOHAY_MODULE_ENTRYPOINT_ID".to_string(),
entrypoint.to_string(),
));
let id = PaneId::alloc();
let scrollback = self.config.scrollback();
let pane = Pane::spawn_command(
id,
80,
24,
root,
self.app_tx.clone(),
&argv,
&env,
scrollback,
)
.map_err(|e| format!("cannot spawn module pane: {e}"))?;
let cmd = pane.command.clone();
self.panes.insert(id, pane);
self.status.insert(id, PaneStatus::new(cmd));
self.session_dirty = true;
match placement {
"tab" => {
let ws = &mut self.workspaces[self.active_ws];
ws.tabs.push(Tab::panes(TileLayout::new(id)));
ws.active_tab = ws.tabs.len() - 1;
self.zoomed = false;
}
"overlay" => {
self.layout_mut().split_focused(Axis::Col, id);
self.zoomed = true; }
_ => {
self.layout_mut().split_focused(Axis::Col, id);
self.zoomed = false;
}
}
self.module_panes.insert(
id,
ModulePaneRecord {
module_id: module_id.to_string(),
entrypoint: entrypoint.to_string(),
},
);
self.emit_event(
"pane.created",
json!({"pane": id.0.to_string(), "module": module_id}),
);
Ok(id)
}
pub fn run_module_command(
&mut self,
module_id: &str,
argv: Vec<String>,
label: String,
extra_env: Vec<(String, String)>,
source: &str,
) -> Result<u64, String> {
self.run_module_command_for(module_id, argv, label, extra_env, source, Target::default())
}
pub fn run_module_command_for(
&mut self,
module_id: &str,
argv: Vec<String>,
label: String,
extra_env: Vec<(String, String)>,
source: &str,
target: Target,
) -> Result<u64, String> {
{
let module = self
.modules
.find(module_id)
.ok_or_else(|| format!("no module {module_id}"))?;
if !module.is_runnable() {
return Err(module
.warning
.clone()
.unwrap_or_else(|| format!("module {module_id} is disabled")));
}
}
let in_flight = self
.module_logs
.iter()
.filter(|l| l.status == ModuleStatus::Running)
.count();
if in_flight >= runtime::MAX_IN_FLIGHT {
return Err(format!(
"too many module commands in flight (max {})",
runtime::MAX_IN_FLIGHT
));
}
let ctx = context::build_for(self, source, &target);
let (root, mut env) = {
let module = self.modules.find(module_id).unwrap();
(module.root.clone(), runtime::base_env(module, &ctx))
};
env.extend(extra_env);
let log_id = runtime::next_log_id();
self.push_module_log(ModuleCommandLog {
id: log_id,
module_id: module_id.to_string(),
label,
argv: argv.clone(),
status: ModuleStatus::Running,
code: None,
out: String::new(),
err: String::new(),
});
runtime::spawn(log_id, root, argv, env, self.app_tx.clone());
Ok(log_id)
}
fn push_module_log(&mut self, log: ModuleCommandLog) {
self.module_logs.push(log);
let n = self.module_logs.len();
if n > runtime::LOG_LIMIT {
self.module_logs.drain(0..n - runtime::LOG_LIMIT);
}
}
pub fn module_command_finished(
&mut self,
log_id: u64,
code: Option<i32>,
out: String,
err: String,
) {
if let Some(log) = self.module_logs.iter_mut().find(|l| l.id == log_id) {
log.status = if code == Some(0) {
ModuleStatus::Succeeded
} else {
ModuleStatus::Failed
};
log.code = code;
log.out = out;
log.err = err;
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::persist::TEST_ENV_LOCK;
use std::time::{Duration, Instant};
#[test]
fn link_then_run_action_captures_output() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modtest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let dir = home.join("echo-mod");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("bohay-module.toml"),
r#"
id = "you.echo"
name = "Echo"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[actions]]
id = "refresh"
title = "Refresh"
command = ["sh", "-c", "echo hello-from-module; echo oops 1>&2"]
"#,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let id = app.module_link_with(&dir, true, None).unwrap();
assert_eq!(id, "you.echo");
assert!(app.modules.find(&id).unwrap().is_runnable());
let log_id = app.module_invoke_action("refresh", None, "test").unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(ev) = rx.recv_timeout(Duration::from_millis(100)) {
app.handle_event(ev);
}
let resolved = app
.module_logs
.iter()
.find(|l| l.id == log_id)
.is_some_and(|l| l.status != ModuleStatus::Running);
if resolved || Instant::now() > deadline {
break;
}
}
let log = app.module_logs.iter().find(|l| l.id == log_id).unwrap();
assert_eq!(log.status, ModuleStatus::Succeeded, "stderr: {}", log.err);
assert_eq!(log.code, Some(0));
assert!(
log.out.contains("hello-from-module"),
"captured stdout: {:?}",
log.out
);
assert!(log.err.contains("oops"), "captured stderr: {:?}", log.err);
app.module_set_enabled(&id, false).unwrap();
assert!(!app.modules.find(&id).unwrap().is_runnable());
assert!(app.module_invoke_action("refresh", None, "test").is_err());
let err = app
.module_invoke_action("refresh", Some(&id), "test")
.unwrap_err();
assert!(err.contains("disabled"), "got: {err}");
app.module_unlink(&id).unwrap();
assert!(app.modules.find(&id).is_none());
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn open_module_pane_tracks_and_cleans_up() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-panetest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let dir = home.join("board-mod");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("bohay-module.toml"),
r#"
id = "you.board"
name = "Board"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[panes]]
id = "board"
title = "Board"
command = ["sh", "-c", "sleep 5"]
"#,
)
.unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.module_link_with(&dir, true, None).unwrap();
let before = app.panes.len();
let pid = app
.module_open_pane("you.board", "board", Some("split"), "test")
.unwrap();
assert_eq!(app.panes.len(), before + 1, "a real pane was spawned");
assert!(
app.module_panes.contains_key(&pid),
"tracked as a module pane"
);
assert!(
app.layout().leaves().contains(&pid),
"the module pane is in the layout"
);
assert!(app
.module_open_pane("you.board", "nope", None, "test")
.is_err());
app.close_pane(pid);
assert!(!app.panes.contains_key(&pid));
assert!(!app.module_panes.contains_key(&pid), "record auto-removed");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn event_hook_runs_with_event_env() {
use std::time::{Duration, Instant};
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-evtest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let dir = home.join("notify-mod");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("bohay-module.toml"),
r#"
id = "you.notify"
name = "Notify"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[events]]
on = "pane.agent_status_changed"
command = ["sh", "-c", "echo event=$BOHAY_MODULE_EVENT json=$BOHAY_MODULE_EVENT_JSON"]
"#,
)
.unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.module_link_with(&dir, true, None).unwrap();
app.emit_event(
"pane.agent_status_changed",
serde_json::json!({"pane": "1", "status": "blocked", "agent": "claude"}),
);
let log_id = app
.module_logs
.iter()
.find(|l| l.label == "event:pane.agent_status_changed")
.map(|l| l.id)
.expect("a hook command was queued");
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(ev) = rx.recv_timeout(Duration::from_millis(100)) {
app.handle_event(ev);
}
let resolved = app
.module_logs
.iter()
.find(|l| l.id == log_id)
.is_some_and(|l| l.status != ModuleStatus::Running);
if resolved || Instant::now() > deadline {
break;
}
}
let log = app.module_logs.iter().find(|l| l.id == log_id).unwrap();
assert_eq!(log.status, ModuleStatus::Succeeded, "stderr: {}", log.err);
assert!(
log.out.contains("event=pane.agent_status_changed"),
"event name injected: {:?}",
log.out
);
assert!(
log.out.contains("blocked"),
"event json injected: {:?}",
log.out
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn module_pane_survives_snapshot_restore() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-restoretest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let dir = home.join("board-mod");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("bohay-module.toml"),
r#"
id = "you.board"
name = "Board"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[panes]]
id = "board"
title = "Board"
command = ["sh", "-c", "sleep 5"]
"#,
)
.unwrap();
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.module_link_with(&dir, true, None).unwrap();
let pid = app
.module_open_pane("you.board", "board", Some("split"), "test")
.unwrap();
assert!(app.module_panes.contains_key(&pid));
let snap = crate::persist::snapshot(&app);
let (tx2, _rx2) = std::sync::mpsc::channel();
let restored = App::from_snapshot(snap, tx2).expect("restore");
let rec = restored
.module_panes
.iter()
.find(|(_, r)| r.module_id == "you.board" && r.entrypoint == "board");
assert!(rec.is_some(), "module pane was restored as a module pane");
let (rid, _) = rec.unwrap();
assert_eq!(
restored.panes.get(rid).map(|p| p.command.as_str()),
Some("sh"),
"it re-ran the module command, not the login shell"
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn settings_modules_tab_lists_and_toggles() {
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::Terminal;
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modtab-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
for n in ["alpha", "beta"] {
let dir = home.join(n);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("bohay-module.toml"),
format!("id = \"you.{n}\"\nname = \"{n}\"\nversion = \"0.1.0\"\nmin_bohay_version = \"0.1.0\"\n"),
)
.unwrap();
}
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.module_link_with(&home.join("alpha"), true, None)
.unwrap();
app.module_link_with(&home.join("beta"), true, None)
.unwrap();
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
app.open_settings();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char('5'),
KeyModifiers::NONE,
))); term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.settings_ctl_rects.len(), 2, "one row per module");
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("you.alpha") && text.contains("you.beta"));
let before = app.modules.find("you.alpha").unwrap().enabled;
let row = app
.settings_ctl_rects
.iter()
.find(|(i, _)| *i == 0)
.unwrap()
.1;
app.handle_event(AppEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: row.x + 2,
row: row.y,
modifiers: KeyModifiers::NONE,
}));
assert_ne!(app.modules.find("you.alpha").unwrap().enabled, before);
assert_eq!(
crate::module::registry::load()
.find("you.alpha")
.unwrap()
.enabled,
!before
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
fn link(app: &mut App, home: &Path, name: &str, toml: &str) -> String {
let dir = home.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bohay-module.toml"), toml).unwrap();
app.module_link_with(&dir, true, None).unwrap()
}
fn settle(app: &mut App, rx: &std::sync::mpsc::Receiver<AppEvent>, log_id: u64) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(ev) = rx.recv_timeout(Duration::from_millis(100)) {
app.handle_event(ev);
}
let done = app
.module_logs
.iter()
.find(|l| l.id == log_id)
.is_some_and(|l| l.status != ModuleStatus::Running);
if done || Instant::now() > deadline {
return;
}
}
}
#[test]
fn module_actions_appear_in_the_right_click_menus() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modmenu-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let pane_builtins = app.pane_menu_items().len();
let ws_builtins = app.ws_menu_items(0).len();
let id = link(
&mut app,
&home,
"ctx-mod",
r#"
id = "you.ctx"
name = "Ctx"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[actions]]
id = "on-pane"
title = "Do pane thing"
contexts = ["pane"]
command = ["sh", "-c", "echo pane=$BOHAY_PANE_ID src=$BOHAY_MODULE_CONTEXT_JSON"]
[[actions]]
id = "on-node"
title = "Do node thing"
contexts = ["node"]
command = ["sh", "-c", "echo ws=$BOHAY_WORKSPACE_ID"]
[[actions]]
id = "headless"
title = "Never in a menu"
command = ["true"]
"#,
);
assert_eq!(app.module_menu_actions("pane").len(), 1);
assert_eq!(app.module_menu_actions("workspace").len(), 1, "node alias");
assert_eq!(app.module_menu_actions("agent").len(), 0);
let target = app.layout().focus;
app.open_pane_menu(target, 1, 1);
assert_eq!(app.pane_menu_items().len(), pane_builtins + 2);
app.open_ws_menu(0, 1, 1);
assert_eq!(app.ws_menu_items(0).len(), ws_builtins + 2);
app.ws_menu = None;
{
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Do pane thing"), "the module row is drawn");
assert!(
!text.contains("Never in a menu"),
"an action with no contexts stays out of the menu"
);
let rects = app.pane_menu.as_ref().unwrap().items.len();
assert_eq!(rects, app.pane_menu_items().len());
}
app.pane_menu_action(PaneMenuItem::Module(0));
assert!(app.pane_menu.is_none(), "the menu closed");
let log_id = app
.module_logs
.iter()
.find(|l| l.label == "action:on-pane")
.map(|l| l.id)
.expect("the action was queued");
settle(&mut app, &rx, log_id);
let log = app.module_logs.iter().find(|l| l.id == log_id).unwrap();
assert_eq!(log.status, ModuleStatus::Succeeded, "stderr: {}", log.err);
assert!(
log.out.contains(&format!("pane={}", target.0)),
"flat BOHAY_PANE_ID points at the clicked pane: {:?}",
log.out
);
assert!(
log.out.contains("\"invocation_source\":\"menu:pane\""),
"the context records where it came from: {:?}",
log.out
);
app.module_set_enabled(&id, false).unwrap();
assert_eq!(app.module_menu_actions("pane").len(), 0);
app.open_pane_menu(target, 1, 1);
assert_eq!(app.pane_menu_items().len(), pane_builtins, "no module rows");
app.module_set_enabled(&id, true).unwrap();
app.open_pane_menu(target, 1, 1);
assert_eq!(app.pane_menu.as_ref().unwrap().module_actions.len(), 1);
app.module_set_enabled(&id, false).unwrap();
let before = app.module_logs.len();
app.pane_menu_action(PaneMenuItem::Module(0));
assert_eq!(app.module_logs.len(), before, "nothing was run");
assert!(app.toast.is_some(), "and the user was told why");
app.open_pane_menu(target, 1, 1);
app.pane_menu_action(PaneMenuItem::Module(99));
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn startup_hooks_run_once_and_again_after_re_enable() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modboot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
let id = link(
&mut app,
&home,
"boot-mod",
r#"
id = "you.boot"
name = "Boot"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[startup]]
command = ["sh", "-c", "echo booted event=$BOHAY_MODULE_EVENT"]
"#,
);
let count = |a: &App| {
a.module_logs
.iter()
.filter(|l| l.label == "startup")
.count()
};
assert_eq!(count(&app), 1, "linking runs the hook");
let log_id = app
.module_logs
.iter()
.find(|l| l.label == "startup")
.unwrap()
.id;
settle(&mut app, &rx, log_id);
let log = app.module_logs.iter().find(|l| l.id == log_id).unwrap();
assert_eq!(log.status, ModuleStatus::Succeeded, "stderr: {}", log.err);
assert!(log.out.contains("event=startup"), "got: {:?}", log.out);
app.run_module_startup_hooks();
app.run_module_startup_hooks();
assert_eq!(count(&app), 1, "once per process");
app.module_set_enabled(&id, false).unwrap();
assert_eq!(count(&app), 1, "disabling runs nothing");
app.module_set_enabled(&id, true).unwrap();
assert_eq!(count(&app), 2, "re-enabling re-runs it");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn declared_settings_reach_a_command_as_env() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modsetenv-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
link(
&mut app,
&home,
"cfg-mod",
r#"
id = "you.cfg"
name = "Cfg"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[settings]]
key = "token"
title = "Token"
type = "string"
secret = true
[[settings]]
key = "limit"
title = "Limit"
type = "number"
default = 5
min = 1
max = 10
[[actions]]
id = "show"
title = "Show"
command = ["sh", "-c", "echo t=$BOHAY_SETTING_TOKEN l=$BOHAY_SETTING_LIMIT"]
"#,
);
let vals = app.module_settings("you.cfg").unwrap();
assert_eq!(vals.get("limit").unwrap(), 5);
assert_eq!(vals.get("token").unwrap(), "");
app.module_set_setting("you.cfg", "token", "abc123".into())
.unwrap();
assert_eq!(
app.module_set_setting("you.cfg", "limit", 99.into())
.unwrap(),
10
);
let log_id = app.module_invoke_action("show", None, "test").unwrap();
settle(&mut app, &rx, log_id);
let log = app.module_logs.iter().find(|l| l.id == log_id).unwrap();
assert_eq!(log.status, ModuleStatus::Succeeded, "stderr: {}", log.err);
assert!(
log.out.contains("t=abc123 l=10"),
"settings reached the command: {:?}",
log.out
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn listing_settings_masks_secrets_but_get_returns_them() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modsec-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
link(
&mut app,
&home,
"sec-mod",
r#"
id = "you.sec"
name = "Sec"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[settings]]
key = "token"
title = "Token"
type = "string"
secret = true
[[settings]]
key = "host"
title = "Host"
type = "string"
default = "example.com"
"#,
);
app.module_set_setting("you.sec", "token", "s3cret".into())
.unwrap();
let list = app
.dispatch("module.settings.list", &json!({"id": "you.sec"}))
.unwrap();
let text = list.to_string();
assert!(
!text.contains("s3cret"),
"a listing must not print a secret: {text}"
);
let entries = list["settings"].as_array().unwrap();
let token = entries.iter().find(|e| e["key"] == "token").unwrap();
assert_eq!(token["value"], Value::Null, "masked");
assert_eq!(token["set"], true, "but reported as configured");
let host = entries.iter().find(|e| e["key"] == "host").unwrap();
assert_eq!(host["value"], "example.com");
let got = app
.dispatch(
"module.settings.get",
&json!({"id": "you.sec", "key": "token"}),
)
.unwrap();
assert_eq!(got["value"], "s3cret");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn item_platforms_gate_panes_and_events() {
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modplat-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
link(
&mut app,
&home,
"plat-mod",
r#"
id = "you.plat"
name = "Plat"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[panes]]
id = "nope"
title = "Elsewhere only"
platforms = ["plan9"]
command = ["sh", "-c", "sleep 5"]
[[events]]
on = "tab.created"
platforms = ["plan9"]
command = ["sh", "-c", "echo should-not-run"]
# A module written against the old event spelling still fires.
[[events]]
on = "node.created"
command = ["sh", "-c", "echo legacy-alias-fired"]
"#,
);
let err = app
.module_open_pane("you.plat", "nope", None, "test")
.unwrap_err();
assert!(err.contains("no pane nope"), "got: {err}");
app.emit_event("tab.created", json!({"tab": "1"}));
assert!(
!app.module_logs
.iter()
.any(|l| l.label == "event:tab.created"),
"a platform-gated hook stays out of the queue"
);
app.emit_event("workspace.created", json!({"workspace": "0"}));
assert!(
app.module_logs
.iter()
.any(|l| l.label == "event:workspace.created"),
"the legacy node.* alias still fires"
);
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn settings_tab_renders_and_edits_module_settings() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-modsettab-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(100, 30, tx).unwrap();
link(
&mut app,
&home,
"ui-mod",
r#"
id = "you.ui"
name = "Ui"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[settings]]
key = "loud"
title = "Play a sound"
type = "bool"
[[settings]]
key = "mode"
title = "Mode"
type = "enum"
options = ["fast", "slow"]
[[settings]]
key = "token"
title = "API token"
type = "string"
secret = true
"#,
);
let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
app.open_settings();
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char('5'),
KeyModifiers::NONE,
))); term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let screen = |t: &Terminal<TestBackend>| -> String {
t.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
let text = screen(&term);
assert!(text.contains("you.ui"), "the module row renders");
assert!(
text.contains("Play a sound"),
"its settings render under it"
);
assert!(text.contains("Mode"));
assert_eq!(app.module_rows().len(), 4);
assert_eq!(app.settings_ctl_rects.len(), 4);
assert_eq!(
app.module_rows()[2],
crate::app::ModuleRow::Setting(0, 1),
"row 2 is the enum setting"
);
for _ in 0..2 {
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::NONE,
)));
}
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::NONE,
)));
assert_eq!(
app.module_settings("you.ui").unwrap().get("mode").unwrap(),
"slow"
);
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Down,
KeyModifiers::NONE,
)));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
assert!(app.module_setting_edit.is_some(), "the prompt opened");
for c in "hunter2".chars() {
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Char(c),
KeyModifiers::NONE,
)));
}
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let typed = screen(&term);
assert!(typed.contains("•••••••"), "a secret echoes as bullets");
assert!(!typed.contains("hunter2"), "and never in the clear");
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
assert!(app.module_setting_edit.is_none(), "the prompt closed");
assert_eq!(
app.module_settings("you.ui").unwrap().get("token").unwrap(),
"hunter2"
);
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Up,
KeyModifiers::NONE,
)));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Up,
KeyModifiers::NONE,
)));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Up,
KeyModifiers::NONE,
)));
app.handle_event(AppEvent::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
assert!(!app.modules.find("you.ui").unwrap().enabled);
assert_eq!(
app.module_rows().len(),
1,
"settings collapse when disabled"
);
assert!(app.settings.as_ref().unwrap().cursor < app.module_rows().len());
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn git_install_builds_and_uninstall_removes_checkout() {
use std::process::Command;
let _env = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = std::env::temp_dir().join(format!("bohay-gittest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("BOHAY_HOME", &home);
let remote = home.join("remote");
std::fs::create_dir_all(&remote).unwrap();
std::fs::write(
remote.join("bohay-module.toml"),
r#"
id = "you.installed"
name = "Installed"
version = "0.1.0"
min_bohay_version = "0.1.0"
[[build]]
command = ["sh", "-c", "touch built.txt"]
[[actions]]
id = "hello"
title = "Hello"
command = ["echo", "hi"]
"#,
)
.unwrap();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&remote)
.output()
.expect("git available")
};
git(&["init", "-q"]);
git(&["add", "-A"]);
git(&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"-m",
"init",
]);
let url = format!("file://{}", remote.display());
let installed = crate::module::install::install(&url, None, true).expect("install");
assert_eq!(installed.id, "you.installed");
assert!(
installed.source.contains('@'),
"pinned source: {}",
installed.source
);
assert!(installed.root.exists());
assert!(
crate::module::install::is_removable(&installed.root),
"landed in the managed dir"
);
assert!(
installed.root.join("built.txt").exists(),
"the [[build]] step ran"
);
let (tx, _rx) = std::sync::mpsc::channel();
let mut app = App::new(80, 24, tx).unwrap();
app.module_link_with(&installed.root, true, Some(installed.source.clone()))
.unwrap();
assert!(app.modules.find("you.installed").is_some());
app.module_uninstall("you.installed").unwrap();
assert!(app.modules.find("you.installed").is_none());
assert!(!installed.root.exists(), "managed checkout removed");
std::env::remove_var("BOHAY_HOME");
let _ = std::fs::remove_dir_all(&home);
}
}