use ratatui::style::Color;
use super::app::Tab;
use crate::core::ConfigProvider;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomKey {
pub key: char,
pub name: String,
pub command: String,
pub context: Option<Tab>,
}
impl CustomKey {
#[must_use]
pub fn applies(&self, tab: Tab) -> bool {
self.context.map_or(true, |c| c == tab)
}
}
#[derive(serde::Deserialize)]
struct RawCustomKey {
key: String,
name: String,
command: String,
#[serde(default)]
context: Option<String>,
}
#[must_use]
pub fn expand_template(template: &str, vars: &[(&str, String)]) -> String {
let mut out = template.to_owned();
for (name, value) in vars {
out = out.replace(&format!("{{{{{name}}}}}"), value);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub open: Color,
pub merged: Color,
pub failed: Color,
pub in_progress: Color,
pub other: Color,
}
impl Default for Theme {
fn default() -> Self {
Self {
open: Color::Green,
merged: Color::Cyan,
failed: Color::Red,
in_progress: Color::Yellow,
other: Color::Gray,
}
}
}
impl Theme {
#[must_use]
pub fn state(&self, state: &str) -> Color {
match state {
"OPEN" | "SUCCESSFUL" => self.open,
"MERGED" => self.merged,
"INPROGRESS" => self.in_progress,
"DECLINED" | "SUPERSEDED" | "FAILED" | "STOPPED" => self.failed,
_ => self.other,
}
}
}
#[derive(Debug, Clone)]
pub struct DashConfig {
pub default_tab: Tab,
pub refresh_secs: u64,
pub theme: Theme,
pub custom_keys: Vec<CustomKey>,
}
impl Default for DashConfig {
fn default() -> Self {
Self {
default_tab: Tab::PullRequests,
refresh_secs: 5,
theme: Theme::default(),
custom_keys: Vec::new(),
}
}
}
impl DashConfig {
#[must_use]
pub fn load(config: &dyn ConfigProvider) -> (Self, Vec<String>) {
let mut cfg = DashConfig::default();
let mut warnings = Vec::new();
let get = |k: &str| config.get("", k).filter(|v| !v.is_empty());
if let Some(v) = get("dash_default_tab") {
match v.as_str() {
"pr" => cfg.default_tab = Tab::PullRequests,
"issue" => cfg.default_tab = Tab::Issues,
"pipeline" => cfg.default_tab = Tab::Pipelines,
other => warnings.push(format!("dash_default_tab: unknown tab {other:?}")),
}
}
if let Some(v) = get("dash_refresh_secs") {
match v.parse::<u64>() {
Ok(n) if (2..=120).contains(&n) => cfg.refresh_secs = n,
_ => warnings.push(format!("dash_refresh_secs: expected 2..=120, got {v:?}")),
}
}
let mut color = |key: &str, slot: &mut Color| {
if let Some(v) = get(key) {
match parse_color(&v) {
Some(c) => *slot = c,
None => warnings.push(format!("{key}: unknown color {v:?}")),
}
}
};
color("dash_theme_state_open", &mut cfg.theme.open);
color("dash_theme_state_merged", &mut cfg.theme.merged);
color("dash_theme_state_failed", &mut cfg.theme.failed);
color("dash_theme_state_in_progress", &mut cfg.theme.in_progress);
if let Some(raw) = get("dash_custom_keys") {
match serde_json::from_str::<Vec<RawCustomKey>>(&raw) {
Ok(entries) => {
for e in entries {
let Some(key) = e.key.chars().next().filter(|_| e.key.chars().count() == 1)
else {
warnings.push(format!("custom key {:?}: must be a single char", e.key));
continue;
};
if super::keymap::is_reserved(key) {
warnings.push(format!(
"custom key '{key}' collides with a built-in binding; ignored"
));
continue;
}
let context = match e.context.as_deref() {
None => None,
Some("pr") => Some(Tab::PullRequests),
Some("issue") => Some(Tab::Issues),
Some("pipeline") => Some(Tab::Pipelines),
Some(other) => {
warnings
.push(format!("custom key '{key}': unknown context {other:?}"));
continue;
}
};
cfg.custom_keys.push(CustomKey {
key,
name: e.name,
command: e.command,
context,
});
}
}
Err(e) => warnings.push(format!("dash_custom_keys: invalid JSON ({e})")),
}
}
(cfg, warnings)
}
}
fn parse_color(name: &str) -> Option<Color> {
Some(match name.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"darkgray" | "darkgrey" => Color::DarkGray,
"white" => Color::White,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::config::FileConfig;
use super::*;
#[test]
fn missing_config_is_all_defaults() {
let cfg = Arc::new(FileConfig::blank());
let (dash, warnings) = DashConfig::load(cfg.as_ref());
assert_eq!(dash.default_tab, Tab::PullRequests);
assert_eq!(dash.refresh_secs, 5);
assert_eq!(dash.theme, Theme::default());
assert!(warnings.is_empty());
}
#[test]
fn valid_values_parse() {
let cfg = FileConfig::blank();
cfg.set("", "dash_default_tab", "pipeline").unwrap();
cfg.set("", "dash_refresh_secs", "10").unwrap();
cfg.set("", "dash_theme_state_open", "blue").unwrap();
let (dash, warnings) = DashConfig::load(&cfg);
assert_eq!(dash.default_tab, Tab::Pipelines);
assert_eq!(dash.refresh_secs, 10);
assert_eq!(dash.theme.open, Color::Blue);
assert!(warnings.is_empty());
}
#[test]
fn malformed_values_warn_and_default() {
let cfg = FileConfig::blank();
cfg.set("", "dash_default_tab", "bogus").unwrap();
cfg.set("", "dash_refresh_secs", "9999").unwrap();
cfg.set("", "dash_theme_state_failed", "chartreuse")
.unwrap();
let (dash, warnings) = DashConfig::load(&cfg);
assert_eq!(dash.default_tab, Tab::PullRequests);
assert_eq!(dash.refresh_secs, 5);
assert_eq!(dash.theme.failed, Color::Red);
assert_eq!(warnings.len(), 3, "warnings: {warnings:?}");
}
#[test]
fn custom_keys_parse_and_reject_collisions() {
let cfg = FileConfig::blank();
cfg.set(
"",
"dash_custom_keys",
r#"[
{"key":"v","name":"editor","command":"$EDITOR {{branch}}","context":"pr"},
{"key":"m","name":"bad","command":"x"},
{"key":"L","name":"lazygit","command":"lazygit"}
]"#,
)
.unwrap();
let (dash, warnings) = DashConfig::load(&cfg);
assert_eq!(dash.custom_keys.len(), 2);
assert_eq!(dash.custom_keys[0].key, 'v');
assert_eq!(dash.custom_keys[0].context, Some(Tab::PullRequests));
assert_eq!(dash.custom_keys[1].key, 'L');
assert!(dash.custom_keys[1].context.is_none());
assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
assert!(warnings[0].contains("collides"));
}
#[test]
fn custom_keys_invalid_json_warns() {
let cfg = FileConfig::blank();
cfg.set("", "dash_custom_keys", "not json").unwrap();
let (dash, warnings) = DashConfig::load(&cfg);
assert!(dash.custom_keys.is_empty());
assert_eq!(warnings.len(), 1);
}
#[test]
fn expand_template_fills_vars() {
let out = expand_template(
"$EDITOR {{branch}} # {{repo}}#{{id}} {{url}}",
&[
("branch", "feat/x".to_owned()),
("repo", "acme/widgets".to_owned()),
("id", "7".to_owned()),
("url", "https://bb/7".to_owned()),
],
);
assert_eq!(out, "$EDITOR feat/x # acme/widgets#7 https://bb/7");
}
#[test]
fn custom_key_applies_respects_context() {
let ck = CustomKey {
key: 'v',
name: "x".to_owned(),
command: "x".to_owned(),
context: Some(Tab::Issues),
};
assert!(ck.applies(Tab::Issues));
assert!(!ck.applies(Tab::PullRequests));
let any = CustomKey {
context: None,
..ck
};
assert!(any.applies(Tab::Pipelines));
}
#[test]
fn parse_color_known_and_unknown() {
assert_eq!(parse_color("CYAN"), Some(Color::Cyan));
assert_eq!(parse_color("grey"), Some(Color::Gray));
assert_eq!(parse_color("octarine"), None);
}
}