fedora-dotfiles-tui 1.1.0

Terminal UI for fedora-dotfiles — browse, toggle, backup, and restore Fedora + KDE dotfiles
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use ratatui::{
    layout::{Constraint, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
    Frame,
};
use serde::{Deserialize, Serialize};
use std::{
    fs, io::{self, Write},
    path::PathBuf,
    process::{Command, Stdio},
    sync::mpsc, thread,
    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};

#[derive(Debug, Clone, Serialize, Deserialize)]
struct DotfileEntry {
    path: String, enabled: bool, category: String, description: String,
    #[serde(default)] sudo: bool, #[serde(default, rename = "is_dir")] is_dir: bool,
}
fn home() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())) }
fn manifest() -> PathBuf { home().join(".config/fedora-dotfiles/manifest.json") }
fn config_file() -> PathBuf { home().join(".config/fedora-dotfiles/config.json") }
fn cli_bin() -> PathBuf {
    if let Ok(e) = std::env::current_exe() { if let Some(d) = e.parent() { let c = d.join("fedora-dotfiles"); if c.exists() { return c; } } }
    let p = home().join("FedoraOS.f/fedora-dotfiles"); if p.exists() { return p; }
    let cargo = home().join(".cargo/bin/fedora-dotfiles"); if cargo.exists() { return cargo; }
    PathBuf::from("./fedora-dotfiles")
}
fn repo_dir() -> PathBuf { home().join(".local/share/fedora-dotfiles/repo") }
fn load() -> Vec<DotfileEntry> { manifest().exists().then(|| fs::read_to_string(manifest()).ok()).flatten().and_then(|d| serde_json::from_str(&d).ok()).unwrap_or_default() }
fn save(e: &[DotfileEntry]) { let p = manifest(); let _ = std::fs::create_dir_all(p.parent().unwrap()); serde_json::to_string_pretty(e).ok().map(|j| fs::write(&p, j).ok()); }
fn cli_blocking(args: &[&str]) -> (bool, String) { match Command::new(cli_bin()).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() { Ok(c) => { let o = c.wait_with_output().unwrap(); (o.status.success(), format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))) } Err(e) => (false, format!("CLI: {}", e)) } }
fn git_status() -> String { let d = repo_dir(); if !d.join(".git").exists() { return "no repo".into(); } match Command::new("git").args(["-C"]).arg(d).args(["status","--porcelain"]).output() { Ok(o) if o.status.success() => { let n = String::from_utf8_lossy(&o.stdout).lines().count(); if n == 0 { "clean".into() } else { format!("{}Δ", n) } } _ => "?".into() } }
fn last_backup_time() -> String {
    let d = repo_dir(); if !d.join(".git").exists() { return String::new(); }
    match Command::new("git").args(["-C"]).arg(d).args(["log","-1","--format=%ct"]).output() {
        Ok(o) if o.status.success() => {
            let ts: u64 = String::from_utf8_lossy(&o.stdout).trim().parse().unwrap_or(0);
            if ts == 0 { return String::new(); }
            let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
            let ago = now.saturating_sub(ts);
            if ago < 60 { "just now".into() } else if ago < 3600 { format!("{}m ago", ago/60) } else if ago < 86400 { format!("{}h ago", ago/3600) } else { format!("{}d ago", ago/86400) }
        } _ => String::new(),
    }
}
fn file_changed_since_backup(e: &DotfileEntry) -> bool { let src = if e.path.starts_with("~/") { home().join(&e.path[2..]) } else { PathBuf::from(&e.path) }; let (rp, _) = repo_file(e); if !src.exists() || !rp.exists() { return false; } fs::read(&src).ok().zip(fs::read(&rp).ok()).map(|(a,b)| a != b).unwrap_or(false) }
fn unified_diff(e: &DotfileEntry) -> Vec<Line<'static>> { let src = if e.path.starts_with("~/") { home().join(&e.path[2..]) } else { PathBuf::from(&e.path) }; let (rp, _) = repo_file(e); if !src.exists() || !rp.exists() { return vec![Line::from("(file missing)")]; } let out = Command::new("diff").args(["-u", rp.to_str().unwrap_or(""), src.to_str().unwrap_or("")]).output(); match out { Ok(o) => { let raw = String::from_utf8_lossy(&o.stdout); if raw.is_empty() { vec![Line::from(Span::styled("(identical)", Style::default().fg(DM)))] } else { raw.lines().map(|l| { if l.starts_with('+') && !l.starts_with("+++") { Line::from(Span::styled(l.to_string(), Style::default().fg(GN))) } else if l.starts_with('-') && !l.starts_with("---") { Line::from(Span::styled(l.to_string(), Style::default().fg(RD))) } else if l.starts_with('@') { Line::from(Span::styled(l.to_string(), Style::default().fg(CY))) } else { Line::from(Span::styled(l.to_string(), Style::default().fg(DM))) } }).collect() } } _ => vec![Line::from("diff failed")], } }
fn colorize_output(raw: &str) -> Vec<Line<'static>> { raw.lines().map(|l| { if l.starts_with('+') && !l.starts_with("+++") { Line::from(Span::styled(l.to_string(), Style::default().fg(GN))) } else if l.starts_with('-') && !l.starts_with("---") { Line::from(Span::styled(l.to_string(), Style::default().fg(RD))) } else if l.starts_with('@') { Line::from(Span::styled(l.to_string(), Style::default().fg(CY))) } else if l.starts_with("✓") || l.contains("Complete") { Line::from(Span::styled(l.to_string(), Style::default().fg(GN))) } else if l.starts_with("✗") || l.contains("Failed") { Line::from(Span::styled(l.to_string(), Style::default().fg(RD))) } else { Line::from(Span::styled(l.to_string(), Style::default().fg(FG))) } }).collect() }

