use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver, Sender};
#[cfg(not(test))]
use std::time::Duration;
use serde_json::Value;
use crate::app::{App, SegmentSide};
use crate::integration_manifest::{IntegrationManifest, StatuslineSegment};
pub const DEFAULT_POLL_SECS: u64 = 300;
pub const MIN_POLL_SECS: u64 = 30;
pub const MAX_POLL_SECS: u64 = 3600;
pub const MAX_WORKERS: usize = 32;
const RENDER_PRIORITY: u8 = 120;
const RENDER_MIN_WIDTH: u16 = 6;
const RENDER_MAX_WIDTH: u16 = 30;
#[derive(Debug, Clone)]
pub struct SourceUpdate {
pub source_id: String,
pub result: Result<HashMap<String, Value>, String>,
}
#[derive(Debug, Clone, Default)]
pub struct ValuesSnapshot {
pub values: HashMap<String, Value>,
pub updated_at: u64,
pub last_error: Option<String>,
}
impl App {
pub fn start_statusline_segment_workers(&mut self) {
let mut sources: Vec<(String, String, u64)> = self
.integration_manifests
.iter()
.filter(|m| self.integration_chip_enabled(&m.id))
.flat_map(|m| {
m.values_sources
.iter()
.filter(|s| binary_from_command_on_path(&s.command))
.map(|s| {
(
s.id.clone(),
s.command.clone(),
clamped_interval(s.poll_interval_secs),
)
})
})
.collect();
self.statusline_segment_worker_shutdowns.clear();
if sources.len() > MAX_WORKERS {
let dropped_count = sources.len() - MAX_WORKERS;
let dropped_ids: Vec<String> = sources[MAX_WORKERS..]
.iter()
.map(|(id, _, _)| id.clone())
.collect();
sources.truncate(MAX_WORKERS);
self.toast(format!(
"statusline: {dropped_count} segment source(s) skipped (cap {MAX_WORKERS}): {}",
dropped_ids.join(", ")
));
}
if sources.is_empty() {
self.statusline_segments_tx = None;
self.statusline_segments_rx = None;
return;
}
let (tx, rx) = mpsc::channel::<SourceUpdate>();
self.statusline_segments_tx = Some(tx.clone());
self.statusline_segments_rx = Some(rx);
for (index, (id, command, interval)) in sources.into_iter().enumerate() {
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
self.statusline_segment_worker_shutdowns.push(shutdown_tx);
let stagger_secs = (index as u64 * 2).min(30);
spawn_worker(id, command, interval, stagger_secs, tx.clone(), shutdown_rx);
}
}
pub fn drain_statusline_segments(&mut self) {
let mut got_update = false;
if let Some(rx) = self.statusline_segments_rx.as_ref() {
loop {
match rx.try_recv() {
Ok(update) => {
got_update = true;
let slot = self
.values_source_snapshots
.entry(update.source_id.clone())
.or_default();
match update.result {
Ok(values) => {
slot.values = values;
slot.updated_at = now_secs();
slot.last_error = None;
}
Err(err) => {
slot.last_error = Some(err);
}
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.statusline_segments_rx = None;
break;
}
}
}
}
let desired = self.compute_segment_render_state();
self.apply_segment_render_state(desired, got_update);
}
fn compute_segment_render_state(&self) -> Vec<RenderedSegment> {
let mut out: Vec<RenderedSegment> = Vec::new();
for m in &self.integration_manifests {
if !self.integration_chip_enabled(&m.id) {
continue;
}
for seg in &m.statusline_segments {
let (text, color, tooltip_extra) =
render_segment_text(seg, self.values_source_snapshots.get(&seg.source), m);
out.push(RenderedSegment {
id: seg.id.clone(),
integration_id: m.id.clone(),
text,
color,
click_command: seg.click_command.clone(),
tooltip: seg.tooltip.clone(),
tooltip_extra,
});
}
}
out
}
fn apply_segment_render_state(&mut self, desired: Vec<RenderedSegment>, _got_update: bool) {
let managed_ids: std::collections::HashSet<String> =
desired.iter().map(|d| d.id.clone()).collect();
let stale: Vec<String> = self
.statusline_segment_managed_ids
.iter()
.filter(|id| !managed_ids.contains(*id))
.cloned()
.collect();
for id in stale {
self.statusline_clear_segment(&id);
self.statusline_segment_managed_ids.remove(&id);
}
for d in desired {
let tooltip = match (d.tooltip.as_deref(), d.tooltip_extra.as_deref()) {
(Some(base), Some(extra)) => Some(format!("{base}\n{extra}")),
(Some(base), None) => Some(base.to_string()),
(None, Some(extra)) => Some(extra.to_string()),
(None, None) => None,
};
self.statusline_set_segment_full(
d.id.clone(),
SegmentSide::Right,
d.text,
Some(d.color),
tooltip,
d.click_command,
RENDER_PRIORITY,
RENDER_MIN_WIDTH,
RENDER_MAX_WIDTH,
);
self.statusline_segment_managed_ids.insert(d.id);
}
}
pub(crate) fn integration_chip_enabled(&self, integration_id: &str) -> bool {
self.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == integration_id && ic.enabled)
}
}
struct RenderedSegment {
id: String,
#[allow(dead_code)] integration_id: String,
text: String,
color: String,
click_command: Option<String>,
tooltip: Option<String>,
tooltip_extra: Option<String>,
}
fn render_segment_text(
seg: &StatuslineSegment,
snapshot: Option<&ValuesSnapshot>,
manifest: &IntegrationManifest,
) -> (String, String, Option<String>) {
if !manifest.is_ready() {
return (
format!("{} \u{29D6}", seg.glyph.trim()),
"yellow".to_string(),
Some("integration not ready — check [requires] on the manifest".to_string()),
);
}
match snapshot {
None => (
format!("{} …", seg.glyph.trim()),
"comment".to_string(),
Some("waiting for first poll".to_string()),
),
Some(snap) if snap.updated_at == 0 && snap.last_error.is_none() => (
format!("{} …", seg.glyph.trim()),
"comment".to_string(),
Some("waiting for first poll".to_string()),
),
Some(snap) if snap.last_error.is_some() && snap.updated_at == 0 => (
format!("{} !", seg.glyph.trim()),
"red".to_string(),
snap.last_error.clone().map(|e| format!("last error: {e}")),
),
Some(snap) if snap.last_error.is_some() => (
format!(
"{} {}",
seg.glyph.trim(),
substitute_template(&seg.format, &snap.values)
),
"yellow".to_string(),
snap.last_error
.clone()
.map(|e| format!("stale — last poll failed: {e}")),
),
Some(snap) => (
format!(
"{} {}",
seg.glyph.trim(),
substitute_template(&seg.format, &snap.values)
),
seg.color.clone(),
None,
),
}
}
fn substitute_template(fmt: &str, values: &HashMap<String, Value>) -> String {
let mut out = String::with_capacity(fmt.len());
let mut chars = fmt.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c != '{' {
out.push(c);
continue;
}
let mut key = String::new();
let mut closed = false;
for (_, k) in chars.by_ref() {
if k == '}' {
closed = true;
break;
}
key.push(k);
}
if !closed {
out.push('{');
out.push_str(&key);
continue;
}
match lookup_key(&key, values) {
Some(v) => out.push_str(&format_value(v)),
None => out.push('?'),
}
}
out
}
fn lookup_key<'a>(key: &str, values: &'a HashMap<String, Value>) -> Option<&'a Value> {
let mut parts = key.split('.');
let first = parts.next()?;
let mut cur = values.get(first)?;
for p in parts {
cur = cur.as_object()?.get(p)?;
}
Some(cur)
}
fn format_value(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => "null".to_string(),
_ => v.to_string(),
}
}
pub(crate) fn binary_from_command_on_path(command: &str) -> bool {
let Some(bin) = command.split_whitespace().next() else {
return false;
};
let p = std::path::Path::new(bin);
if p.is_absolute() {
return p.is_file();
}
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
if dir.join(bin).is_file() {
return true;
}
}
false
}
fn clamped_interval(secs: Option<u64>) -> u64 {
secs.unwrap_or(DEFAULT_POLL_SECS)
.clamp(MIN_POLL_SECS, MAX_POLL_SECS)
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(not(test))]
fn spawn_worker(
source_id: String,
command: String,
interval_secs: u64,
stagger_secs: u64,
tx: Sender<SourceUpdate>,
shutdown_rx: std::sync::mpsc::Receiver<()>,
) {
std::thread::Builder::new()
.name(format!("mnml-statusline-{source_id}"))
.spawn(move || {
run_worker(
source_id,
command,
interval_secs,
stagger_secs,
tx,
shutdown_rx,
)
})
.ok();
}
#[cfg(test)]
fn spawn_worker(
_source_id: String,
_command: String,
_interval_secs: u64,
_stagger_secs: u64,
_tx: Sender<SourceUpdate>,
_shutdown_rx: std::sync::mpsc::Receiver<()>,
) {
}
#[cfg(not(test))]
fn run_worker(
source_id: String,
command: String,
interval_secs: u64,
stagger_secs: u64,
tx: Sender<SourceUpdate>,
shutdown_rx: std::sync::mpsc::Receiver<()>,
) {
if stagger_secs > 0 {
match shutdown_rx.recv_timeout(Duration::from_secs(stagger_secs)) {
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
}
loop {
let result = run_poll_once(&command);
if tx
.send(SourceUpdate {
source_id: source_id.clone(),
result,
})
.is_err()
{
return;
}
match shutdown_rx.recv_timeout(Duration::from_secs(interval_secs)) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
}
Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return;
}
}
}
}
#[cfg(not(test))]
fn run_poll_once(command: &str) -> Result<HashMap<String, Value>, String> {
let mut parts = command.split_whitespace();
let bin = parts.next().ok_or_else(|| "empty command".to_string())?;
let args: Vec<&str> = parts.collect();
let out = std::process::Command::new(bin)
.args(&args)
.output()
.map_err(|e| format!("spawn: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let head: String = stderr.chars().take(200).collect();
return Err(format!(
"exit {}: {}",
out.status.code().unwrap_or(-1),
head.trim()
));
}
let value: Value =
serde_json::from_slice(&out.stdout).map_err(|e| format!("json parse: {e}"))?;
let obj = value
.as_object()
.ok_or_else(|| "expected JSON object at top level".to_string())?;
Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
}
#[allow(dead_code)]
fn _channel_type_witness() -> Option<(Sender<SourceUpdate>, Receiver<SourceUpdate>)> {
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn vals(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn substitutes_top_level_keys() {
let v = vals(&[("open", json!(3)), ("approved", json!(1))]);
assert_eq!(substitute_template("{open}({approved})", &v), "3(1)");
}
#[test]
fn missing_keys_render_as_question_mark() {
let v = vals(&[("open", json!(3))]);
assert_eq!(substitute_template("{open} / {gone}", &v), "3 / ?");
}
#[test]
fn nested_keys_walk_objects() {
let v = vals(&[("scoped", json!({"limits": {"fable": 42}}))]);
assert_eq!(substitute_template("{scoped.limits.fable}%", &v), "42%");
assert_eq!(substitute_template("{scoped.limits.fable.deep}", &v), "?");
}
#[test]
fn string_values_render_without_quotes() {
let v = vals(&[("name", json!("bitbucket"))]);
assert_eq!(substitute_template("[{name}]", &v), "[bitbucket]");
}
#[test]
fn primitives_render_via_to_string() {
let v = vals(&[
("n", json!(2)),
("b", json!(true)),
("f", json!(1.5)),
("null", json!(null)),
]);
assert_eq!(
substitute_template("{n}/{b}/{f}/{null}", &v),
"2/true/1.5/null"
);
}
#[test]
fn unclosed_brace_renders_literally() {
let v = vals(&[("open", json!(3))]);
assert_eq!(substitute_template("{open} and {stuck", &v), "3 and {stuck");
}
#[test]
fn clamp_defaults_and_bounds() {
assert_eq!(clamped_interval(None), DEFAULT_POLL_SECS);
assert_eq!(clamped_interval(Some(0)), MIN_POLL_SECS);
assert_eq!(clamped_interval(Some(10)), MIN_POLL_SECS);
assert_eq!(clamped_interval(Some(120)), 120);
assert_eq!(clamped_interval(Some(99_999)), MAX_POLL_SECS);
}
#[test]
#[cfg(unix)]
fn binary_lookup_absolute_path() {
assert!(binary_from_command_on_path("/bin/sh -c 'echo hi'"));
assert!(!binary_from_command_on_path(
"/definitely/nonexistent/xyzzy --flag"
));
assert!(!binary_from_command_on_path(""));
}
#[test]
fn snapshot_state_transitions() {
use crate::integration_manifest::{IntegrationManifest, StatuslineSegment};
use std::path::PathBuf;
let m = IntegrationManifest {
id: "bb".into(),
label: "BB".into(),
description: None,
version: None,
binary: Some("mnml-forge-bitbucket".into()),
category: None,
homepage: None,
docs: None,
repository: None,
author: None,
chip: None,
commands: vec![],
context_menu: vec![],
menu_bar: vec![],
statusline: None,
values_sources: vec![],
statusline_segments: vec![],
settings: vec![],
notifications: None,
requires: None,
auth: vec![],
prefetch: vec![],
source_path: PathBuf::new(),
override_env: HashMap::new(),
override_auth_values: HashMap::new(),
auto_update_override: None,
};
let seg = StatuslineSegment {
id: "prs".into(),
source: "bb_vals".into(),
glyph: "".into(),
color: "cyan".into(),
format: "{open}".into(),
tooltip: None,
click_command: None,
};
let (text, color, _) = render_segment_text(&seg, None, &m);
assert!(
text.ends_with('…'),
"expected waiting placeholder, got {text:?}"
);
assert_eq!(color, "comment");
let snap = ValuesSnapshot {
values: HashMap::new(),
updated_at: 0,
last_error: Some("boom".into()),
};
let (text, color, extra) = render_segment_text(&seg, Some(&snap), &m);
assert!(text.ends_with('!'), "expected error sigil, got {text:?}");
assert_eq!(color, "red");
assert_eq!(extra.as_deref(), Some("last error: boom"));
let snap = ValuesSnapshot {
values: vals(&[("open", json!(4))]),
updated_at: 100,
last_error: None,
};
let (text, color, extra) = render_segment_text(&seg, Some(&snap), &m);
assert!(
text.ends_with(" 4"),
"expected substituted text, got {text:?}"
);
assert_eq!(color, "cyan");
assert!(extra.is_none());
}
}