init
This commit is contained in:
commit
4cf726709f
36 files changed
+8617
No files matched your search
@@ -0,0 +1,141 @@
|
||||
pub mod ui;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub workspace_root: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub default_provider: String,
|
||||
pub default_branch: String,
|
||||
pub default_visibility: String,
|
||||
pub editor: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub gitignore_template: String,
|
||||
pub readme_template: String,
|
||||
pub share_stats: bool,
|
||||
pub timezone: String,
|
||||
pub vsc_data_dir: PathBuf,
|
||||
/// Folder names inside workspace_root treated as organisational groups.
|
||||
/// Their direct children are scanned as repos, not the group folder itself.
|
||||
/// Example: ["clients", "internal", "opensource"]
|
||||
#[serde(default)]
|
||||
pub workspace_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
|
||||
Self {
|
||||
workspace_root: home.join("projects"),
|
||||
data_dir: home.join(".fxg"),
|
||||
default_provider: "github".into(),
|
||||
default_branch: "main".into(),
|
||||
default_visibility: "private".into(),
|
||||
editor: std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()),
|
||||
username: String::new(),
|
||||
email: String::new(),
|
||||
gitignore_template: "node".into(),
|
||||
readme_template: "heartbyte-default".into(),
|
||||
share_stats: false,
|
||||
timezone: "Europe/Warsaw".into(),
|
||||
vsc_data_dir: home.join(".fxg/vsc-data"),
|
||||
workspace_groups: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn fields(&self) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("workspace_root", self.workspace_root.to_string_lossy().into()),
|
||||
("data_dir", self.data_dir.to_string_lossy().into()),
|
||||
("default_provider", self.default_provider.clone()),
|
||||
("default_branch", self.default_branch.clone()),
|
||||
("default_visibility", self.default_visibility.clone()),
|
||||
("editor", self.editor.clone()),
|
||||
("username", self.username.clone()),
|
||||
("email", self.email.clone()),
|
||||
("gitignore_template", self.gitignore_template.clone()),
|
||||
("readme_template", self.readme_template.clone()),
|
||||
("share_stats", self.share_stats.to_string()),
|
||||
("timezone", self.timezone.clone()),
|
||||
("vsc_data_dir", self.vsc_data_dir.to_string_lossy().into()),
|
||||
("workspace_groups", self.workspace_groups.join(", ")),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn set_field(&mut self, key: &str, value: String) -> Result<()> {
|
||||
match key {
|
||||
"workspace_root" => self.workspace_root = PathBuf::from(&value),
|
||||
"data_dir" => self.data_dir = PathBuf::from(&value),
|
||||
"default_provider" => self.default_provider = value,
|
||||
"default_branch" => self.default_branch = value,
|
||||
"default_visibility" => self.default_visibility = value,
|
||||
"editor" => self.editor = value,
|
||||
"username" => self.username = value,
|
||||
"email" => self.email = value,
|
||||
"gitignore_template" => self.gitignore_template = value,
|
||||
"readme_template" => self.readme_template = value,
|
||||
"share_stats" => self.share_stats = value == "true",
|
||||
"timezone" => self.timezone = value,
|
||||
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
|
||||
"workspace_groups" => {
|
||||
self.workspace_groups = value
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
other => anyhow::bail!("unknown config key: {}", other),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".fxg")
|
||||
.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result<AppConfig> {
|
||||
let path = config_path();
|
||||
if path.exists() {
|
||||
let raw = std::fs::read_to_string(&path)?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
} else {
|
||||
Ok(AppConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_config(cfg: &AppConfig) -> Result<()> {
|
||||
let path = config_path();
|
||||
ensure_parent(&path)?;
|
||||
std::fs::write(path, toml::to_string_pretty(cfg)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn ensure_data_dir(cfg: &AppConfig) -> Result<()> {
|
||||
std::fs::create_dir_all(&cfg.data_dir)?;
|
||||
std::fs::create_dir_all(&cfg.vsc_data_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_parent(p: &Path) -> Result<()> {
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(cfg: AppConfig) -> Result<()> {
|
||||
let updated = ui::run_config_tui(cfg)?;
|
||||
save_config(&updated)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::tui;
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
struct State {
|
||||
fields: Vec<(&'static str, String)>,
|
||||
selected: usize,
|
||||
editing: bool,
|
||||
edit_buf: String,
|
||||
quit: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn new(cfg: &AppConfig) -> Self {
|
||||
Self {
|
||||
fields: cfg.fields(),
|
||||
selected: 0,
|
||||
editing: false,
|
||||
edit_buf: String::new(),
|
||||
quit: false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fn render(f: &mut Frame, state: &State) {
|
||||
let area = f.area();
|
||||
let outer = Block::default()
|
||||
.title(" fxg — 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 value_str = if active && state.editing {
|
||||
format!("{}_", state.edit_buf)
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
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),
|
||||
]);
|
||||
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 [r] reset [q] save & quit"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
|
||||
chunks[1],
|
||||
);
|
||||
}
|
||||
|
||||
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.quit = true,
|
||||
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 => {
|
||||
state.edit_buf = state.fields[state.selected].1.clone();
|
||||
state.editing = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Ok(state.quit)
|
||||
})?;
|
||||
Ok(state.to_config(cfg))
|
||||
}
|
||||
Reference in new issue
Block a user