kagi-app 0.1.6

CLI application for Kagi secrets manager
Documentation
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use kagi_domain::repository::secret_repo::SecretRepository;
use kagi_store::fs_store::FileStore;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use std::io;

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

struct App {
    env: String,
    affected: Vec<String>,
    confirm_input: String,
    confirmed: Option<bool>,
}

pub fn run_tui_env_del(store: &FileStore, env: &str) -> anyhow::Result<bool> {
    let all_services = store.list_services()?;
    let prefix = format!("/{env}");
    let affected: Vec<String> = all_services
        .into_iter()
        .filter(|s| s.ends_with(&prefix))
        .collect();

    let mut app = App {
        env: env.to_string(),
        affected,
        confirm_input: String::new(),
        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::Esc => {
                    app.confirmed = Some(false);
                    return Ok(());
                }
                KeyCode::Enter if app.confirm_input == app.env => {
                    app.confirmed = Some(true);
                    return Ok(());
                }
                KeyCode::Backspace => {
                    app.confirm_input.pop();
                }
                KeyCode::Char(c) => {
                    app.confirm_input.push(c);
                }
                _ => {}
            }
        }
        if last_tick.elapsed() >= tick_rate {
            last_tick = std::time::Instant::now();
        }
    }
}

fn draw_ui(f: &mut ratatui::Frame, app: &App, theme: &Theme) {
    let content = layout::draw_frame(f, theme, "Delete Environment", "Esc=cancel  Enter=confirm");

    let error_style = theme.error_style();
    let warning_style = theme.warning_style();
    let muted_style = theme.muted_style();
    let white_style = Style::default().fg(theme.text());

    let block = Block::default()
        .borders(Borders::ALL)
        .title("Confirm")
        .title_style(error_style)
        .border_style(theme.block_style());

    let mut lines: Vec<Line> = Vec::new();
    let warning_text = format!(
        "This will delete '{}' from the following services:",
        app.env
    );
    lines.push(Line::from(vec![
        Span::styled("WARNING: ", error_style),
        Span::styled(warning_text, warning_style),
    ]));
    lines.push(Line::from(""));

    if app.affected.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "No services currently have this environment.",
            muted_style,
        )]));
    } else {
        for scope in &app.affected {
            lines.push(Line::from(vec![
                Span::styled("", muted_style),
                Span::styled(scope, white_style),
            ]));
        }
    }

    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled("Type ", muted_style),
        Span::styled(&app.env, error_style),
        Span::styled(" to confirm deletion, or Esc to cancel.", muted_style),
    ]));
    lines.push(Line::from(""));
    let input_display = format!(
        "> {}{}",
        app.confirm_input,
        if app.confirm_input.is_empty() {
            " "
        } else {
            ""
        }
    );
    let input_style = if app.confirm_input == app.env {
        Style::default()
            .fg(theme.success())
            .add_modifier(Modifier::BOLD)
    } else {
        white_style
    };
    lines.push(Line::from(vec![Span::styled(input_display, input_style)]));

    let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });

    f.render_widget(Clear, content);
    f.render_widget(paragraph, content);
}