fn bat_preview(path: &PathBuf, max_lines: usize) -> Vec<Line<'static>> {
    if !path.exists() || path.is_dir() { return vec![]; }
    let range = format!(":{}", max_lines);
    let out = Command::new("bat").args(["--color=never","--style=plain","--line-range",&range]).arg(path).output();
    match out { Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).lines().map(|l| Line::from(l.to_string())).collect(), _ => match fs::read_to_string(path) { Ok(s) => s.lines().take(max_lines).map(|l| Line::from(l.to_string())).collect(), Err(_) => vec![] } }
}

fn fuzzy_match(pat: &str, text: &str) -> bool {
    let mut pc = pat.chars().peekable();
    for tc in text.chars() {
        if let Some(&p) = pc.peek() {
            if p.eq_ignore_ascii_case(&tc) { pc.next(); }
        } else { break; }
    }
    pc.peek().is_none()
}

fn dir_preview(path: &PathBuf) -> Vec<Line<'static>> {
    if !path.exists() || !path.is_dir() { return vec![Line::from("(not a directory)")]; }
    let mut entries: Vec<_> = match fs::read_dir(path) { Ok(d) => d.filter_map(|e| e.ok()).filter(|e| !e.file_name().to_string_lossy().starts_with('.')).collect(), Err(e) => return vec![Line::from(format!("(error: {})", e))] };
    entries.sort_by_key(|e| e.file_name());
    let max = 15.min(entries.len());
    let mut lines = vec![Line::from(Span::styled(format!("{} items:", entries.len()), Style::default().fg(DM)))];
    for ent in entries.iter().take(max) {
        let name = ent.file_name().to_string_lossy().to_string();
        let is_dir = ent.file_type().map(|t| t.is_dir()).unwrap_or(false);
        let suffix = if is_dir { "/" } else { "" };
        lines.push(Line::from(Span::styled(format!("{}{}", name, suffix), Style::default().fg(CY))));
        if !is_dir {
            if let Ok(data) = fs::read_to_string(ent.path()) {
                for l in data.lines().take(3) { lines.push(Line::from(Span::styled(format!("  {}", l), Style::default().fg(DM)))); }
            }
        }
    }
    if entries.len() > max { lines.push(Line::from(Span::styled(format!("... and {} more", entries.len() - max), Style::default().fg(DM)))); }
    lines
}

