Skip to main content

tui/tabs/config/
mod.rs

1//! Config tab: curated key→value viewer/editor.
2//!
3//! No `list-config` native exists; a native enumerating all keys would
4//! enable full enumeration. Until then, only the keys in [`KNOWN_CONFIG_KEYS`]
5//! are shown.
6
7use cli_core::render::{RenderError, WireValue, parse_wire, reparse_list};
8use ratatui::Frame;
9use ratatui::layout::{Constraint, Rect};
10use ratatui::style::{Color, Modifier, Style};
11use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState};
12use scripting::nomiscript::{Value, format_value, list_to_vec};
13
14/// Curated config keys shown in this tab.
15pub const KNOWN_CONFIG_KEYS: &[&str] = &["locale", "userregistrytimeout"];
16
17/// Per-key fetch state.
18#[derive(Debug, Clone, PartialEq)]
19pub enum ConfigCell {
20    /// A `get-config` request is in flight with this envelope id.
21    Loading { id: i64 },
22    /// Reply received; the value is the string from `:config-value`.
23    Loaded(String),
24    /// The key has no value set — `get-config` returned `(:config-value nil)`.
25    Unset,
26    /// A genuine failure: server error, or an envelope/parse error.
27    Error(String),
28}
29
30pub struct ConfigTab {
31    pub entries: Vec<(String, ConfigCell)>,
32    pub selected: usize,
33}
34
35impl ConfigTab {
36    #[must_use]
37    pub fn new() -> Self {
38        Self {
39            entries: KNOWN_CONFIG_KEYS
40                .iter()
41                .map(|k| ((*k).to_string(), ConfigCell::Unset))
42                .collect(),
43            selected: 0,
44        }
45    }
46
47    /// All entries are `Unset` — used to decide whether `ensure_tab_loaded`
48    /// should issue fetches.
49    #[must_use]
50    pub fn is_idle(&self) -> bool {
51        self.entries
52            .iter()
53            .all(|(_, c)| matches!(c, ConfigCell::Unset))
54    }
55
56    /// Mark the entry for `key` as `Loading`.
57    pub fn set_loading(&mut self, key: &str, id: i64) {
58        if let Some((_, cell)) = self.entries.iter_mut().find(|(k, _)| k == key) {
59            *cell = ConfigCell::Loading { id };
60        }
61    }
62
63    /// Process a routed reply for `key`.
64    ///
65    /// A server error maps to `Unset` (key not set); an envelope or parse
66    /// error maps to `Error`; a successful reply extracts `:config-value`.
67    pub fn on_reply(&mut self, key: &str, wire: &str) {
68        let new_state = parse_config_reply(wire);
69        if let Some((_, cell)) = self.entries.iter_mut().find(|(k, _)| k == key) {
70            *cell = new_state;
71        }
72    }
73
74    /// Reset all entries to `Unset` so the next tab-switch re-fetches.
75    pub fn reset(&mut self) {
76        for (_, cell) in &mut self.entries {
77            *cell = ConfigCell::Unset;
78        }
79    }
80
81    /// Whether any entry is still waiting for a reply.
82    #[must_use]
83    pub fn any_loading(&self) -> bool {
84        self.entries
85            .iter()
86            .any(|(_, c)| matches!(c, ConfigCell::Loading { .. }))
87    }
88
89    /// Key name at the selected row.
90    #[must_use]
91    pub fn selected_key(&self) -> Option<&str> {
92        self.entries.get(self.selected).map(|(k, _)| k.as_str())
93    }
94
95    /// Current display value at the selected row; empty string if not loaded.
96    #[must_use]
97    pub fn selected_value(&self) -> &str {
98        match self.entries.get(self.selected) {
99            Some((_, ConfigCell::Loaded(v))) => v.as_str(),
100            _ => "",
101        }
102    }
103
104    pub fn select_next(&mut self) {
105        if self.selected + 1 < self.entries.len() {
106            self.selected += 1;
107        }
108    }
109
110    pub fn select_prev(&mut self) {
111        self.selected = self.selected.saturating_sub(1);
112    }
113
114    pub fn draw(&self, frame: &mut Frame, area: Rect) {
115        let block = Block::default().borders(Borders::ALL).title("Config");
116        let header_style = Style::default().add_modifier(Modifier::BOLD);
117        let selected_style = Style::default().bg(Color::DarkGray);
118
119        let header = Row::new(vec![
120            Cell::from("Key").style(header_style),
121            Cell::from("Value").style(header_style),
122        ]);
123
124        let rows: Vec<Row> = self
125            .entries
126            .iter()
127            .enumerate()
128            .map(|(i, (key, cell))| {
129                let display: &str = match cell {
130                    ConfigCell::Loaded(v) => v.as_str(),
131                    ConfigCell::Loading { .. } => "(loading\u{2026})",
132                    ConfigCell::Unset => "(unset)",
133                    ConfigCell::Error(e) => e.as_str(),
134                };
135                let style = if i == self.selected {
136                    selected_style
137                } else {
138                    Style::default()
139                };
140                Row::new(vec![Cell::from(key.as_str()), Cell::from(display)]).style(style)
141            })
142            .collect();
143
144        let widths = [Constraint::Percentage(40), Constraint::Percentage(60)];
145        let table = Table::new(rows, widths).header(header).block(block);
146        let mut state = TableState::default().with_selected(Some(self.selected));
147        frame.render_stateful_widget(table, area, &mut state);
148    }
149}
150
151impl Default for ConfigTab {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// Build the `(set-config <key> <value>)` s-expression.
158pub fn build_set_config_form(key: &str, value: &str) -> String {
159    use cli_core::eval::escape_str;
160    format!("(set-config {} {})", escape_str(key), escape_str(value))
161}
162
163fn parse_config_reply(wire: &str) -> ConfigCell {
164    // An UNSET key is NOT a server error: `get-config` returns
165    // `(:config-value nil)` for a missing field (rpc config.rs run_get_config).
166    // A server error therefore means a genuine failure (bad arg / DB / runtime)
167    // and must surface as such, not be hidden as "(unset)".
168    match parse_wire(wire) {
169        Err(RenderError::Server { code, message }) => {
170            ConfigCell::Error(format!("[{code}] {message}"))
171        }
172        Err(e) => ConfigCell::Error(e.to_string()),
173        Ok(WireValue::Value(Value::String(ref s))) => extract_config_value(s),
174        Ok(WireValue::Value(ref other)) => {
175            ConfigCell::Error(format!("unexpected reply type: {}", format_value(other)))
176        }
177    }
178}
179
180fn extract_config_value(plist_str: &str) -> ConfigCell {
181    let parsed = match reparse_list(plist_str) {
182        Ok(v) => v,
183        Err(e) => return ConfigCell::Error(format!("parse error: {e}")),
184    };
185    match plist_config_value(&parsed) {
186        // `(:config-value nil)` is how the native reports a key with no value set.
187        Some(Value::Nil) => ConfigCell::Unset,
188        Some(Value::String(s)) => ConfigCell::Loaded(s),
189        Some(other) => ConfigCell::Loaded(format_value(&other)),
190        None => ConfigCell::Error(format!("missing :config-value in: {plist_str}")),
191    }
192}
193
194fn plist_config_value(value: &Value) -> Option<Value> {
195    let items = list_to_vec(value)?;
196    let pos = items
197        .iter()
198        .position(|v| matches!(v, Value::Symbol(s) if s == ":config-value"))?;
199    items.get(pos + 1).cloned()
200}
201
202#[cfg(test)]
203mod tests;