use crate::core::config::constraint::parse_constraint;
use crate::core::config::keymap_builder::{build_keymap, KeymapEntry};
use crate::core::config::merge::merge_user_config;
use crate::core::keymap::Keymap;
use crate::core::layout::{LayoutNode, PageLayoutConfig, SlotCase, SlotRule, SplitDirection};
use anyhow::{anyhow, Context, Result};
use kdl::{KdlDocument, KdlNode};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
static DEFAULT_KDL: &str = include_str!("../../../assets/default.kdl");
const VIEW_PANE: &str = "view";
const DEFAULT_PROCS_REFRESH_INTERVAL: &str = "2s";
const MIN_PROCS_REFRESH_MS: u64 = 250;
const DEFAULT_PROCS_HISTORY: usize = 120;
const MIN_PROCS_HISTORY: usize = 10;
const MAX_PROCS_HISTORY: usize = 10_000;
const DEFAULT_GITHUB_POLL_INTERVAL: &str = "5s";
const MIN_GITHUB_POLL_MS: u64 = 2000;
pub fn parse_interval(s: &str) -> Option<Duration> {
let s = s.trim();
let (num, per_unit_ms) = s
.strip_suffix("ms")
.map(|n| (n, 1.0))
.or_else(|| s.strip_suffix('s').map(|n| (n, 1000.0)))?;
let value: f64 = num.trim().parse().ok()?;
if !value.is_finite() || value <= 0.0 {
return None;
}
let ms = (value * per_unit_ms).round() as u64;
(ms >= MIN_PROCS_REFRESH_MS).then(|| Duration::from_millis(ms))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectsBoard {
ByTitle(String),
ByNumber(u64),
}
impl ProjectsBoard {
pub fn matches(&self, number: u64, title: &str) -> bool {
match self {
ProjectsBoard::ByTitle(t) => t
.chars()
.flat_map(char::to_lowercase)
.eq(title.chars().flat_map(char::to_lowercase)),
ProjectsBoard::ByNumber(n) => *n == number,
}
}
}
impl std::fmt::Display for ProjectsBoard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectsBoard::ByTitle(t) => write!(f, "{t:?}"),
ProjectsBoard::ByNumber(n) => write!(f, "{n}"),
}
}
}
pub struct LoadedPageConfig {
pub name: String,
pub layout: PageLayoutConfig,
pub pane_keys: HashMap<String, Vec<KeymapEntry>>,
pub pane_ids: Vec<(String, usize)>,
pub placed_ids: Vec<usize>,
pub bindings: Vec<(String, String)>,
pub source: Option<PathBuf>,
}
impl LoadedPageConfig {
fn describe_source(&self) -> String {
describe_source(self.source.as_deref())
}
pub fn keymap<A>(&self, pane: &str) -> Result<Keymap<A>>
where
A: Clone + FromStr<Err = String>,
{
let entries = self.pane_keys.get(pane).ok_or_else(|| {
anyhow!(
"invalid {}: page {:?} missing pane {pane:?} keys block",
self.describe_source(),
self.name
)
})?;
build_keymap::<A>(entries).map_err(|e| {
anyhow!(
"invalid {}: page {:?} pane {pane:?}: {e}",
self.describe_source(),
self.name
)
})
}
pub fn resolve_id(&self, name: &str) -> Option<usize> {
self.pane_ids
.iter()
.find(|(n, _)| n == name)
.map(|(_, id)| *id)
}
pub fn resolve_id_expect(&self, name: &str) -> usize {
self.resolve_id(name)
.unwrap_or_else(|| panic!("pane {name:?} not found in {:?} page config", self.name))
}
pub fn is_placed(&self, name: &str) -> bool {
self.resolve_id(name)
.is_some_and(|id| self.placed_ids.contains(&id))
}
pub fn resolve_select_bindings(&self) -> HashMap<usize, usize> {
self.bindings
.iter()
.map(|(sel, det)| {
let s = self
.resolve_id(sel)
.unwrap_or_else(|| panic!("bind select={sel:?}: pane not found in config"));
let d = self
.resolve_id(det)
.unwrap_or_else(|| panic!("bind detail={det:?}: pane not found in config"));
(s, d)
})
.filter(|(s, d)| self.placed_ids.contains(s) && self.placed_ids.contains(d))
.collect()
}
}
fn parse_app_block(doc: &KdlDocument) -> Result<Vec<(String, String)>> {
let mut entries = Vec::new();
for node in doc.nodes() {
let key_str = node.name().value().to_string();
let action_str = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("app entry {key_str:?} missing action argument"))?
.to_string();
entries.push((key_str, action_str));
}
Ok(entries)
}
fn describe_source(source: Option<&Path>) -> String {
match source {
Some(p) => format!("config file {}", p.display()),
None => "built-in config".to_string(),
}
}
fn removed_page_reason(name: &str) -> Option<String> {
match name {
"actions" => Some(
"page \"actions\" was folded into the \"github\" page (v0.8.0); \
remove it from pages / app bindings"
.to_string(),
),
_ => None,
}
}
#[derive(Debug)]
pub struct Config {
default_doc: KdlDocument,
doc: KdlDocument,
source: Option<PathBuf>,
}
impl Config {
pub fn default_text() -> &'static str {
DEFAULT_KDL
}
fn parse_default() -> KdlDocument {
DEFAULT_KDL.parse().expect("default.kdl is always valid")
}
pub fn builtin() -> Self {
let default_doc = Self::parse_default();
Self {
doc: default_doc.clone(),
default_doc,
source: None,
}
}
pub fn with_user(user: &KdlDocument, source: PathBuf) -> Result<Self> {
let default_doc = Self::parse_default();
let mut doc = default_doc.clone();
merge_user_config(&mut doc, user)
.with_context(|| format!("invalid config file {}", source.display()))?;
let cfg = Self {
default_doc,
doc,
source: Some(source),
};
cfg.validate(user)?;
Ok(cfg)
}
pub fn with_repo_layer(&self, repo: &KdlDocument, source: PathBuf) -> Result<Self> {
if repo
.nodes()
.iter()
.any(|n| n.name().value() == "repo-config")
{
return Err(anyhow!(
"invalid config file {}: repo-config can only be set in the user config, not in .vig.kdl",
source.display()
));
}
let mut doc = self.doc.clone();
merge_user_config(&mut doc, repo)
.with_context(|| format!("invalid config file {}", source.display()))?;
let cfg = Self {
default_doc: self.default_doc.clone(),
doc,
source: Some(source),
};
cfg.validate(repo)?;
Ok(cfg)
}
fn validate(&self, layer: &KdlDocument) -> Result<()> {
let pages = self.pages()?;
self.validate_user_app_bindings(layer, &pages)?;
self.git_page()?;
self.github_page()?;
self.files_page()?;
self.docker_page()?;
self.procs_page()?;
self.worktrees_page()?;
self.projects_page()?;
self.app_entries()?;
self.theme()?;
self.icons()?;
self.image_preview()?;
self.procs_refresh_interval()?;
self.procs_history()?;
self.github_poll_interval()?;
self.projects_board()?;
self.repo_config()?;
Ok(())
}
pub fn repo_config(&self) -> Result<bool> {
let Some(node) = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "repo-config")
else {
return Ok(true);
};
match node.get(0usize).and_then(|v| v.as_string()) {
Some("on") => Ok(true),
Some("off") => Ok(false),
_ => Err(anyhow!(
"invalid {}: repo-config expects \"on\" or \"off\"",
self.describe()
)),
}
}
pub fn builtin_page_names(&self) -> Vec<String> {
self.default_doc
.nodes()
.iter()
.filter(|n| n.name().value() == "page")
.filter_map(|n| n.get(0usize).and_then(|v| v.as_string()))
.map(str::to_string)
.collect()
}
pub fn pages(&self) -> Result<Vec<String>> {
let known = self.builtin_page_names();
let Some(node) = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "pages")
else {
return Ok(known);
};
let mut pages: Vec<String> = Vec::new();
for entry in node.entries() {
let name = match (entry.name(), entry.value().as_string()) {
(None, Some(s)) => s,
_ => {
return Err(anyhow!(
"invalid {}: pages: expected page names as string arguments \
(e.g. `pages \"git\" \"files\"`)",
self.describe()
))
}
};
if !known.iter().any(|k| k == name) {
if let Some(reason) = removed_page_reason(name) {
return Err(anyhow!("invalid {}: pages: {reason}", self.describe()));
}
return Err(anyhow!(
"invalid {}: pages: unknown page {name:?}; expected one of: {}",
self.describe(),
known.join(", ")
));
}
if pages.iter().any(|p| p == name) {
return Err(anyhow!(
"invalid {}: pages: page {name:?} listed twice",
self.describe()
));
}
pages.push(name.to_string());
}
if pages.is_empty() {
return Err(anyhow!(
"invalid {}: pages must list at least one page; expected some of: {}",
self.describe(),
known.join(", ")
));
}
Ok(pages)
}
fn validate_user_app_bindings(&self, user: &KdlDocument, pages: &[String]) -> Result<()> {
let Some(app) = user
.nodes()
.iter()
.find(|n| n.name().value() == "app")
.and_then(|n| n.children())
else {
return Ok(());
};
let known = self.builtin_page_names();
for (key, action) in parse_app_block(app)
.with_context(|| format!("invalid {}: app block", self.describe()))?
{
let Some(name) = action.strip_prefix("page:") else {
continue;
};
if pages.iter().any(|p| p == name) {
continue;
}
let reason = if known.iter().any(|k| k == name) {
format!(
"page {name:?} is not listed in `pages` ({})",
pages.join(", ")
)
} else if let Some(reason) = removed_page_reason(name) {
reason
} else {
format!(
"unknown page {name:?}; expected one of: {}",
known.join(", ")
)
};
return Err(anyhow!(
"invalid {}: app block: {key:?} {action:?}: {reason}",
self.describe()
));
}
Ok(())
}
pub fn procs_refresh_interval(&self) -> Result<Duration> {
let raw = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "procs-refresh-interval")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| {
anyhow!("procs-refresh-interval block missing interval argument")
})
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?
.unwrap_or_else(|| DEFAULT_PROCS_REFRESH_INTERVAL.to_string());
parse_interval(&raw).ok_or_else(|| {
anyhow!(
"invalid {}: bad procs-refresh-interval {raw:?}; expected a duration such as \
\"2s\" or \"500ms\" (at least {}ms)",
self.describe(),
MIN_PROCS_REFRESH_MS
)
})
}
pub fn procs_history(&self) -> Result<usize> {
let raw = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "procs-history")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| anyhow!("procs-history block missing sample-count argument"))
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?;
let Some(raw) = raw else {
return Ok(DEFAULT_PROCS_HISTORY);
};
match raw.trim().parse::<usize>() {
Ok(n) if (MIN_PROCS_HISTORY..=MAX_PROCS_HISTORY).contains(&n) => Ok(n),
_ => Err(anyhow!(
"invalid {}: bad procs-history {raw:?}; expected a sample count between \
{MIN_PROCS_HISTORY} and {MAX_PROCS_HISTORY}",
self.describe()
)),
}
}
pub fn github_poll_interval(&self) -> Result<Duration> {
let raw = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "github-poll-interval")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| anyhow!("github-poll-interval block missing interval argument"))
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?
.unwrap_or_else(|| DEFAULT_GITHUB_POLL_INTERVAL.to_string());
parse_interval(&raw)
.filter(|d| d.as_millis() >= u128::from(MIN_GITHUB_POLL_MS))
.ok_or_else(|| {
anyhow!(
"invalid {}: bad github-poll-interval {raw:?}; expected a duration such as \
\"5s\" or \"10s\" (at least {}ms)",
self.describe(),
MIN_GITHUB_POLL_MS
)
})
}
pub fn projects_board(&self) -> Result<Option<ProjectsBoard>> {
let Some(node) = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "projects-board")
else {
return Ok(None);
};
let invalid = |what: &str| {
anyhow!(
"invalid {}: bad projects-board ({what}); expected exactly one argument, \
a board title (`projects-board \"Roadmap\"`) or a project number \
(`projects-board 2`)",
self.describe()
)
};
let [entry] = node.entries() else {
return Err(invalid("one argument required"));
};
if entry.name().is_some() {
return Err(invalid("a property is not an argument"));
}
if let Some(title) = entry.value().as_string() {
if title.trim().is_empty() {
return Err(invalid("empty title"));
}
return Ok(Some(ProjectsBoard::ByTitle(title.to_string())));
}
if let Some(n) = entry.value().as_integer() {
return match u64::try_from(n) {
Ok(n) if n > 0 => Ok(Some(ProjectsBoard::ByNumber(n))),
_ => Err(invalid("a project number is positive")),
};
}
Err(invalid("not a string or integer"))
}
pub fn image_preview(&self) -> Result<crate::files::domain::image::ImagePreviewMode> {
use crate::files::domain::image::{ImagePreviewMode, IMAGE_PREVIEW_MODES};
let mode = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "image-preview")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| anyhow!("image-preview block missing mode argument"))
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?
.unwrap_or_else(|| IMAGE_PREVIEW_MODES[0].to_string());
ImagePreviewMode::parse(&mode).ok_or_else(|| {
anyhow!(
"invalid {}: unknown image-preview mode {mode:?}; expected one of: {}",
self.describe(),
IMAGE_PREVIEW_MODES.join(", ")
)
})
}
pub fn icons(&self) -> Result<bool> {
let modes = crate::files::domain::icons::ICON_MODES;
let mode = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "icons")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| anyhow!("icons block missing mode argument"))
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?
.unwrap_or_else(|| modes[0].to_string());
if !modes.contains(&mode.as_str()) {
return Err(anyhow!(
"invalid {}: unknown icons mode {mode:?}; expected one of: {}",
self.describe(),
modes.join(", ")
));
}
Ok(mode == "nerd")
}
pub fn theme(&self) -> Result<String> {
let name = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "theme")
.map(|n| {
n.get(0usize)
.and_then(|v| v.as_string())
.map(str::to_string)
.ok_or_else(|| anyhow!("theme block missing name argument"))
})
.transpose()
.with_context(|| format!("invalid {}", self.describe()))?
.unwrap_or_else(|| crate::core::syntax::DEFAULT_THEME.to_string());
let available = crate::core::syntax::theme_names();
if !available.contains(&name) {
return Err(anyhow!(
"invalid {}: unknown theme {name:?}; available: {}",
self.describe(),
available.join(", ")
));
}
Ok(name)
}
pub fn describe(&self) -> String {
describe_source(self.source.as_deref())
}
pub fn git_page(&self) -> Result<LoadedPageConfig> {
self.page("git")
}
pub fn github_page(&self) -> Result<LoadedPageConfig> {
self.page("github")
}
pub fn files_page(&self) -> Result<LoadedPageConfig> {
self.page("files")
}
pub fn docker_page(&self) -> Result<LoadedPageConfig> {
self.page("docker")
}
pub fn procs_page(&self) -> Result<LoadedPageConfig> {
self.page("procs")
}
pub fn worktrees_page(&self) -> Result<LoadedPageConfig> {
self.page("worktrees")
}
pub fn projects_page(&self) -> Result<LoadedPageConfig> {
self.page("projects")
}
fn page(&self, name: &str) -> Result<LoadedPageConfig> {
load_page_from_doc(&self.doc, &self.default_doc, name)
.map(|mut p| {
p.source = self.source.clone();
p
})
.with_context(|| format!("invalid {}", self.describe()))
}
pub fn app_entries(&self) -> Result<Vec<(String, String)>> {
let entries = self
.doc
.nodes()
.iter()
.find(|n| n.name().value() == "app")
.and_then(|n| n.children())
.map(parse_app_block)
.unwrap_or_else(|| Ok(Vec::new()))
.with_context(|| format!("invalid {}: app block", self.describe()))?;
let pages = self.pages()?;
let known = self.builtin_page_names();
Ok(entries
.into_iter()
.filter(|(_, action)| match action.strip_prefix("page:") {
Some(name) => pages.iter().any(|p| p == name) || !known.iter().any(|k| k == name),
None => true,
})
.collect())
}
}
#[cfg(test)]
pub fn load_git_page_config() -> Result<LoadedPageConfig> {
Config::builtin().git_page()
}
#[cfg(test)]
pub fn load_github_page_config() -> Result<LoadedPageConfig> {
Config::builtin().github_page()
}
#[cfg(test)]
pub fn load_app_entries() -> Vec<(String, String)> {
Config::builtin()
.app_entries()
.expect("default.kdl app block is always valid")
}
fn page_children<'a>(doc: &'a KdlDocument, page_name: &str) -> Result<&'a KdlDocument> {
doc.nodes()
.iter()
.filter(|n| n.name().value() == "page")
.find(|n| n.get(0usize).and_then(|v| v.as_string()) == Some(page_name))
.ok_or_else(|| anyhow!("page {page_name:?} not found"))?
.children()
.ok_or_else(|| anyhow!("page {page_name:?} has no children block"))
}
fn layout_block<'a>(
children: &'a KdlDocument,
page_name: &str,
) -> Result<(&'a KdlDocument, &'a KdlNode)> {
let layout_doc = children
.nodes()
.iter()
.find(|n| n.name().value() == "layout")
.and_then(|n| n.children())
.ok_or_else(|| anyhow!("page {page_name:?} missing layout block"))?;
let root = layout_doc
.nodes()
.first()
.ok_or_else(|| anyhow!("page {page_name:?} layout is empty"))?;
Ok((layout_doc, root))
}
fn load_page_from_doc(
doc: &KdlDocument,
default_doc: &KdlDocument,
page_name: &str,
) -> Result<LoadedPageConfig> {
let children = page_children(doc, page_name)?;
let (layout_doc, layout_root) = layout_block(children, page_name)?;
let default_children = page_children(default_doc, page_name)?;
let pane_ids = build_pane_ids(default_children);
let name_map: HashMap<&str, usize> = pane_ids
.iter()
.map(|(name, id)| (name.as_str(), *id))
.collect();
let mut slot_id_counter: usize = 0;
let mut slot_name_to_id: HashMap<String, usize> = HashMap::new();
let mut slot_rules: Vec<SlotRule> = Vec::new();
let root = parse_layout_root(
layout_doc,
&name_map,
&mut slot_id_counter,
&mut slot_name_to_id,
&mut slot_rules,
page_name,
)?;
let mut placed_ids = Vec::new();
for (name, count) in placement_counts(layout_root) {
let id = *name_map
.get(name.as_str())
.ok_or_else(|| anyhow!("page {page_name:?}: unknown pane in layout: {name:?}"))?;
if count > 1 {
return Err(anyhow!(
"page {page_name:?}: layout places pane {name:?} more than once"
));
}
placed_ids.push(id);
}
if placed_ids.is_empty() {
return Err(anyhow!("page {page_name:?}: layout places no pane"));
}
let tab_panes = parse_tabs(children, &name_map, &placed_ids, page_name)?;
let pane_keys = parse_all_pane_keys(children, page_name)?;
let bindings = parse_bindings(children, &name_map, page_name)?;
Ok(LoadedPageConfig {
name: page_name.to_string(),
layout: PageLayoutConfig {
tree: root,
tab_panes,
slot_rules,
},
pane_keys,
pane_ids,
placed_ids,
bindings,
source: None,
})
}
fn placement_counts(root: &KdlNode) -> Vec<(String, usize)> {
fn bump(out: &mut Vec<(String, usize)>, name: &str) {
match out.iter_mut().find(|(n, _)| n == name) {
Some((_, c)) => *c += 1,
None => out.push((name.to_string(), 1)),
}
}
fn walk(node: &KdlNode, out: &mut Vec<(String, usize)>) {
match node.name().value() {
"place" => {
if let Some(name) = node.get(0usize).and_then(|v| v.as_string()) {
bump(out, name);
}
}
"slot" => {
let mut shown: Vec<&str> = Vec::new();
shown.extend(node.get("then").and_then(|v| v.as_string()));
shown.extend(node.get("default").and_then(|v| v.as_string()));
if let Some(children) = node.children() {
for child in children.nodes() {
if child.name().value() == "when" {
shown.extend(child.get("then").and_then(|v| v.as_string()));
}
}
}
let mut seen: Vec<&str> = Vec::new();
for name in shown {
if !seen.contains(&name) {
seen.push(name);
bump(out, name);
}
}
}
"split" => {
if let Some(children) = node.children() {
for child in children.nodes() {
walk(child, out);
}
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(root, &mut out);
out
}
fn build_pane_ids(page_children: &KdlDocument) -> Vec<(String, usize)> {
let mut result = Vec::new();
let mut id = 0usize;
for node in page_children.nodes() {
if node.name().value() == "pane" {
if let Some(name) = node.get(0usize).and_then(|v| v.as_string()) {
if name != VIEW_PANE {
result.push((name.to_string(), id));
id += 1;
}
}
}
}
result
}
fn parse_bindings(
page_children: &KdlDocument,
name_map: &HashMap<&str, usize>,
page: &str,
) -> Result<Vec<(String, String)>> {
page_children
.nodes()
.iter()
.filter(|n| n.name().value() == "bind")
.map(|n| {
let select = n
.get("select")
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("bind in page {page:?} missing select= attribute"))?
.to_string();
let detail = n
.get("detail")
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("bind in page {page:?} missing detail= attribute"))?
.to_string();
if !name_map.contains_key(select.as_str()) {
return Err(anyhow!(
"bind select={select:?} in page {page:?}: pane not found"
));
}
if !name_map.contains_key(detail.as_str()) {
return Err(anyhow!(
"bind detail={detail:?} in page {page:?}: pane not found"
));
}
Ok((select, detail))
})
.collect()
}
fn parse_tabs(
doc: &KdlDocument,
name_map: &HashMap<&str, usize>,
placed_ids: &[usize],
page: &str,
) -> Result<Vec<usize>> {
let tabs_node = doc
.nodes()
.iter()
.find(|n| n.name().value() == "tabs")
.ok_or_else(|| anyhow!("page {page:?} missing tabs node"))?;
let ids: Vec<usize> = tabs_node
.entries()
.iter()
.filter(|e| e.name().is_none()) .map(|e| {
let pane_name = e
.value()
.as_string()
.ok_or_else(|| anyhow!("tabs entry is not a string"))?;
name_map
.get(pane_name)
.copied()
.ok_or_else(|| anyhow!("unknown pane in tabs: {pane_name:?}"))
})
.collect::<Result<_>>()?;
Ok(ids
.into_iter()
.filter(|id| placed_ids.contains(id))
.collect())
}
fn parse_layout_root(
doc: &KdlDocument,
name_map: &HashMap<&str, usize>,
slot_id_counter: &mut usize,
slot_name_to_id: &mut HashMap<String, usize>,
slot_rules: &mut Vec<SlotRule>,
page: &str,
) -> Result<LayoutNode> {
let root_node = doc
.nodes()
.first()
.ok_or_else(|| anyhow!("page {page:?} layout is empty"))?;
parse_layout_element(
root_node,
name_map,
slot_id_counter,
slot_name_to_id,
slot_rules,
)
}
fn parse_layout_element(
node: &KdlNode,
name_map: &HashMap<&str, usize>,
slot_id_counter: &mut usize,
slot_name_to_id: &mut HashMap<String, usize>,
slot_rules: &mut Vec<SlotRule>,
) -> Result<LayoutNode> {
match node.name().value() {
"split" => parse_split(node, name_map, slot_id_counter, slot_name_to_id, slot_rules),
"place" => parse_place(node, name_map),
"slot" => parse_slot(node, name_map, slot_id_counter, slot_name_to_id, slot_rules),
other => Err(anyhow!("unknown layout element: {other:?}")),
}
}
fn parse_split(
node: &KdlNode,
name_map: &HashMap<&str, usize>,
slot_id_counter: &mut usize,
slot_name_to_id: &mut HashMap<String, usize>,
slot_rules: &mut Vec<SlotRule>,
) -> Result<LayoutNode> {
let dir_str = node
.get("direction")
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("split missing direction="))?;
let direction = match dir_str {
"horizontal" => SplitDirection::Horizontal,
"vertical" => SplitDirection::Vertical,
d => return Err(anyhow!("unknown split direction: {d:?}")),
};
let child_doc = node
.children()
.ok_or_else(|| anyhow!("split has no children block"))?;
let mut children = Vec::new();
for child_node in child_doc.nodes() {
let size_str = child_node
.get("size")
.and_then(|v| v.as_string())
.unwrap_or("min:0");
let constraint =
parse_constraint(size_str).map_err(|e| anyhow!("split child size error: {e}"))?;
let child_layout = parse_layout_element(
child_node,
name_map,
slot_id_counter,
slot_name_to_id,
slot_rules,
)?;
children.push((constraint, child_layout));
}
Ok(LayoutNode::Split {
direction,
children,
})
}
fn parse_place(node: &KdlNode, name_map: &HashMap<&str, usize>) -> Result<LayoutNode> {
let pane_name = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("place missing pane name argument"))?;
let pane_id = *name_map
.get(pane_name)
.ok_or_else(|| anyhow!("unknown pane in place: {pane_name:?}"))?;
Ok(LayoutNode::Pane(pane_id))
}
fn parse_slot(
node: &KdlNode,
name_map: &HashMap<&str, usize>,
slot_id_counter: &mut usize,
slot_name_to_id: &mut HashMap<String, usize>,
slot_rules: &mut Vec<SlotRule>,
) -> Result<LayoutNode> {
let slot_name = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("slot missing name argument"))?
.to_string();
let slot_id = *slot_name_to_id.entry(slot_name.clone()).or_insert_with(|| {
let id = *slot_id_counter;
*slot_id_counter += 1;
id
});
let default_name = node
.get("default")
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("slot {slot_name:?} missing default="))?;
let default_pane = *name_map
.get(default_name)
.ok_or_else(|| anyhow!("unknown pane in slot default=: {default_name:?}"))?;
let resolve_pane = |pane_name: &str, what: &str| -> Result<usize> {
name_map
.get(pane_name)
.copied()
.ok_or_else(|| anyhow!("unknown pane in {what}: {pane_name:?}"))
};
let trigger_list = |entries: &[kdl::KdlEntry], what: &str| -> Result<Vec<usize>> {
entries
.iter()
.filter(|e| e.name().is_none())
.map(|e| {
let pane_name = e
.value()
.as_string()
.ok_or_else(|| anyhow!("{what} entry is not a string"))?;
resolve_pane(pane_name, what)
})
.collect()
};
let children: Vec<&KdlNode> = node
.children()
.map(|d| d.nodes().iter().collect())
.unwrap_or_default();
let mut cases = Vec::new();
if let Some(then_name) = node.get("then").and_then(|v| v.as_string()) {
let then_pane = resolve_pane(then_name, "slot then=")?;
let trigger_panes = children
.iter()
.find(|n| n.name().value() == "triggers")
.map(|n| trigger_list(n.entries(), "triggers"))
.transpose()?
.unwrap_or_default();
cases.push(SlotCase {
trigger_panes,
then_pane,
});
}
for child in children.iter().filter(|n| n.name().value() == "when") {
let then_name = child
.get("then")
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("slot {slot_name:?}: when missing then="))?;
let then_pane = resolve_pane(then_name, "when then=")?;
let trigger_panes = trigger_list(child.entries(), "when")?;
if trigger_panes.is_empty() {
return Err(anyhow!(
"slot {slot_name:?}: when then={then_name:?} lists no trigger panes"
));
}
cases.push(SlotCase {
trigger_panes,
then_pane,
});
}
if cases.is_empty() {
return Err(anyhow!(
"slot {slot_name:?} needs then= (with a triggers child) or at least one when child"
));
}
slot_rules.push(SlotRule {
slot_id,
cases,
default_pane,
});
Ok(LayoutNode::Slot(slot_id))
}
fn parse_all_pane_keys(
page_children: &KdlDocument,
page: &str,
) -> Result<HashMap<String, Vec<KeymapEntry>>> {
let mut result = HashMap::new();
for node in page_children.nodes() {
if node.name().value() == "pane" {
let pane_name = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("pane node in page {page:?} missing name argument"))?
.to_string();
let keys = parse_pane_keys(node, &pane_name)?;
result.insert(pane_name, keys);
}
}
Ok(result)
}
fn parse_pane_keys(pane_node: &KdlNode, pane_name: &str) -> Result<Vec<KeymapEntry>> {
let Some(pane_children) = pane_node.children() else {
return Ok(Vec::new());
};
let Some(keys_children) = pane_children
.nodes()
.iter()
.find(|n| n.name().value() == "keys")
.and_then(|n| n.children())
else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
for node in keys_children.nodes() {
match node.name().value() {
"preset" => {
let preset_name = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| anyhow!("preset in pane {pane_name:?} missing name argument"))?
.to_string();
entries.push(KeymapEntry::Preset(preset_name));
}
key_str => {
let action_str = node
.get(0usize)
.and_then(|v| v.as_string())
.ok_or_else(|| {
anyhow!("key {key_str:?} in pane {pane_name:?} missing action argument")
})?
.to_string();
entries.push(KeymapEntry::Binding {
key: key_str.to_string(),
action: action_str,
});
}
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_git_page() {
let cfg = load_git_page_config().unwrap();
assert_eq!(cfg.name, "git");
}
#[test]
fn load_github_page() {
let cfg = load_github_page_config().unwrap();
assert_eq!(cfg.name, "github");
}
#[test]
fn app_entries_populated() {
let entries = load_app_entries();
assert!(!entries.is_empty());
let has_quit = entries.iter().any(|(_, a)| a == "Quit");
assert!(has_quit, "app entries should contain Quit");
}
#[test]
fn git_pane_keys_present() {
let cfg = load_git_page_config().unwrap();
for name in &[
"view",
"file_tree",
"branch_list",
"git_log",
"reflog",
"diff_view",
] {
assert!(
cfg.pane_keys.contains_key(*name),
"missing pane keys for {name}"
);
}
}
#[test]
fn github_pane_keys_present() {
let cfg = load_github_page_config().unwrap();
for name in &[
"view",
"issue_list",
"pr_list",
"run_list",
"issue_detail",
"pr_detail",
"run_detail",
] {
assert!(
cfg.pane_keys.contains_key(*name),
"missing pane keys for {name}"
);
}
}
#[test]
fn git_pane_ids_correct() {
let cfg = load_git_page_config().unwrap();
let expected = [
("file_tree", 0usize),
("branch_list", 1),
("git_log", 2),
("reflog", 3),
("diff_view", 4),
];
for (name, id) in &expected {
assert_eq!(
cfg.resolve_id(name),
Some(*id),
"pane {name:?} should have id {id}"
);
}
}
#[test]
fn files_page_ids_keys_and_bindings() {
let cfg = Config::builtin().files_page().unwrap();
assert_eq!(cfg.name, "files");
assert_eq!(cfg.resolve_id("parent_dir"), Some(0));
assert_eq!(cfg.resolve_id("dir_list"), Some(1));
assert_eq!(cfg.resolve_id("preview"), Some(2));
assert_eq!(cfg.layout.tab_panes, vec![1, 2]);
assert_eq!(
cfg.bindings,
vec![("dir_list".to_string(), "preview".to_string())]
);
for name in ["view", "parent_dir", "dir_list", "preview"] {
assert!(
cfg.pane_keys.contains_key(name),
"missing pane keys for {name}"
);
}
assert!(cfg.pane_keys["parent_dir"].is_empty());
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.contains(&("3".to_string(), "page:files".to_string())));
}
#[test]
fn docker_page_ids_keys_and_bindings() {
let cfg = Config::builtin().docker_page().unwrap();
assert_eq!(cfg.name, "docker");
assert_eq!(cfg.resolve_id("containers"), Some(0));
assert_eq!(cfg.resolve_id("images"), Some(1));
assert_eq!(cfg.resolve_id("detail"), Some(2));
assert_eq!(cfg.resolve_id("logs"), Some(3));
assert_eq!(cfg.layout.tab_panes, vec![0, 1, 2, 3]);
assert_eq!(
cfg.bindings,
vec![
("containers".to_string(), "detail".to_string()),
("images".to_string(), "detail".to_string()),
]
);
for name in ["view", "containers", "images", "detail", "logs"] {
assert!(
cfg.pane_keys.contains_key(name),
"missing pane keys for {name}"
);
}
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.contains(&("4".to_string(), "page:docker".to_string())));
}
#[test]
fn projects_page_ids_keys_and_bindings() {
let cfg = Config::builtin().projects_page().unwrap();
assert_eq!(cfg.name, "projects");
assert_eq!(cfg.resolve_id("projects"), Some(0));
assert_eq!(cfg.resolve_id("board"), Some(1));
assert_eq!(cfg.resolve_id("detail"), Some(2));
assert_eq!(cfg.placed_ids, vec![1, 2]);
assert!(!cfg.is_placed("projects"));
assert!(cfg.is_placed("board"));
assert!(!cfg.is_placed("nope"));
assert_eq!(cfg.layout.tab_panes, vec![1, 2]);
assert_eq!(
cfg.bindings,
vec![
("projects".to_string(), "board".to_string()),
("board".to_string(), "detail".to_string()),
]
);
let select = cfg.resolve_select_bindings();
assert_eq!(select.len(), 1);
assert_eq!(select.get(&1), Some(&2));
for name in ["view", "projects", "board", "detail"] {
assert!(
cfg.pane_keys.contains_key(name),
"missing pane keys for {name}"
);
}
let board = format!("{:?}", cfg.pane_keys["board"]);
for (k, a) in [
("t", "ToggleTable"),
("s", "CycleSort"),
("h", "PrevColumn"),
] {
assert!(
board.contains(&format!("key: {k:?}, action: {a:?}")),
"board pane binds {k} to {a}: {board}"
);
}
let view = format!("{:?}", cfg.pane_keys["view"]);
for (k, a) in [("p", "NextProject"), ("P", "PrevProject")] {
assert!(
view.contains(&format!("key: {k:?}, action: {a:?}")),
"view binds {k} to {a}: {view}"
);
}
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.contains(&("7".to_string(), "page:projects".to_string())));
}
#[test]
fn user_layout_can_place_the_projects_list_pane() {
let cfg = user(
r#"page "projects" {
layout {
split direction="horizontal" {
place "projects" size="22%"
split direction="vertical" size="min:30" {
place "board" size="60%"
place "detail" size="min:5"
}
}
}
tabs "projects" "board" "detail"
}"#,
)
.unwrap();
let page = cfg.projects_page().unwrap();
let default = Config::builtin().projects_page().unwrap();
assert_eq!(page.pane_ids, default.pane_ids, "IDs must not change");
assert_eq!(page.placed_ids, vec![0, 1, 2]);
assert!(page.is_placed("projects"));
assert_eq!(page.layout.tab_panes, vec![0, 1, 2]);
let select = page.resolve_select_bindings();
assert_eq!(select.get(&0), Some(&1));
assert_eq!(select.get(&1), Some(&2));
}
#[test]
fn procs_page_ids_keys_and_bindings() {
let cfg = Config::builtin().procs_page().unwrap();
assert_eq!(cfg.name, "procs");
assert_eq!(cfg.resolve_id("processes"), Some(0));
assert_eq!(cfg.resolve_id("ports"), Some(1));
assert_eq!(cfg.resolve_id("detail"), Some(2));
assert_eq!(cfg.resolve_id("graphs"), Some(3));
assert_eq!(cfg.layout.tab_panes, vec![0, 1, 2, 3]);
assert_eq!(
cfg.bindings,
vec![("processes".to_string(), "detail".to_string())]
);
for name in ["view", "processes", "ports", "detail", "graphs"] {
assert!(
cfg.pane_keys.contains_key(name),
"missing pane keys for {name}"
);
}
for pane in ["processes", "graphs"] {
let keys = format!("{:?}", cfg.pane_keys[pane]);
assert!(
keys.contains("key: \"c\", action: \"TogglePerCore\""),
"pane {pane} binds c to TogglePerCore: {keys}"
);
}
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.contains(&("5".to_string(), "page:procs".to_string())));
}
#[test]
fn procs_refresh_interval_default_override_and_validation() {
assert_eq!(
Config::builtin().procs_refresh_interval().unwrap(),
Duration::from_secs(2)
);
assert_eq!(
user(r#"procs-refresh-interval "500ms""#)
.unwrap()
.procs_refresh_interval()
.unwrap(),
Duration::from_millis(500)
);
assert_eq!(
user(r#"procs-refresh-interval "1.5s""#)
.unwrap()
.procs_refresh_interval()
.unwrap(),
Duration::from_millis(1500)
);
for bad in [
r#"procs-refresh-interval "fast""#,
r#"procs-refresh-interval "100ms""#,
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(msg.contains("procs-refresh-interval"), "{bad}: {msg}");
}
assert_eq!(parse_interval("2s"), Some(Duration::from_secs(2)));
assert_eq!(parse_interval(" 250ms "), Some(Duration::from_millis(250)));
assert_eq!(parse_interval("0s"), None);
assert_eq!(parse_interval("-1s"), None);
assert_eq!(parse_interval("2"), None);
}
#[test]
fn github_poll_interval_default_override_and_validation() {
assert_eq!(
Config::builtin().github_poll_interval().unwrap(),
Duration::from_secs(5)
);
assert_eq!(
user(r#"github-poll-interval "2s""#)
.unwrap()
.github_poll_interval()
.unwrap(),
Duration::from_secs(2)
);
assert_eq!(
user(r#"github-poll-interval "7.5s""#)
.unwrap()
.github_poll_interval()
.unwrap(),
Duration::from_millis(7500)
);
for bad in [
r#"github-poll-interval "fast""#,
r#"github-poll-interval "1s""#,
r#"github-poll-interval "1999ms""#,
r#"github-poll-interval "5""#,
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(msg.contains("github-poll-interval"), "{bad}: {msg}");
}
}
#[test]
fn procs_history_default_override_and_validation() {
assert_eq!(Config::builtin().procs_history().unwrap(), 120);
assert_eq!(
user(r#"procs-history "300""#)
.unwrap()
.procs_history()
.unwrap(),
300
);
assert_eq!(
user(r#"procs-history "10""#)
.unwrap()
.procs_history()
.unwrap(),
10
);
assert_eq!(
user(r#"procs-history "10000""#)
.unwrap()
.procs_history()
.unwrap(),
10000
);
for bad in [
r#"procs-history "9""#,
r#"procs-history "10001""#,
r#"procs-history "many""#,
r#"procs-history "-5""#,
r#"procs-history "0""#,
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(msg.contains("procs-history"), "{bad}: {msg}");
}
}
#[test]
fn github_page_runs_column_ids_keys_and_bindings() {
let cfg = Config::builtin().github_page().unwrap();
assert_eq!(cfg.resolve_id("run_list"), Some(2));
assert_eq!(cfg.resolve_id("run_detail"), Some(5));
assert_eq!(cfg.layout.tab_panes, vec![0, 1, 2]);
assert_eq!(
cfg.bindings,
vec![
("issue_list".to_string(), "issue_detail".to_string()),
("pr_list".to_string(), "pr_detail".to_string()),
("run_list".to_string(), "run_detail".to_string()),
]
);
assert_eq!(cfg.layout.slot_rules.len(), 1);
let rule = &cfg.layout.slot_rules[0];
assert_eq!(rule.default_pane, 3, "issue_detail by default");
assert_eq!(rule.resolve(1), 4, "pr_list focus shows pr_detail");
assert_eq!(rule.resolve(4), 4);
assert_eq!(rule.resolve(2), 5, "run_list focus shows run_detail");
assert_eq!(rule.resolve(5), 5);
assert_eq!(rule.resolve(0), 3);
assert!(Config::builtin()
.builtin_page_names()
.iter()
.all(|p| p != "actions"));
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.iter().all(|(_, a)| a != "page:actions"));
}
#[test]
fn removed_actions_page_fails_fast_with_a_hint() {
for bad in [
r#"pages "git" "actions""#,
r#"app { "6" "page:actions" }"#,
r#"pages "git" "github"; app { "a" "page:actions" }"#,
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(
msg.contains("folded into the \"github\" page"),
"{bad}: {msg}"
);
assert!(msg.contains("v0.8.0"), "{bad}: {msg}");
assert!(
msg.contains("remove it from pages / app bindings"),
"{bad}: {msg}"
);
}
let msg = format!(
"{:#}",
user(r#"page "actions" { }"#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
}
#[test]
fn worktrees_page_ids_keys_and_bindings() {
let cfg = Config::builtin().worktrees_page().unwrap();
assert_eq!(cfg.name, "worktrees");
assert_eq!(cfg.resolve_id("worktrees"), Some(0));
assert_eq!(cfg.resolve_id("stashes"), Some(1));
assert_eq!(cfg.resolve_id("preview"), Some(2));
assert_eq!(cfg.layout.tab_panes, vec![0, 1, 2]);
assert_eq!(
cfg.bindings,
vec![
("worktrees".to_string(), "preview".to_string()),
("stashes".to_string(), "preview".to_string()),
]
);
let select = cfg.resolve_select_bindings();
assert_eq!(select.get(&0), Some(&2));
assert_eq!(select.get(&1), Some(&2));
for name in ["view", "worktrees", "stashes", "preview"] {
assert!(
cfg.pane_keys.contains_key(name),
"missing pane keys for {name}"
);
}
let entries = Config::builtin().app_entries().unwrap();
assert!(entries.contains(&("6".to_string(), "page:worktrees".to_string())));
}
#[test]
fn github_pane_ids_correct() {
let cfg = load_github_page_config().unwrap();
let expected = [
("issue_list", 0usize),
("pr_list", 1),
("run_list", 2),
("issue_detail", 3),
("pr_detail", 4),
("run_detail", 5),
];
for (name, id) in &expected {
assert_eq!(
cfg.resolve_id(name),
Some(*id),
"pane {name:?} should have id {id}"
);
}
}
#[test]
fn git_bindings_correct() {
let cfg = load_git_page_config().unwrap();
assert!(
cfg.bindings
.contains(&("file_tree".to_string(), "diff_view".to_string())),
"missing bind file_tree→diff_view"
);
assert!(
cfg.bindings
.contains(&("branch_list".to_string(), "git_log".to_string())),
"missing bind branch_list→git_log"
);
}
#[test]
fn github_bindings_correct() {
let cfg = load_github_page_config().unwrap();
assert!(
cfg.bindings
.contains(&("issue_list".to_string(), "issue_detail".to_string())),
"missing bind issue_list→issue_detail"
);
assert!(
cfg.bindings
.contains(&("pr_list".to_string(), "pr_detail".to_string())),
"missing bind pr_list→pr_detail"
);
}
fn user(kdl: &str) -> Result<Config> {
let doc: KdlDocument = kdl.parse().unwrap();
Config::with_user(&doc, PathBuf::from("/u/config.kdl"))
}
fn repo_over(user_kdl: &str, repo_kdl: &str) -> (Config, Result<Config>) {
let cfg = user(user_kdl).unwrap();
let doc: KdlDocument = repo_kdl.parse().unwrap();
let merged = cfg.with_repo_layer(&doc, PathBuf::from("/w/.vig.kdl"));
(cfg, merged)
}
#[test]
fn repo_layer_overrides_user() {
let (_, merged) = repo_over(
"theme \"Solarized (dark)\"\npages \"git\" \"files\"",
"theme \"InspiredGitHub\"\npages \"git\"",
);
let merged = merged.unwrap();
assert_eq!(merged.theme().unwrap(), "InspiredGitHub");
assert_eq!(merged.pages().unwrap(), vec!["git".to_string()]);
}
#[test]
fn user_layer_survives_where_repo_is_silent() {
let (_, merged) = repo_over(r#"theme "Solarized (dark)""#, r#"pages "git""#);
assert_eq!(merged.unwrap().theme().unwrap(), "Solarized (dark)");
}
#[test]
fn repo_layer_error_names_the_repo_file_and_leaves_base_usable() {
let (base, merged) = repo_over(r#"theme "Solarized (dark)""#, r#"theme "nope""#);
let err = format!("{:#}", merged.unwrap_err());
assert!(err.contains("/w/.vig.kdl"), "{err}");
assert_eq!(base.theme().unwrap(), "Solarized (dark)");
}
#[test]
fn repo_layer_rejects_repo_config_node() {
for repo_kdl in [r#"repo-config "off""#, r#"repo-config "on""#] {
let (base, merged) = repo_over(r#"theme "Solarized (dark)""#, repo_kdl);
let err = format!("{:#}", merged.unwrap_err());
assert!(err.contains("only be set in the user config"), "{err}");
assert!(err.contains("/w/.vig.kdl"), "{err}");
assert_eq!(base.theme().unwrap(), "Solarized (dark)");
}
}
#[test]
fn repo_config_kill_switch_parses_and_validates() {
assert!(user(r#"theme "InspiredGitHub""#)
.unwrap()
.repo_config()
.unwrap());
assert!(user(r#"repo-config "on""#).unwrap().repo_config().unwrap());
assert!(!user(r#"repo-config "off""#).unwrap().repo_config().unwrap());
let err = user(r#"repo-config "sometimes""#).unwrap_err().to_string();
assert!(err.contains("repo-config"), "{err}");
}
fn key(s: &str) -> crossterm::event::KeyEvent {
let ki: crate::core::keymap::KeyInput = s.parse().unwrap();
crossterm::event::KeyEvent::new(ki.code, ki.modifiers)
}
#[test]
fn user_overrides_and_unbinds_pane_keys() {
use crate::git::panes::file_tree::FileTreeAction;
let cfg = user(
r#"page "git" { pane "file_tree" { keys { "Space" "ExpandOrOpen"; "i" "None"; "x" "ToggleDir" } } }"#,
)
.unwrap();
let km = cfg
.git_page()
.unwrap()
.keymap::<FileTreeAction>("file_tree")
.unwrap();
assert!(matches!(
km.lookup(key("Space")),
Some(FileTreeAction::ExpandOrOpen)
));
assert!(km.lookup(key("i")).is_none(), "i should be unbound");
assert!(matches!(
km.lookup(key("x")),
Some(FileTreeAction::ToggleDir)
));
assert!(km.lookup(key("j")).is_some());
assert!(matches!(
km.lookup(key("Enter")),
Some(FileTreeAction::ExpandOrOpen)
));
let default = Config::builtin().git_page().unwrap();
let merged = cfg.git_page().unwrap();
assert_eq!(
format!("{:?}", default.pane_keys["branch_list"]),
format!("{:?}", merged.pane_keys["branch_list"])
);
}
#[test]
fn user_app_keys_merge() {
let cfg = user(r#"app { "3" "page:github"; "1" "None" }"#).unwrap();
let entries = cfg.app_entries().unwrap();
let pages = cfg.pages().unwrap();
let names: Vec<&str> = pages.iter().map(String::as_str).collect();
let km = crate::core::keymap::build_app_keymap(&entries, &names).unwrap();
assert!(km.lookup(key("1")).is_none());
assert!(matches!(
km.lookup(key("2")),
Some(crate::core::keymap::AppAction::SwitchPage(1))
));
assert!(matches!(
km.lookup(key("3")),
Some(crate::core::keymap::AppAction::SwitchPage(1))
));
assert!(matches!(
km.lookup(key("Ctrl+c")),
Some(crate::core::keymap::AppAction::Quit)
));
}
const ALL_PAGES: [&str; 7] = [
"git",
"github",
"files",
"docker",
"procs",
"worktrees",
"projects",
];
#[test]
fn builtin_pages_list_every_page_in_declaration_order() {
let cfg = Config::builtin();
assert_eq!(cfg.pages().unwrap(), ALL_PAGES);
assert_eq!(
cfg.builtin_page_names(),
ALL_PAGES,
"the `pages` node must list every `page` block, in order"
);
assert_eq!(
user("theme \"base16-eighties.dark\"")
.unwrap()
.pages()
.unwrap(),
ALL_PAGES
);
}
#[test]
fn user_pages_reorder_and_disable() {
let cfg = user(r#"pages "files" "git""#).unwrap();
assert_eq!(cfg.pages().unwrap(), ["files", "git"]);
let entries = cfg.app_entries().unwrap();
let page_actions: Vec<&str> = entries
.iter()
.filter_map(|(_, a)| a.strip_prefix("page:"))
.collect();
assert_eq!(page_actions, ["git", "files"]);
let pages = cfg.pages().unwrap();
let names: Vec<&str> = pages.iter().map(String::as_str).collect();
let km = crate::core::keymap::build_app_keymap(&entries, &names).unwrap();
assert!(matches!(
km.lookup(key("1")),
Some(crate::core::keymap::AppAction::SwitchPage(1))
));
assert!(matches!(
km.lookup(key("3")),
Some(crate::core::keymap::AppAction::SwitchPage(0))
));
assert!(km.lookup(key("2")).is_none(), "github is disabled");
}
#[test]
fn user_pages_errors_mention_file_and_page() {
let msg = format!(
"{:#}",
user(r#"pages "git" "nope""#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("unknown page \"nope\""), "{msg}");
assert!(
msg.contains("worktrees"),
"{msg} should list the known pages"
);
let msg = format!(
"{:#}",
user(r#"pages "git" "files" "git""#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("\"git\" listed twice"), "{msg}");
let msg = format!("{:#}", user(r#"pages"#).expect_err("expected an error"));
assert!(msg.contains("at least one page"), "{msg}");
let msg = format!(
"{:#}",
user(r#"pages git=1"#).expect_err("expected an error")
);
assert!(msg.contains("string arguments"), "{msg}");
}
#[test]
fn user_binding_to_disabled_or_unknown_page_fails() {
let msg = format!(
"{:#}",
user(r#"pages "git" "files"; app { "w" "page:worktrees" }"#)
.expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("\"w\" \"page:worktrees\""), "{msg}");
assert!(msg.contains("not listed in `pages`"), "{msg}");
let msg = format!(
"{:#}",
user(r#"app { "x" "page:nope" }"#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("unknown page \"nope\""), "{msg}");
let cfg = user(r#"pages "git" "worktrees"; app { "w" "page:worktrees" }"#).unwrap();
assert_eq!(cfg.pages().unwrap(), ["git", "worktrees"]);
}
#[test]
fn user_layout_replaces_without_renumbering_panes() {
let cfg = user(
r#"page "git" {
layout {
split direction="horizontal" {
split direction="vertical" size="30%" {
place "file_tree"
place "branch_list"
place "reflog"
}
slot "main" then="git_log" default="diff_view" {
triggers "branch_list" "reflog" "git_log"
}
}
}
tabs "branch_list" "file_tree" "reflog"
}"#,
)
.unwrap();
let page = cfg.git_page().unwrap();
let default = Config::builtin().git_page().unwrap();
assert_eq!(page.pane_ids, default.pane_ids, "IDs must not change");
assert_eq!(page.bindings, default.bindings, "bind left untouched");
assert!(matches!(
page.layout.tree,
LayoutNode::Split {
direction: SplitDirection::Horizontal,
..
}
));
assert_eq!(page.layout.tab_panes, vec![1, 0, 3]);
}
#[test]
fn user_slot_accepts_single_and_multi_case_forms() {
let cfg = user(
r#"page "github" {
layout {
split direction="vertical" {
split direction="horizontal" size="40%" {
place "issue_list"; place "pr_list"; place "run_list"
}
slot "detail" then="pr_detail" default="issue_detail" {
triggers "pr_list" "pr_detail" "run_list" "run_detail"
}
}
}
}"#,
)
.unwrap();
let rule = &cfg.github_page().unwrap().layout.slot_rules[0];
assert_eq!(rule.cases.len(), 1);
assert_eq!(rule.resolve(2), 4);
assert_eq!(rule.resolve(0), 3);
let cfg = user(
r#"page "github" {
layout {
split direction="vertical" {
split direction="horizontal" size="40%" {
place "issue_list"; place "pr_list"; place "run_list"
}
slot "detail" default="run_detail" {
when "issue_list" "issue_detail" then="issue_detail"
when "pr_list" "pr_detail" then="pr_detail"
}
}
}
}"#,
)
.unwrap();
let rule = &cfg.github_page().unwrap().layout.slot_rules[0];
assert_eq!(rule.cases.len(), 2);
assert_eq!(rule.resolve(0), 3);
assert_eq!(rule.resolve(1), 4);
assert_eq!(rule.resolve(2), 5, "default");
for (bad, expect) in [
(r#"slot "detail" default="issue_detail" { }"#, "needs then="),
(
r#"slot "detail" default="issue_detail" { when then="pr_detail" }"#,
"lists no trigger panes",
),
(
r#"slot "detail" default="issue_detail" { when "pr_list" }"#,
"when missing then=",
),
(
r#"slot "detail" default="issue_detail" { when "nope" then="pr_detail" }"#,
"unknown pane in when",
),
] {
let kdl = format!(
r#"page "github" {{ layout {{ split direction="vertical" {{
split direction="horizontal" {{
place "issue_list"; place "pr_list"; place "run_list"
place "pr_detail"; place "run_detail"
}}
{bad}
}} }} }}"#
);
let msg = format!("{:#}", user(&kdl).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(msg.contains(expect), "{bad}: {msg}");
}
}
#[test]
fn user_layout_may_leave_panes_unplaced() {
let cfg = user(
r#"page "git" {
layout { split direction="vertical" { place "file_tree"; place "diff_view" } }
}"#,
)
.unwrap();
let page = cfg.git_page().unwrap();
let default = Config::builtin().git_page().unwrap();
assert_eq!(page.pane_ids, default.pane_ids, "IDs must not change");
assert_eq!(page.placed_ids, vec![0, 4]);
assert!(page.is_placed("file_tree"));
assert!(!page.is_placed("branch_list"));
assert_eq!(
page.layout.tab_panes,
vec![0],
"unplaced panes leave the tab cycle"
);
assert_eq!(page.bindings, default.bindings, "raw bind list is kept");
let select = page.resolve_select_bindings();
assert_eq!(select.get(&0), Some(&4), "file_tree → diff_view stays");
assert!(
!select.contains_key(&1),
"branch_list → git_log is ignored while unplaced"
);
let cfg = user(
r#"page "git" {
layout { place "diff_view" }
bind select="branch_list" detail="git_log"
}"#,
)
.unwrap();
let page = cfg.git_page().unwrap();
assert_eq!(page.placed_ids, vec![4]);
assert!(page.layout.tab_panes.is_empty());
assert!(page.resolve_select_bindings().is_empty());
}
#[test]
fn user_layout_rejects_unknown_repeated_or_no_panes() {
for (bad, expect) in [
(
r#"page "git" { layout { split direction="vertical" { place "file_tree"; place "nope" } } }"#,
"unknown pane in place: \"nope\"",
),
(
r#"page "git" { layout { split direction="vertical" { place "file_tree"; place "file_tree" } } }"#,
"places pane \"file_tree\" more than once",
),
(
r#"page "git" { layout { split direction="vertical" {
place "git_log"
slot "main" then="git_log" default="diff_view" { triggers "branch_list" }
} } }"#,
"places pane \"git_log\" more than once",
),
(
r#"page "git" { tabs "nope" }"#,
"unknown pane in tabs: \"nope\"",
),
(
r#"page "git" { bind select="nope" detail="diff_view" }"#,
"bind select=\"nope\"",
),
(
r#"page "git" { layout { split direction="vertical" { } } }"#,
"places no pane",
),
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
assert!(msg.contains(expect), "{bad}: {msg}");
}
let cfg = user(
r#"page "github" { layout { split direction="vertical" {
split direction="horizontal" { place "issue_list"; place "pr_list"; place "run_list" }
slot "detail" default="issue_detail" {
when "issue_list" then="issue_detail"
when "pr_list" "pr_detail" then="pr_detail"
}
} } }"#,
)
.unwrap();
let page = cfg.github_page().unwrap();
assert_eq!(page.placed_ids, vec![0, 1, 2, 3, 4]);
assert!(!page.is_placed("run_detail"));
}
#[test]
fn user_structural_errors_mention_file() {
for bad in [
r#"page "nope" { }"#,
r#"page "git" { pane "nope" { keys { } } }"#,
r#"page "git" { layout { place "nope" } }"#,
r#"page "git" { tabs "nope" }"#,
r#"page "git" { bind select="file_tree" detail="nope" }"#,
r#"colors { }"#,
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("/u/config.kdl"), "{bad}: {msg}");
}
}
#[test]
fn user_invalid_action_mentions_file_page_and_pane() {
use crate::git::panes::file_tree::FileTreeAction;
let cfg =
user(r#"page "git" { pane "file_tree" { keys { "x" "NoSuchAction" } } }"#).unwrap();
let msg = format!(
"{:#}",
cfg.git_page()
.unwrap()
.keymap::<FileTreeAction>("file_tree")
.err()
.expect("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("\"git\""), "{msg}");
assert!(msg.contains("\"file_tree\""), "{msg}");
assert!(msg.contains("NoSuchAction"), "{msg}");
}
#[test]
fn theme_default_override_and_validation() {
assert_eq!(
Config::builtin().theme().unwrap(),
crate::core::syntax::DEFAULT_THEME
);
let cfg = user(r#"theme "Solarized (dark)""#).unwrap();
assert_eq!(cfg.theme().unwrap(), "Solarized (dark)");
let msg = format!(
"{:#}",
user(r#"theme "nope""#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("unknown theme \"nope\""), "{msg}");
assert!(msg.contains("base16-eighties.dark"), "{msg}");
let msg = format!("{:#}", user(r#"theme"#).expect_err("expected an error"));
assert!(msg.contains("missing name"), "{msg}");
}
#[test]
fn icons_default_override_and_validation() {
assert!(Config::builtin().icons().unwrap());
assert!(!user(r#"icons "none""#).unwrap().icons().unwrap());
let msg = format!(
"{:#}",
user(r#"icons "emoji""#).expect_err("expected an error")
);
assert!(msg.contains("/u/config.kdl"), "{msg}");
assert!(msg.contains("unknown icons mode \"emoji\""), "{msg}");
assert!(msg.contains("nerd, none"), "{msg}");
}
#[test]
fn projects_board_default_override_and_validation() {
assert_eq!(Config::builtin().projects_board().unwrap(), None);
assert_eq!(
user(r#"projects-board "vig demo board""#)
.unwrap()
.projects_board()
.unwrap(),
Some(ProjectsBoard::ByTitle("vig demo board".into()))
);
assert_eq!(
user("projects-board 2").unwrap().projects_board().unwrap(),
Some(ProjectsBoard::ByNumber(2))
);
for bad in [
"projects-board",
r#"projects-board "a" "b""#,
r#"projects-board title="a""#,
r#"projects-board """#,
"projects-board -1",
"projects-board 0",
"projects-board 1.5",
"projects-board #true",
] {
let msg = format!("{:#}", user(bad).expect_err("expected an error"));
assert!(msg.contains("bad projects-board"), "{bad}: {msg}");
assert!(msg.contains("config file /u/config.kdl"), "{bad}: {msg}");
}
}
#[test]
fn image_preview_default_override_and_validation() {
use crate::files::domain::image::ImagePreviewMode;
assert_eq!(
Config::builtin().image_preview().unwrap(),
ImagePreviewMode::Auto
);
assert_eq!(
user(r#"image-preview "none""#)
.unwrap()
.image_preview()
.unwrap(),
ImagePreviewMode::None
);
let msg = format!(
"{:#}",
user(r#"image-preview "sixel""#).expect_err("expected an error")
);
assert!(
msg.contains("unknown image-preview mode \"sixel\""),
"{msg}"
);
assert!(msg.contains("auto, halfblocks, none"), "{msg}");
}
#[test]
fn empty_user_config_equals_builtin() {
let cfg = user("").unwrap();
let a = Config::builtin().git_page().unwrap();
let b = cfg.git_page().unwrap();
assert_eq!(a.pane_ids, b.pane_ids);
assert_eq!(a.bindings, b.bindings);
assert_eq!(a.layout.tab_panes, b.layout.tab_panes);
assert_eq!(cfg.theme().unwrap(), Config::builtin().theme().unwrap());
assert_eq!(a.pane_keys.len(), b.pane_keys.len());
for (pane, keys) in &a.pane_keys {
assert_eq!(
format!("{keys:?}"),
format!("{:?}", b.pane_keys[pane]),
"{pane}"
);
}
}
#[test]
fn github_bindings_distinct_instances() {
let cfg = load_github_page_config().unwrap();
let issue_detail_id = cfg.resolve_id("issue_detail").unwrap();
let pr_detail_id = cfg.resolve_id("pr_detail").unwrap();
assert_ne!(
issue_detail_id, pr_detail_id,
"issue_detail and pr_detail must be distinct instances"
);
let issue_binding = cfg
.bindings
.iter()
.find(|(_, d)| d == "issue_detail")
.expect("issue_detail binding missing");
let pr_binding = cfg
.bindings
.iter()
.find(|(_, d)| d == "pr_detail")
.expect("pr_detail binding missing");
assert_ne!(
issue_binding.0, pr_binding.0,
"issue_detail and pr_detail must be bound to different select panes"
);
}
}