// ── Wizard ─────────────────────────────────────────────────────────────
fn wizard() -> io::Result<bool> { println!("\x1b[1;36m═══ fedora-dotfiles Setup Wizard ═══\x1b[0m\n"); print!("  Checking GitHub authentication... "); io::stdout().flush()?; let gh = Command::new("gh").args(["auth","status"]).stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false); if gh { println!("\x1b[32m✓ Authenticated\x1b[0m"); } else { println!("\x1b[33m✗ Not authenticated\x1b[0m"); println!("\n  Run: \x1b[36mgh auth login\x1b[0m\n  (HTTPS — no SSH key needed)\n"); return Ok(false); } println!("\n  What would you like to do?\n"); println!("    \x1b[36m[b]\x1b[0m  Backup my dotfiles to GitHub"); println!("    \x1b[36m[r]\x1b[0m  Restore dotfiles from GitHub"); println!("    \x1b[36m[q]\x1b[0m  Quit (launch TUI)"); print!("\n  Choice: "); io::stdout().flush()?; match read_char()? { 'b'|'B' => wiz_backup()?, 'r'|'R' => wiz_restore()?, _ => { println!("  Launching TUI...\n"); return Ok(true); } } Ok(true) }
fn wiz_backup() -> io::Result<()> { println!("\n  Do you have a GitHub repo for dotfiles?\n    \x1b[36m[y]\x1b[0m Yes  \x1b[36m[n]\x1b[0m No — create one"); print!("  Choice: "); io::stdout().flush()?; let has = read_char()?; println!(); let url: String = if has == 'y' || has == 'Y' { print!("  Repo URL: "); io::stdout().flush()?; let mut u = String::new(); io::stdin().read_line(&mut u)?; u.trim().to_string() } else { let user = String::from_utf8_lossy(&Command::new("gh").args(["api","user","--jq",".login"]).output().map(|o| o.stdout).unwrap_or_default()).trim().to_string(); print!("  Repo name [fedora-dotfiles]: "); io::stdout().flush()?; let mut nn = String::new(); io::stdin().read_line(&mut nn)?; let name = if nn.trim().is_empty() { "fedora-dotfiles" } else { nn.trim() }; print!("  Private? [Y/n]: "); io::stdout().flush()?; let is_priv = read_char()? != 'n'; println!(); let full = format!("{}/{}", user, name); let mut args = vec!["repo","create",&full,"--description","Fedora dotfiles backup"]; if is_priv { args.push("--private"); } else { args.push("--public"); } print!("  Creating {}... ", full); io::stdout().flush()?; if Command::new("gh").args(&args).status().map(|s| s.success()).unwrap_or(false) { println!("\x1b[32m✓ Created\x1b[0m"); format!("https://github.com/{}/{}.git", user, name) } else { println!("\x1b[33mFailed\x1b[0m"); print!("  Enter URL manually: "); io::stdout().flush()?; let mut u = String::new(); io::stdin().read_line(&mut u)?; u.trim().to_string() } }; if url.is_empty() { println!("  Aborting.\n"); return Ok(()); } println!("  Initializing with {}...", url); let (_, _) = cli_blocking(&["init","--repo",&url]); let e = load(); println!("\n  Loaded {} files ({} enabled)", e.len(), e.iter().filter(|x| x.enabled).count()); print!("  Run backup now? [Y/n]: "); io::stdout().flush()?; if read_char()?.to_ascii_lowercase() != 'n' { let (_, o) = cli_blocking(&["backup"]); println!("{}", o); } println!("\n  \x1b[36m═══ Done! ═══\x1b[0m\n"); Ok(()) }
fn wiz_restore() -> io::Result<()> { print!("  GitHub repo URL: "); io::stdout().flush()?; let mut url = String::new(); io::stdin().read_line(&mut url)?; let url = url.trim().to_string(); if url.is_empty() { println!("  Aborting.\n"); return Ok(()); } cli_blocking(&["init","--repo",&url]); print!("\n  Run restore now? [Y/n]: "); io::stdout().flush()?; if read_char()?.to_ascii_lowercase() != 'n' { let (_, dry) = cli_blocking(&["restore","--dry-run","--yes"]); println!("{}", dry.chars().take(600).collect::<String>()); print!("\n  Proceed? [y/N]: "); io::stdout().flush()?; if read_char()?.to_ascii_lowercase() == 'y' { let (_, o) = cli_blocking(&["restore","--yes"]); println!("{}", o); } } println!("\n  \x1b[36m═══ Done! ═══\x1b[0m\n"); Ok(()) }
fn read_char() -> io::Result<char> { let mut l = String::new(); io::stdin().read_line(&mut l)?; Ok(l.chars().next().unwrap_or('\n')) }

