iconmate 1.2.1

CLI to fetch icons and save them into your Vite, NextJS, or similar TypeScript project
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use tui_textarea::{Input, Key};

use crate::app_state::{App, AppFocus};
use crate::sync::{self, SyncPlan};
use crate::utils::popup_area;
use crate::views::theme;

#[derive(Debug)]
pub enum SyncPopupState {
    Plan(SyncPlan),
    Error(String),
}

impl App {
    pub fn init_sync_popup(&mut self) {
        let state = match build_sync_plan(&self.config) {
            Ok(plan) => SyncPopupState::Plan(plan),
            Err(err) => SyncPopupState::Error(format!("Failed to compute sync plan:\n{err}")),
        };
        self.sync_popup_state = Some(state);
        self.app_focus = AppFocus::SyncPopup;
    }

    pub fn handlekeys_sync_popup(&mut self, input: Input) {
        match input.key {
            Key::Esc | Key::Char('q') | Key::Char('S') | Key::Char('s') => {
                self.sync_popup_state = None;
                self.app_focus = AppFocus::Main;
            }
            _ => {}
        }
    }
}

fn build_sync_plan(config: &crate::app_state::AppConfig) -> anyhow::Result<SyncPlan> {
    let folder = PathBuf::from(&config.folder);
    let barrel_file = config.flutter_barrel_file.as_deref().map(Path::new);
    let renames: HashMap<String, String> = HashMap::new();

    let ctx = sync::SyncContext {
        folder: &folder,
        preset: &config.preset,
        flutter_barrel_file: barrel_file,
        flutter_barrel_class: config.flutter_barrel_class.as_deref(),
        renames: &renames,
    };
    sync::compute_sync_plan(&ctx)
}

pub fn render_sync_popup(f: &mut Frame, app: &App) {
    let area = popup_area(f.area(), 84, 24);
    let body_area = theme::render_popup_shell(f, area, "Sync");

    let state = match app.sync_popup_state.as_ref() {
        Some(s) => s,
        None => return,
    };

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(2),
            Constraint::Length(1),
            Constraint::Min(0),
        ])
        .split(body_area);

    let description = Paragraph::new(vec![
        Line::from(Span::styled(
            "Checks drift if you edited your icons folder manually without iconmate.",
            Style::default().fg(theme::MUTED_TEXT),
        )),
        Line::from(Span::styled(
            "Read-only in the TUI — run `iconmate sync --apply` to write.",
            Style::default().fg(theme::SUBTLE_TEXT),
        )),
    ])
    .alignment(Alignment::Left);
    f.render_widget(description, layout[0]);

    match state {
        SyncPopupState::Error(msg) => {
            let body = Paragraph::new(msg.clone())
                .style(Style::default().fg(theme::ERROR))
                .alignment(Alignment::Left);
            f.render_widget(body, layout[2]);
        }
        SyncPopupState::Plan(plan) => {
            let body = Paragraph::new(plan_to_lines(plan)).alignment(Alignment::Left);
            f.render_widget(body, layout[2]);
        }
    }
}

const ADD_COLOR: Color = theme::ACCENT;
const PRUNE_COLOR: Color = theme::ERROR;
const WARN_COLOR: Color = Color::Rgb(250, 204, 21);

fn plan_to_lines(plan: &SyncPlan) -> Vec<Line<'static>> {
    let text = Style::default().fg(theme::TEXT);
    let muted = Style::default().fg(theme::MUTED_TEXT);
    let subtle = Style::default().fg(theme::SUBTLE_TEXT);

    let mut lines: Vec<Line<'static>> = Vec::new();
    lines.push(Line::from(vec![
        Span::styled("Barrel: ", muted),
        Span::styled(plan.barrel_location.clone(), text),
    ]));
    lines.push(Line::from(vec![
        Span::styled("Preset: ", muted),
        Span::styled(plan.preset.clone(), text),
    ]));
    lines.push(Line::from(""));

    if plan.is_clean() {
        lines.push(Line::from(Span::styled(
            "● It's clean and synced!",
            Style::default()
                .fg(theme::ACCENT)
                .add_modifier(Modifier::BOLD),
        )));
        return lines;
    }

    if !plan.additions.is_empty() {
        lines.push(Line::from(Span::styled(
            format!("Would add ({}):", plan.additions.len()),
            Style::default().fg(ADD_COLOR).add_modifier(Modifier::BOLD),
        )));
        for a in &plan.additions {
            lines.push(Line::from(vec![
                Span::styled(
                    format!("  + {:<24}", a.identifier),
                    Style::default().fg(ADD_COLOR),
                ),
                Span::styled(" → ", muted),
                Span::styled(a.file_path.clone(), Style::default().fg(ADD_COLOR)),
                Span::styled("  (orphan file)", subtle),
            ]));
        }
        lines.push(Line::from(""));
    }

    if !plan.removals.is_empty() {
        lines.push(Line::from(Span::styled(
            format!("Would prune ({}):", plan.removals.len()),
            Style::default()
                .fg(PRUNE_COLOR)
                .add_modifier(Modifier::BOLD),
        )));
        for r in &plan.removals {
            lines.push(Line::from(vec![
                Span::styled(
                    format!("  - {:<24}", r.identifier),
                    Style::default().fg(PRUNE_COLOR),
                ),
                Span::styled(" → ", muted),
                Span::styled(r.file_path.clone(), Style::default().fg(PRUNE_COLOR)),
                Span::styled("  (file missing)", subtle),
            ]));
        }
        lines.push(Line::from(""));
    }

    if !plan.collisions.is_empty() {
        lines.push(Line::from(Span::styled(
            format!("Collisions ({}):", plan.collisions.len()),
            Style::default().fg(WARN_COLOR).add_modifier(Modifier::BOLD),
        )));
        for c in &plan.collisions {
            lines.push(Line::from(vec![
                Span::styled(
                    format!("  ! {}", c.inferred_identifier),
                    Style::default().fg(WARN_COLOR),
                ),
                Span::styled(" collides with ", muted),
                Span::styled(
                    format!("`{}`", c.conflicting_identifier),
                    Style::default().fg(WARN_COLOR),
                ),
                Span::styled(format!(" (from {})", c.file_path), subtle),
            ]));
        }
        lines.push(Line::from(""));
    }

    if !plan.additions.is_empty() || !plan.removals.is_empty() {
        lines.push(Line::from(Span::styled(
            "Run with --apply to write additions.",
            muted,
        )));
        if !plan.removals.is_empty() {
            lines.push(Line::from(Span::styled(
                "Run with --apply --prune to also remove orphan entries.",
                muted,
            )));
        }
    }
    if !plan.collisions.is_empty() {
        lines.push(Line::from(Span::styled(
            "Resolve collisions with --rename <inferred>=<newName>, or rename the SVG on disk.",
            muted,
        )));
    }

    lines
}