Files
vulp/src/config/ui.rs
T
dualfroz 0468c032c6 Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene
- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract
- CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI"
- Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus
- Fix: org repos were never refreshed (owner==username match); refresh now
  parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs
- Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math)
- Stash indicator + [m] actions, session persistence, gh CLI token fallback
- Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
2026-07-06 14:42:52 +02:00

243 lines
8.8 KiB
Rust

//! Standalone TUI for `vulp config`: a flat editable field list, switching
//! out to `inquire::Select` (outside ratatui's raw mode) for fields that
//! must be chosen from a fixed list rather than typed freely.
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEvent};
use inquire::Select;
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
// Fields that must be chosen from a fixed list — no free text allowed.
const ENUM_FIELDS: &[(&str, &[&str])] = &[
("default_provider", &["github", "gitlab", "gitea", "forgejo"]),
("default_visibility", &["private", "public"]),
("share_stats", &["false", "true"]),
("gitignore_template", &["node", "rust", "python", "blank"]),
];
/// Looks up the fixed option list for `key`, if it's an enum field.
fn enum_options(key: &str) -> Option<&'static [&'static str]> {
ENUM_FIELDS.iter().find(|(k, _)| *k == key).map(|(_, opts)| *opts)
}
// ── TUI state ─────────────────────────────────────────────────────────────────
/// Signal from the ratatui inner loop to the outer loop in `run_config_tui`:
/// either stop entirely, or briefly leave raw mode to show an `inquire`
/// picker for one enum field.
enum Action {
Quit,
OpenPicker { field_idx: usize, options: Vec<&'static str> },
}
struct State {
fields: Vec<(&'static str, String)>,
selected: usize,
editing: bool,
edit_buf: String,
action: Option<Action>,
}
impl State {
fn new(cfg: &AppConfig) -> Self {
Self {
fields: cfg.fields(),
selected: 0,
editing: false,
edit_buf: String::new(),
action: None,
}
}
/// Applies every edited field back onto `base`, returning the updated
/// config. Starts from the original `AppConfig` (rather than building
/// one from scratch) so any field not exposed in `fields()` is preserved.
fn to_config(&self, base: AppConfig) -> AppConfig {
let mut cfg = base;
let labels: Vec<&str> = self.fields.iter().map(|(k, _)| *k).collect();
for (i, (_, val)) in self.fields.iter().enumerate() {
let _ = cfg.set_field(labels[i], val.clone());
}
cfg
}
}
// ── render ────────────────────────────────────────────────────────────────────
/// Draws the field list, with enum fields tagged `[pick]` and the
/// currently-edited field (if any) showing its live edit buffer.
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default()
.title(" vulp — Configuration ")
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
let items: Vec<ListItem> = state.fields.iter().enumerate().map(|(i, (key, val))| {
let active = i == state.selected;
let is_enum = enum_options(key).is_some();
let value_str = if active && state.editing {
format!("{}_", state.edit_buf)
} else {
val.clone()
};
let value_style = if is_enum {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let key_label = if is_enum {
format!("{:<22} [pick]", key)
} else {
format!("{:<22} ", key)
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled(key_label, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(value_str, value_style),
]);
let style = if active {
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(line).style(style)
}).collect();
f.render_widget(List::new(items), chunks[0]);
let hint = if state.editing {
" [Enter] confirm [Esc] cancel [Backspace] delete"
} else {
" [↑/↓] navigate [Enter] edit / pick [r] reset defaults [q] save & quit"
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
// ── key handling ──────────────────────────────────────────────────────────────
/// Routes one key event to either free-text edit-buffer editing or
/// navigation/action handling, depending on `state.editing`.
fn handle_key(state: &mut State, key: KeyEvent) {
if state.editing {
match key.code {
KeyCode::Enter => {
state.fields[state.selected].1 = state.edit_buf.clone();
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Esc => {
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Char(c) => state.edit_buf.push(c),
KeyCode::Backspace => { state.edit_buf.pop(); }
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
state.action = Some(Action::Quit);
}
KeyCode::Char('r') => {
let defaults = AppConfig::default().fields();
state.fields = defaults;
}
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.fields.len() { state.selected += 1; }
}
KeyCode::Enter => {
let key_name = state.fields[state.selected].0;
if let Some(opts) = enum_options(key_name) {
// Signal the outer loop to open a picker
state.action = Some(Action::OpenPicker {
field_idx: state.selected,
options: opts.to_vec(),
});
} else {
state.edit_buf = state.fields[state.selected].1.clone();
state.editing = true;
}
}
_ => {}
}
}
// ── ratatui inner loop ────────────────────────────────────────────────────────
/// Runs the ratatui draw/poll/input loop until `handle_key` sets an
/// `Action`, then returns it (defaulting to `Quit` if the loop somehow
/// exits without one).
fn run_ratatui_loop(state: &mut State) -> Result<Action> {
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(state, key);
}
Ok(state.action.is_some())
})?;
Ok(state.action.take().unwrap_or(Action::Quit))
}
// ── public entry point ────────────────────────────────────────────────────────
/// Runs the settings TUI until the user quits, alternating between the
/// ratatui field list and (for enum fields) an `inquire` picker shown after
/// briefly leaving raw mode. Returns the fully updated config for the
/// caller to persist.
pub fn run_config_tui(cfg: AppConfig) -> Result<AppConfig> {
let mut state = State::new(&cfg);
loop {
match run_ratatui_loop(&mut state)? {
Action::Quit => break,
Action::OpenPicker { field_idx, options } => {
// We are now back in normal terminal mode — inquire can draw.
let current = &state.fields[field_idx].1;
let start = options.iter().position(|o| *o == current.as_str()).unwrap_or(0);
let key_name = state.fields[field_idx].0;
// Esc (None) keeps the old value.
if let Some(chosen) = Select::new(&format!("{}:", key_name), options)
.with_starting_cursor(start)
.with_help_message("Use ↑/↓ to select, Enter to confirm, Esc to cancel")
.prompt_skippable()?
{
state.fields[field_idx].1 = chosen.to_string();
}
// Loop continues — re-enter ratatui
}
}
}
Ok(state.to_config(cfg))
}