// ── App ───────────────────────────────────────────────────────────────
struct App {
    entries: Vec<DotfileEntry>, category: usize, filter: String, sel: usize, scroll: usize,
    mode: Mode, dialog: Option<DialogKind>, status: String, repo_status: String, last_backup: String,
    list_state: ListState, rx: Option<mpsc::Receiver<(bool, String)>>, label: String,
    sp: usize, sp_tick: Instant, result: Option<(bool, String)>, deadline: Instant,
    output_lines: Vec<Line<'static>>, output_scroll: usize, output_title: String,
}
#[derive(PartialEq)] enum Mode { Normal, Filter, Help, Dialog, Output, Working }
enum DialogKind { ConfirmRestore(String), DiffPreview(String) }
const SPINNER: &[&str] = &["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
const CATS: &[&str] = &["All","System","KDE","User"];
const MAP: &[&str] = &["all","system","kde","user"];

impl App {
    fn new() -> Self { let mut s = ListState::default(); s.select(Some(0)); let gs = git_status(); let lb = last_backup_time(); Self { entries: load(), category: 0, filter: String::new(), sel: 0, scroll: 0, mode: Mode::Normal, dialog: None, status: status_bar(&gs, &lb, "Space:toggle  /:search  b:backup  r:restore  d:diff  h:help  q:quit"), repo_status: gs, last_backup: lb, list_state: s, rx: None, label: String::new(), sp: 0, sp_tick: Instant::now(), result: None, deadline: Instant::now(), output_lines: Vec::new(), output_scroll: 0, output_title: String::new() } }
    fn filtered(&self) -> Vec<usize> { let ft = &self.filter; self.entries.iter().enumerate().filter(|(_, e)| (self.category == 0 || MAP.get(self.category) == Some(&e.category.as_str())) && (ft.is_empty() || fuzzy_match(ft, &e.path) || fuzzy_match(ft, &e.description))).map(|(i, _)| i).collect() }
    fn real_idx(&self) -> Option<usize> { self.filtered().get(self.sel).copied() }
    fn count(&self) -> usize { self.filtered().len() }
    fn sync(&mut self, vis: u16) { let n = self.count(); let v = vis.saturating_sub(2) as usize; if n == 0 || v == 0 { self.list_state.select(None); return; } if self.sel >= n { self.sel = n.saturating_sub(1); } if self.sel < self.scroll { self.scroll = self.sel; } else if self.sel >= self.scroll.saturating_add(v) { self.scroll = self.sel.saturating_sub(v.saturating_sub(1)); } if n > v { self.scroll = self.scroll.min(n.saturating_sub(v)); } else { self.scroll = 0; } self.list_state.select(Some((self.sel.saturating_sub(self.scroll)).min(v))); }
    fn toggle(&mut self) { if self.count() == 0 { return; } if let Some(i) = self.real_idx() { self.entries[i].enabled = !self.entries[i].enabled; self.status = status_bar(&self.repo_status, &self.last_backup, &format!("{} {}", if self.entries[i].enabled { "✓" } else { "✗" }, self.entries[i].path)); save(&self.entries); } }
    fn set_status(&mut self, msg: &str) { self.status = status_bar(&self.repo_status, &self.last_backup, msg); }
    fn toggle_category(&mut self, cat: usize) { let ena = !self.entries.iter().any(|e| e.category == MAP[cat] && e.enabled); for e in &mut self.entries { if e.category == MAP[cat] { e.enabled = ena; } } save(&self.entries); self.status = status_bar(&self.repo_status, &self.last_backup, &format!("{} all {}", if ena { "✓" } else { "✗" }, CATS[cat])); }
    fn update_repo(&mut self) { self.repo_status = git_status(); self.last_backup = last_backup_time(); }
    fn launch(&mut self, label: &str, args: Vec<String>) { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let ca: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); let r = cli_blocking(&ca); let _ = tx.send(r); }); self.rx = Some(rx); self.label = label.to_string(); self.mode = Mode::Working; self.sp = 0; self.sp_tick = Instant::now(); self.result = None; self.deadline = Instant::now() + Duration::from_millis(500); }
    fn poll(&mut self) -> Option<(bool, String)> { if self.sp_tick.elapsed().as_millis() >= 80 { self.sp = (self.sp + 1) % SPINNER.len(); self.sp_tick = Instant::now(); } if let Some(ref rx) = self.rx { if let Ok(r) = rx.try_recv() { self.rx = None; self.result = Some(r); } } if Instant::now() >= self.deadline { return self.result.take(); } None }
}
fn status_bar(repo: &str, backup: &str, msg: &str) -> String { let lb = if backup.is_empty() { String::new() } else { format!("  Last: {}", backup) }; format!("[{}]{}  {}", repo, lb, msg) }

// ── Styling ───────────────────────────────────────────────────────────
const BG: Color = Color::Rgb(18,18,24);  const FG: Color = Color::Rgb(238,238,255); const CY: Color = Color::Rgb(0,230,230);
const GN: Color = Color::Rgb(0,255,120); const RD: Color = Color::Rgb(255,60,60);   const YW: Color = Color::Rgb(255,220,50);
const PK: Color = Color::Rgb(255,80,180); const DM: Color = Color::Rgb(130,140,160); const HL: Color = Color::Rgb(50,55,75);
const OR: Color = Color::Rgb(255,150,30);

