Test message 123!
This commit is contained in:
1 parent
4cf726709f
commit
d4fdc9890a
52 files changed
+5537
-277
No files matched your search
@@ -24,6 +24,21 @@ pub struct AppConfig {
|
||||
/// Example: ["clients", "internal", "opensource"]
|
||||
#[serde(default)]
|
||||
pub workspace_groups: Vec<String>,
|
||||
/// Shell command used to copy text to clipboard (e.g. "wl-copy" or "xclip -selection clipboard").
|
||||
#[serde(default = "default_clipboard_cmd")]
|
||||
pub clipboard_cmd: String,
|
||||
/// Count lines-of-code during workspace scan (slow for large repos; off by default).
|
||||
#[serde(default)]
|
||||
pub show_locs: bool,
|
||||
}
|
||||
|
||||
fn default_clipboard_cmd() -> String {
|
||||
for cmd in &["wl-copy", "xclip", "pbcopy"] {
|
||||
if std::process::Command::new("which").arg(cmd).output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
return if *cmd == "xclip" { "xclip -selection clipboard".into() } else { cmd.to_string() };
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -44,6 +59,8 @@ impl Default for AppConfig {
|
||||
timezone: "Europe/Warsaw".into(),
|
||||
vsc_data_dir: home.join(".fxg/vsc-data"),
|
||||
workspace_groups: vec![],
|
||||
clipboard_cmd: default_clipboard_cmd(),
|
||||
show_locs: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+102
-19
@@ -2,6 +2,7 @@ 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},
|
||||
@@ -10,12 +11,31 @@ use ratatui::{
|
||||
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"]),
|
||||
];
|
||||
|
||||
fn enum_options(key: &str) -> Option<&'static [&'static str]> {
|
||||
ENUM_FIELDS.iter().find(|(k, _)| *k == key).map(|(_, opts)| *opts)
|
||||
}
|
||||
|
||||
// ── TUI state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
quit: bool,
|
||||
fields: Vec<(&'static str, String)>,
|
||||
selected: usize,
|
||||
editing: bool,
|
||||
edit_buf: String,
|
||||
action: Option<Action>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
@@ -25,7 +45,7 @@ impl State {
|
||||
selected: 0,
|
||||
editing: false,
|
||||
edit_buf: String::new(),
|
||||
quit: false,
|
||||
action: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +59,8 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn render(f: &mut Frame, state: &State) {
|
||||
let area = f.area();
|
||||
let outer = Block::default()
|
||||
@@ -54,17 +76,33 @@ fn render(f: &mut Frame, state: &State) {
|
||||
|
||||
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(format!(" [{:2}] ", i + 1)),
|
||||
Span::styled(format!("{:<22}", key), Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" "),
|
||||
Span::raw(value_str),
|
||||
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 {
|
||||
@@ -78,7 +116,7 @@ fn render(f: &mut Frame, state: &State) {
|
||||
let hint = if state.editing {
|
||||
" [Enter] confirm [Esc] cancel [Backspace] delete"
|
||||
} else {
|
||||
" [↑/↓] navigate [Enter] edit [r] reset [q] save & quit"
|
||||
" [↑/↓] navigate [Enter] edit / pick [r] reset defaults [q] save & quit"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
|
||||
@@ -86,6 +124,8 @@ fn render(f: &mut Frame, state: &State) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── key handling ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_key(state: &mut State, key: KeyEvent) {
|
||||
if state.editing {
|
||||
match key.code {
|
||||
@@ -104,8 +144,11 @@ fn handle_key(state: &mut State, key: KeyEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
|
||||
KeyCode::Char('q') | KeyCode::Esc => {
|
||||
state.action = Some(Action::Quit);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
let defaults = AppConfig::default().fields();
|
||||
state.fields = defaults;
|
||||
@@ -117,21 +160,61 @@ fn handle_key(state: &mut State, key: KeyEvent) {
|
||||
if state.selected + 1 < state.fields.len() { state.selected += 1; }
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.edit_buf = state.fields[state.selected].1.clone();
|
||||
state.editing = true;
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run_config_tui(cfg: AppConfig) -> Result<AppConfig> {
|
||||
let mut state = State::new(&cfg);
|
||||
tui::run_loop(|terminal| {
|
||||
terminal.draw(|f| render(f, &state))?;
|
||||
if let Some(Event::Key(key)) = tui::next_event(250)? {
|
||||
handle_key(&mut state, key);
|
||||
|
||||
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;
|
||||
match Select::new(&format!("{}:", key_name), options)
|
||||
.with_starting_cursor(start)
|
||||
.with_help_message("Use ↑/↓ to select, Enter to confirm, Esc to cancel")
|
||||
.prompt_skippable()?
|
||||
{
|
||||
Some(chosen) => { state.fields[field_idx].1 = chosen.to_string(); }
|
||||
None => {} // Esc — keep old value
|
||||
}
|
||||
// Loop continues — re-enter ratatui
|
||||
}
|
||||
}
|
||||
Ok(state.quit)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(state.to_config(cfg))
|
||||
}
|
||||
Reference in new issue
Block a user