kagi-app 0.1.6

CLI application for Kagi secrets manager
Documentation
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Constraint;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Cell, Row, Table};
use std::io;

use super::layout;
use super::theme::Theme;

struct ImportItem {
    key: String,
    is_overwritten: bool,
}

struct App {
    items: Vec<ImportItem>,
    selected: usize,
    scope: String,
    file: String,
    confirmed: Option<bool>,
}

pub fn run_tui_import(
    imported: Vec<String>,
    overwritten: Vec<String>,
    scope: String,
    file: String,
) -> anyhow::Result<bool> {
    let mut items = Vec::new();
    for key in &imported {
        let is_overwritten = overwritten.contains(key);
        items.push(ImportItem {
            key: key.clone(),
            is_overwritten,
        });
    }

    let mut app = App {
        items,
        selected: 0,
        scope,
        file,
        confirmed: None,
    };

    let theme = Theme::default();
    layout::run_tui(|terminal| run_app(terminal, &mut app, &theme))?;
    Ok(app.confirmed == Some(true))
}

fn run_app(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    theme: &Theme,
) -> io::Result<()> {
    let tick_rate = std::time::Duration::from_millis(250);
    let mut last_tick = std::time::Instant::now();

    loop {
        terminal.draw(|f| draw_ui(f, app, theme))?;

        if app.confirmed.is_some() {
            return Ok(());
        }

        let timeout = tick_rate.saturating_sub(last_tick.elapsed());
        if crossterm::event::poll(timeout)?
            && let Event::Key(key) = event::read()?
        {
            if key.kind != KeyEventKind::Press {
                continue;
            }
            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => {
                    app.confirmed = Some(false);
                    return Ok(());
                }
                KeyCode::Char('y') => {
                    app.confirmed = Some(true);
                    return Ok(());
                }
                KeyCode::Char('n') => {
                    app.confirmed = Some(false);
                    return Ok(());
                }
                KeyCode::Down if app.selected + 1 < app.items.len() => {
                    app.selected += 1;
                }
                KeyCode::Up if app.selected > 0 => {
                    app.selected -= 1;
                }
                _ => {}
            }
        }
        if last_tick.elapsed() >= tick_rate {
            last_tick = std::time::Instant::now();
        }
    }
}

fn draw_ui(f: &mut ratatui::Frame, app: &App, theme: &Theme) {
    let overwritten_count = app.items.iter().filter(|i| i.is_overwritten).count();
    let title = if overwritten_count > 0 {
        format!(
            "Import Preview — {} ({} overwritten)",
            app.scope, overwritten_count
        )
    } else {
        format!("Import Preview — {}", app.scope)
    };

    let content = layout::draw_frame(f, theme, &title, "y=confirm  n=cancel  q=quit  ↑↓=navigate");

    let header = Row::new(vec!["Key", "Status"])
        .style(theme.header_style())
        .height(1);

    let rows: Vec<Row> = app
        .items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            let style = if i == app.selected {
                theme.highlight_style()
            } else {
                Style::default()
            };
            let status = if item.is_overwritten {
                Span::styled("overwrite", theme.warning_style())
            } else {
                Span::styled("new", theme.success_style())
            };
            Row::new(vec![
                Cell::new(Span::styled(&item.key, theme.key_hint_style())).style(style),
                Cell::new(status).style(style),
            ])
            .height(1)
        })
        .collect();

    let table = Table::new(
        rows,
        [Constraint::Percentage(50), Constraint::Percentage(50)],
    )
    .header(header)
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title(format!("Keys from {} ({})", app.file, app.items.len()))
            .title_style(theme.header_style())
            .border_style(theme.block_style()),
    );
    f.render_widget(table, content);
}