fn entry_item(e: &DotfileEntry, changed: bool) -> ListItem<'static> { let ico = if e.enabled { "✓" } else { "✗" }; let ch = if changed && e.enabled { "* " } else { "" }; let col = if !e.enabled { RD } else if e.sudo { YW } else { GN }; ListItem::new(Line::from(Span::styled(format!("{}{} {}{}{}", ch, ico, e.path, if e.is_dir { "/" } else { "" }, if e.sudo { " [sudo]" } else { "" }), Style::default().fg(col)))) }
fn detail_lines(e: &DotfileEntry, changed: bool) -> Vec<Line<'static>> { let p = e.path.clone(); let c = e.category.clone(); let d = e.description.clone(); let mut lines = vec![Line::from(Span::styled(format!("Path:       {}", p), Style::default().fg(CY))),Line::from(Span::styled(format!("Category:   {}", c), Style::default().fg(DM))),Line::from(Span::styled(format!("Status:     {}", if e.enabled { "✓ Enabled" } else { "✗ Disabled" }), Style::default().fg(GN))),Line::from(Span::styled(format!("Needs sudo: {}", if e.sudo { "Yes" } else { "No" }), Style::default().fg(YW))),Line::default(),Line::from(Span::styled(d, Style::default().fg(DM)))]; if changed { lines.push(Line::from(Span::styled("* Modified since last backup", Style::default().fg(OR)))); } if p.contains(".ssh/") { lines.push(Line::default()); lines.push(Line::from(Span::styled("⚠ SECURITY", Style::default().fg(PK)))); } if p.contains("kwalletrc") { lines.push(Line::default()); lines.push(Line::from(Span::styled("⚠ Wallet config", Style::default().fg(YW)))); } if p.contains("kdeconnect") { lines.push(Line::default()); lines.push(Line::from(Span::styled("⚠ Device IDs", Style::default().fg(YW)))); } lines }

