Lines
65.74 %
Functions
43.75 %
Branches
100 %
//! Config tab: curated key→value viewer/editor.
//!
//! No `list-config` native exists; a native enumerating all keys would
//! enable full enumeration. Until then, only the keys in [`KNOWN_CONFIG_KEYS`]
//! are shown.
use cli_core::render::{RenderError, WireValue, parse_wire, reparse_list};
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState};
use scripting::nomiscript::{Value, format_value, list_to_vec};
/// Curated config keys shown in this tab.
pub const KNOWN_CONFIG_KEYS: &[&str] = &["locale", "userregistrytimeout"];
/// Per-key fetch state.
#[derive(Debug, Clone, PartialEq)]
pub enum ConfigCell {
/// A `get-config` request is in flight with this envelope id.
Loading { id: i64 },
/// Reply received; the value is the string from `:config-value`.
Loaded(String),
/// The key has no value set — `get-config` returned `(:config-value nil)`.
Unset,
/// A genuine failure: server error, or an envelope/parse error.
Error(String),
}
pub struct ConfigTab {
pub entries: Vec<(String, ConfigCell)>,
pub selected: usize,
impl ConfigTab {
#[must_use]
pub fn new() -> Self {
Self {
entries: KNOWN_CONFIG_KEYS
.iter()
.map(|k| ((*k).to_string(), ConfigCell::Unset))
.collect(),
selected: 0,
/// All entries are `Unset` — used to decide whether `ensure_tab_loaded`
/// should issue fetches.
pub fn is_idle(&self) -> bool {
self.entries
.all(|(_, c)| matches!(c, ConfigCell::Unset))
/// Mark the entry for `key` as `Loading`.
pub fn set_loading(&mut self, key: &str, id: i64) {
if let Some((_, cell)) = self.entries.iter_mut().find(|(k, _)| k == key) {
*cell = ConfigCell::Loading { id };
/// Process a routed reply for `key`.
///
/// A server error maps to `Unset` (key not set); an envelope or parse
/// error maps to `Error`; a successful reply extracts `:config-value`.
pub fn on_reply(&mut self, key: &str, wire: &str) {
let new_state = parse_config_reply(wire);
*cell = new_state;
/// Reset all entries to `Unset` so the next tab-switch re-fetches.
pub fn reset(&mut self) {
for (_, cell) in &mut self.entries {
*cell = ConfigCell::Unset;
/// Whether any entry is still waiting for a reply.
pub fn any_loading(&self) -> bool {
.any(|(_, c)| matches!(c, ConfigCell::Loading { .. }))
/// Key name at the selected row.
pub fn selected_key(&self) -> Option<&str> {
self.entries.get(self.selected).map(|(k, _)| k.as_str())
/// Current display value at the selected row; empty string if not loaded.
pub fn selected_value(&self) -> &str {
match self.entries.get(self.selected) {
Some((_, ConfigCell::Loaded(v))) => v.as_str(),
_ => "",
pub fn select_next(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
pub fn draw(&self, frame: &mut Frame, area: Rect) {
let block = Block::default().borders(Borders::ALL).title("Config");
let header_style = Style::default().add_modifier(Modifier::BOLD);
let selected_style = Style::default().bg(Color::DarkGray);
let header = Row::new(vec![
Cell::from("Key").style(header_style),
Cell::from("Value").style(header_style),
]);
let rows: Vec<Row> = self
.entries
.enumerate()
.map(|(i, (key, cell))| {
let display: &str = match cell {
ConfigCell::Loaded(v) => v.as_str(),
ConfigCell::Loading { .. } => "(loading\u{2026})",
ConfigCell::Unset => "(unset)",
ConfigCell::Error(e) => e.as_str(),
};
let style = if i == self.selected {
selected_style
} else {
Style::default()
Row::new(vec![Cell::from(key.as_str()), Cell::from(display)]).style(style)
})
.collect();
let widths = [Constraint::Percentage(40), Constraint::Percentage(60)];
let table = Table::new(rows, widths).header(header).block(block);
let mut state = TableState::default().with_selected(Some(self.selected));
frame.render_stateful_widget(table, area, &mut state);
impl Default for ConfigTab {
fn default() -> Self {
Self::new()
/// Build the `(set-config <key> <value>)` s-expression.
pub fn build_set_config_form(key: &str, value: &str) -> String {
use cli_core::eval::escape_str;
format!("(set-config {} {})", escape_str(key), escape_str(value))
fn parse_config_reply(wire: &str) -> ConfigCell {
// An UNSET key is NOT a server error: `get-config` returns
// `(:config-value nil)` for a missing field (rpc config.rs run_get_config).
// A server error therefore means a genuine failure (bad arg / DB / runtime)
// and must surface as such, not be hidden as "(unset)".
match parse_wire(wire) {
Err(RenderError::Server { code, message }) => {
ConfigCell::Error(format!("[{code}] {message}"))
Err(e) => ConfigCell::Error(e.to_string()),
Ok(WireValue::Value(Value::String(ref s))) => extract_config_value(s),
Ok(WireValue::Value(ref other)) => {
ConfigCell::Error(format!("unexpected reply type: {}", format_value(other)))
fn extract_config_value(plist_str: &str) -> ConfigCell {
let parsed = match reparse_list(plist_str) {
Ok(v) => v,
Err(e) => return ConfigCell::Error(format!("parse error: {e}")),
match plist_config_value(&parsed) {
// `(:config-value nil)` is how the native reports a key with no value set.
Some(Value::Nil) => ConfigCell::Unset,
Some(Value::String(s)) => ConfigCell::Loaded(s),
Some(other) => ConfigCell::Loaded(format_value(&other)),
None => ConfigCell::Error(format!("missing :config-value in: {plist_str}")),
fn plist_config_value(value: &Value) -> Option<Value> {
let items = list_to_vec(value)?;
let pos = items
.position(|v| matches!(v, Value::Symbol(s) if s == ":config-value"))?;
items.get(pos + 1).cloned()
#[cfg(test)]
mod tests;