// ── Render ────────────────────────────────────────────────────────────
fn ui(f: &mut Frame, a: &mut App) {
    let area = f.area();
    let [header, body, stat] = Layout::vertical([Constraint::Length(1), Constraint::Min(3), Constraint::Length(2)]).areas(area);
    let mut spans: Vec<Span> = Vec::new();
    for (i, &cat) in CATS.iter().enumerate() {
        if i > 0 { spans.push(Span::raw(" │ ")); }
        let style = if i == a.category { Style::default().fg(CY).add_modifier(Modifier::BOLD) } else { Style::default().fg(DM) };
        spans.push(Span::styled(cat.to_string(), style));
    }
    f.render_widget(Paragraph::new(Line::from(spans)), header);
    let [filter_bar, main_body] = Layout::vertical([Constraint::Length(1), Constraint::Min(3)]).areas(body);
    if a.mode == Mode::Filter {
        let cnt = a.filtered().len();
        let txt = format!("🔍 ▏{}▕  {} match{}", a.filter, cnt, if cnt == 1 { "" } else { "es" });
        f.render_widget(Paragraph::new(Line::from(Span::styled(txt, Style::default().fg(CY).add_modifier(Modifier::BOLD)))), filter_bar);
    } else {
        f.render_widget(Paragraph::new(Line::from(Span::styled("🔍 / to search", Style::default().fg(DM)))), filter_bar);
    }
    let [left, right] = Layout::horizontal([Constraint::Length(52), Constraint::Min(20)]).areas(main_body);
    let idx = a.filtered(); let en = idx.iter().filter(|&&i| a.entries[i].enabled).count();
    a.sync(left.height);
    let vis = (left.height.saturating_sub(2) as usize).max(1);
    let vis_items: Vec<ListItem> = idx.iter().skip(a.scroll).take(vis).map(|&i| { let changed = a.entries[i].enabled && file_changed_since_backup(&a.entries[i]); entry_item(&a.entries[i], changed) }).collect();
    f.render_stateful_widget(List::new(vis_items).block(Block::default().borders(Borders::ALL).title(format!(" Files ({}/{}) ", en, idx.len())).border_style(Style::default().fg(DM))).highlight_style(Style::default().bg(HL).add_modifier(Modifier::BOLD)).highlight_symbol("▶ "), left, &mut a.list_state);
    let [detail_area, preview_area] = Layout::vertical([Constraint::Length(8), Constraint::Min(3)]).areas(right);
    if a.mode == Mode::Working { let lines = vec![Line::from(Span::styled(format!("{} {}", SPINNER[a.sp], a.label), Style::default().fg(CY).add_modifier(Modifier::BOLD))), Line::default(), Line::from(Span::styled("Running...", Style::default().fg(DM)))]; f.render_widget(Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title(" Working ").border_style(Style::default().fg(CY))).style(Style::default().fg(FG)), right); }
    else if a.mode == Mode::Output {
        let vis_lines = (right.height.saturating_sub(2) as usize).max(1);
        let shown: Vec<Line> = a.output_lines.iter().skip(a.output_scroll).take(vis_lines).cloned().collect();
        let title = format!(" {} ({}/{}) ", a.output_title, a.output_scroll + 1, a.output_lines.len());
        f.render_widget(Paragraph::new(Text::from(shown)).block(Block::default().borders(Borders::ALL).title(title).border_style(Style::default().fg(CY))).style(Style::default().fg(FG)), right);
    } else {
        let dl = a.real_idx().map(|i| { let changed = a.entries[i].enabled && file_changed_since_backup(&a.entries[i]); detail_lines(&a.entries[i], changed) }).unwrap_or_else(|| vec![Line::from(Span::styled("Select a file", Style::default().fg(DM).add_modifier(Modifier::ITALIC)))]);
        f.render_widget(Paragraph::new(Text::from(dl)).block(Block::default().borders(Borders::ALL).title(" Details ").border_style(Style::default().fg(DM))).style(Style::default().fg(FG)), detail_area);
        f.render_widget(Clear, preview_area);
        if let Some(i) = a.real_idx() {
            let e = &a.entries[i];
            if !e.is_dir {
                let src = if e.path.starts_with("~/") { home().join(&e.path[2..]) } else { PathBuf::from(&e.path) };
                f.render_widget(Paragraph::new(Text::from(bat_preview(&src, 20))).block(Block::default().borders(Borders::ALL).title(" Preview ").border_style(Style::default().fg(DM))).style(Style::default().fg(FG)), preview_area);
            } else {
                let src = if e.path.starts_with("~/") { home().join(&e.path[2..]) } else { PathBuf::from(&e.path) };
                f.render_widget(Paragraph::new(Text::from(dir_preview(&src))).block(Block::default().borders(Borders::ALL).title(" Preview ").border_style(Style::default().fg(DM))).style(Style::default().fg(FG)), preview_area);
            }
        } else { f.render_widget(Paragraph::new("Select a file").block(Block::default().borders(Borders::ALL).title(" Preview ").border_style(Style::default().fg(DM))).style(Style::default().fg(DM)), preview_area); }
    }
    f.render_widget(Paragraph::new(Line::from(Span::styled(&a.status, Style::default().fg(CY)))).block(Block::default().borders(Borders::TOP).border_style(Style::default().fg(DM))), stat);
    if a.mode == Mode::Help { help_pop(f, area); }
    if let Some(ref dk) = a.dialog { dlg_pop(f, area, dk); }
}

fn help_pop(f: &mut Frame, a: Rect) { let e: &[(&str,&str)] = &[("","fedora-dotfiles TUI"),("",""),("↑↓jk","Move"),("Space","Toggle"),("!@#$","Toggle all (Sys|KDE|Cach|Usr)"),("1-4","Category"),("a","All"),("R","Restore selected"),("Tab","Next"),("/","Filter (Esc)"),("",""),("b","Backup"),("r","Restore all"),("d","Diff"),("",""),("q","Quit  h/Esc  Close")]; let text: Vec<Line> = e.iter().map(|(k,d)| if k.is_empty()&&d.is_empty(){Line::default()} else if k.is_empty(){Line::from(Span::styled(*d,Style::default().fg(FG)))} else{Line::from(vec![Span::styled(format!("{} ",k),Style::default().fg(CY).add_modifier(Modifier::BOLD)),Span::styled(*d,Style::default().fg(FG))])}).collect(); let pop = centered(a, 44, (text.len()+2) as u16); f.render_widget(Clear, pop); f.render_widget(Paragraph::new(Text::from(text)).block(Block::default().borders(Borders::ALL).title(" Help ").border_style(Style::default().fg(CY)).style(Style::default().bg(BG))), pop); }
fn dlg_pop(f: &mut Frame, a: Rect, dk: &DialogKind) { match dk { DialogKind::DiffPreview(text) => { let lines: Vec<Line> = text.lines().map(|l| { if l.starts_with('+') && !l.starts_with("+++") { Line::from(Span::styled(l.to_string(), Style::default().fg(GN))) } else if l.starts_with('-') && !l.starts_with("---") { Line::from(Span::styled(l.to_string(), Style::default().fg(RD))) } else if l.starts_with('@') { Line::from(Span::styled(l.to_string(), Style::default().fg(CY))) } else { Line::from(Span::styled(l.to_string(), Style::default().fg(DM))) } }).collect(); let pop = centered(a, 80, (lines.len() as u16 + 3).min(24)); f.render_widget(Clear, pop); f.render_widget(Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title(" Dry-run Diff ").border_style(Style::default().fg(CY)).style(Style::default().bg(BG))), pop); } DialogKind::ConfirmRestore(dry) => { let lines = vec![Line::from(Span::styled("Pull from GitHub and overwrite system files.",Style::default().fg(YW))),Line::from(Span::styled("/etc/* uses sudo. Existing files backed up.",Style::default().fg(DM))),Line::default(),Line::from(Span::styled(dry.clone(),Style::default().fg(DM))),Line::default(),Line::from(Span::styled("[y] Yes    [n] No  [d] View diff",Style::default().fg(CY).add_modifier(Modifier::BOLD)))]; let pop = centered(a, 70, (lines.len()+2) as u16); f.render_widget(Clear, pop); f.render_widget(Paragraph::new(Text::from(lines)).block(Block::default().borders(Borders::ALL).title(" Restore? ").border_style(Style::default().fg(YW)).style(Style::default().bg(BG))), pop); } } }
fn centered(r: Rect, w: u16, h: u16) -> Rect { Rect { x: r.x+(r.width.saturating_sub(w)/2), y: r.y+(r.height.saturating_sub(h)/2), width: w.min(r.width), height: h.min(r.height) } }

// ── Input ─────────────────────────────────────────────────────────────
fn handle(k: KeyEvent, a: &mut App) -> bool { match a.mode { Mode::Working => true, Mode::Normal => key_normal(k, a), Mode::Filter => key_filter(k, a), Mode::Help => { a.mode = Mode::Normal; true }, Mode::Dialog => key_dlg(k, a), Mode::Output => key_output(k, a) } }
fn key_normal(k: KeyEvent, a: &mut App) -> bool { let n = a.count(); match k.code { KeyCode::Char('q') => false, KeyCode::Esc => true, KeyCode::Char('h') => { a.mode = Mode::Help; true }, KeyCode::Char(' ') | KeyCode::Enter => { a.toggle(); true }, KeyCode::Char('b') => { a.launch("Backing up...", vec!["backup".into()]); true }, KeyCode::Char('r') => { a.launch("Dry-run...", vec!["restore".into(),"--dry-run".into(),"--yes".into()]); true }, KeyCode::Char('R') => { if let Some(i) = a.real_idx() { let e = &a.entries[i]; let (rp, su) = repo_file(e); let src = source_path(e); if rp.exists() { if su { let _ = Command::new("sudo").args(["cp", rp.to_str().unwrap(), src.to_str().unwrap()]).spawn(); } else { let _ = fs::copy(&rp, &src); } a.set_status(&format!("✓ Restored {}", e.path)); } else { a.set_status(&format!("✗ Not in repo: {}", e.path)); } } true }, KeyCode::Char('d') => { if let Some(i) = a.real_idx() { let diff_lines = unified_diff(&a.entries[i]); a.output_lines = diff_lines; a.output_scroll = 0; a.output_title = format!("Diff: {}", a.entries[i].path); a.mode = Mode::Output; } true }, KeyCode::Char('1') => { a.category = 1; a.sel = 0; a.scroll = 0; true }, KeyCode::Char('2') => { a.category = 2; a.sel = 0; a.scroll = 0; true }, KeyCode::Char('3') => { a.category = 3; a.sel = 0; a.scroll = 0; true }, KeyCode::Char('4') => { a.category = 4; a.sel = 0; a.scroll = 0; true }, KeyCode::Char('!') => { a.toggle_category(1); true }, KeyCode::Char('@') => { a.toggle_category(2); true }, KeyCode::Char('#') => { a.toggle_category(3); true }, KeyCode::Char('$') => { a.toggle_category(4); true }, KeyCode::Char('a') => { a.category = 0; a.sel = 0; a.scroll = 0; true }, KeyCode::Char('/') => { a.mode = Mode::Filter; true }, KeyCode::Tab => { a.category = (a.category + 1) % CATS.len(); a.sel = 0; a.scroll = 0; true }, KeyCode::BackTab => { a.category = if a.category == 0 { CATS.len() - 1 } else { a.category - 1 }; a.sel = 0; a.scroll = 0; true }, KeyCode::Up | KeyCode::Char('k') => { if n > 0 { a.sel = if a.sel == 0 { n - 1 } else { a.sel - 1 }; } true }, KeyCode::Down | KeyCode::Char('j') => { if n > 0 { a.sel = (a.sel + 1) % n; } true }, _ => true } }
fn key_output(k: KeyEvent, a: &mut App) -> bool { match k.code { KeyCode::Esc | KeyCode::Char('q') => { a.mode = Mode::Normal; true }, KeyCode::Up | KeyCode::Char('k') => { if a.output_scroll > 0 { a.output_scroll -= 1; } true }, KeyCode::Down | KeyCode::Char('j') => { let max = a.output_lines.len().saturating_sub(1); if a.output_scroll < max { a.output_scroll += 1; } true }, _ => true } }
fn key_filter(k: KeyEvent, a: &mut App) -> bool { match k.code { KeyCode::Esc => { a.filter.clear(); a.mode = Mode::Normal; a.sel = 0; a.scroll = 0; true }, KeyCode::Enter => { a.mode = Mode::Normal; true }, KeyCode::Backspace => { a.filter.pop(); a.sel = 0; a.scroll = 0; true }, KeyCode::Char(c) => { a.filter.push(c); a.sel = 0; a.scroll = 0; true }, _ => true } }
fn key_dlg(k: KeyEvent, a: &mut App) -> bool { if let Some(DialogKind::ConfirmRestore(_)) = a.dialog { match k.code { KeyCode::Char('y')|KeyCode::Char('Y') => { a.launch("Restoring...", vec!["restore".into(),"--yes".into()]); true }, KeyCode::Char('d')|KeyCode::Char('D') => { let (_, dry) = cli_blocking(&["restore","--dry-run","--yes"]); a.dialog = Some(DialogKind::DiffPreview(dry)); true }, KeyCode::Char('n')|KeyCode::Char('N')|KeyCode::Esc => { a.dialog = None; a.mode = Mode::Normal; true }, _ => true } } else { a.dialog = None; a.mode = Mode::Normal; true } }
fn repo_file(e: &DotfileEntry) -> (PathBuf, bool) { if e.path.starts_with("~/") { (repo_dir().join("home").join(&e.path[2..]), false) } else if e.path.starts_with('/') { (repo_dir().join("root").join(&e.path[1..]), e.sudo) } else { (repo_dir().join("home").join(&e.path), false) } }
fn source_path(e: &DotfileEntry) -> PathBuf { if e.path.starts_with("~/") { home().join(&e.path[2..]) } else { PathBuf::from(&e.path) } }

fn extract_cli() -> PathBuf {
    let dest = home().join(".cargo/bin/fedora-dotfiles");
    if !dest.exists() {
        let _ = std::fs::create_dir_all(dest.parent().unwrap());
        if std::fs::write(&dest, include_str!("../fedora-dotfiles")).is_ok() {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755));
        }
    }
    dest
}

fn ensure_manifest() { if !manifest().exists() { let json = include_str!("default_manifest.json"); if let Ok(entries) = serde_json::from_str::<Vec<DotfileEntry>>(json) { save(&entries); } else { let p = manifest(); let _ = std::fs::create_dir_all(p.parent().unwrap()); std::fs::write(&p, "[]").ok(); } } }

fn main() -> io::Result<()> { extract_cli(); ensure_manifest(); let wiz = std::env::args().any(|a| a == "--wizard" || a == "-w") || !config_file().exists(); if wiz { match wizard() { Ok(false) => return Ok(()), _ => {} } } let mut t = ratatui::init(); t.clear().unwrap(); let mut a = App::new(); let r = run(&mut t, &mut a); ratatui::restore(); r }
fn run(t: &mut ratatui::DefaultTerminal, a: &mut App) -> io::Result<()> { loop { t.draw(|f| ui(f, a))?; if a.mode == Mode::Working { if let Some((ok, out)) = a.poll() { a.update_repo(); if a.label.contains("Dry-run") { a.dialog = Some(DialogKind::ConfirmRestore(out.chars().take(400).collect())); a.mode = Mode::Dialog; } else { a.output_lines = colorize_output(&out); a.output_scroll = 0; a.output_title = a.label.split("...").next().unwrap_or("Result").to_string(); a.mode = Mode::Output; a.set_status(if ok { "✓ Done — q to close" } else { "✗ Failed — q to close" }); } continue; } while event::poll(Duration::from_millis(0))? { let _ = event::read()?; } thread::sleep(Duration::from_millis(16)); continue; } if event::poll(Duration::from_millis(100))? { if let Event::Key(k) = event::read()? { if !handle(k, a) { return Ok(()); } } } } }