use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span, Text},
widgets::{
Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState, Tabs, Wrap,
},
Frame, Terminal,
};
use std::{
collections::HashMap,
io::{self, Stdout},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::mpsc;
use crate::{
detection::{Finding, SecretDetector},
CargoCrypt, CryptoResult,
};
mod app;
mod clipboard;
mod dashboard;
mod events;
mod notifications;
mod secrets;
mod security;
mod styles;
mod widgets;
pub use app::*;
pub use clipboard::*;
pub use dashboard::*;
pub use events::*;
pub use notifications::*;
pub use secrets::*;
pub use security::*;
pub use styles::*;
pub use widgets::*;
#[derive(Debug, Clone)]
pub struct TuiApp {
pub current_tab: usize,
pub nav_state: NavigationState,
pub dashboard: Dashboard,
pub secrets: SecretManager,
pub security: SecurityManager,
pub clipboard: ClipboardManager,
pub notifications: NotificationManager,
pub metrics: PerformanceMetrics,
pub animation: AnimationState,
pub command_mode: CommandMode,
pub input_buffer: String,
pub should_quit: bool,
}
#[derive(Debug, Clone)]
pub struct NavigationState {
pub mode: NavigationMode,
pub selected_item: usize,
pub scroll_offset: usize,
pub list_state: ListState,
pub scrollbar_state: ScrollbarState,
}
#[derive(Debug, Clone, PartialEq)]
pub enum NavigationMode {
Normal,
Insert,
Visual,
Command,
}
#[derive(Debug, Clone)]
pub struct CommandMode {
pub active: bool,
pub input: String,
pub history: Vec<String>,
pub history_index: usize,
}
#[derive(Debug, Clone)]
pub struct AnimationState {
pub frame: usize,
pub last_update: Instant,
pub spinning_cargo: usize,
pub security_scan_progress: f64,
pub pulse_phase: f64,
}
#[derive(Debug, Clone)]
pub struct PerformanceMetrics {
pub fps: f64,
pub frame_time: Duration,
pub memory_usage: u64,
pub cpu_usage: f64,
pub active_secrets: usize,
pub scan_results: usize,
}
#[derive(Debug, Clone, Copy)]
pub enum Tab {
Dashboard = 0,
Secrets = 1,
Security = 2,
Repository = 3,
Settings = 4,
}
impl Tab {
pub fn titles() -> Vec<&'static str> {
vec!["🏠 Dashboard", "🔐 Secrets", "🛡️ Security", "📦 Repository", "⚙️ Settings"]
}
pub fn from_index(index: usize) -> Option<Tab> {
match index {
0 => Some(Tab::Dashboard),
1 => Some(Tab::Secrets),
2 => Some(Tab::Security),
3 => Some(Tab::Repository),
4 => Some(Tab::Settings),
_ => None,
}
}
}
impl Default for TuiApp {
fn default() -> Self {
Self {
current_tab: 0,
nav_state: NavigationState {
mode: NavigationMode::Normal,
selected_item: 0,
scroll_offset: 0,
list_state: ListState::default(),
scrollbar_state: ScrollbarState::default(),
},
dashboard: Dashboard::default(),
secrets: SecretManager::default(),
security: SecurityManager::default(),
clipboard: ClipboardManager::default(),
notifications: NotificationManager::default(),
metrics: PerformanceMetrics {
fps: 60.0,
frame_time: Duration::from_millis(16),
memory_usage: 0,
cpu_usage: 0.0,
active_secrets: 0,
scan_results: 0,
},
animation: AnimationState {
frame: 0,
last_update: Instant::now(),
spinning_cargo: 0,
security_scan_progress: 0.0,
pulse_phase: 0.0,
},
command_mode: CommandMode {
active: false,
input: String::new(),
history: Vec::new(),
history_index: 0,
},
input_buffer: String::new(),
should_quit: false,
}
}
}
impl TuiApp {
pub fn new() -> Self {
Self::default()
}
pub async fn init(&mut self, crypt: Arc<CargoCrypt>) -> CryptoResult<()> {
self.dashboard.init(crypt.clone()).await?;
self.secrets.init(crypt.clone()).await?;
self.security.init(crypt.clone()).await?;
self.clipboard.set_auto_clear(Duration::from_secs(30));
self.update_metrics().await?;
Ok(())
}
pub async fn update(&mut self) -> CryptoResult<()> {
let now = Instant::now();
if now.duration_since(self.animation.last_update) > Duration::from_millis(50) {
self.animation.frame = (self.animation.frame + 1) % 8;
self.animation.spinning_cargo = (self.animation.spinning_cargo + 1) % 4;
self.animation.pulse_phase = (self.animation.pulse_phase + 0.1) % (2.0 * std::f64::consts::PI);
self.animation.last_update = now;
}
self.dashboard.update().await?;
if self.security.is_scanning() {
self.animation.security_scan_progress = self.security.scan_progress();
}
self.update_metrics().await?;
self.clipboard.update();
self.notifications.update();
Ok(())
}
async fn update_metrics(&mut self) -> CryptoResult<()> {
self.metrics.active_secrets = self.secrets.count().await;
self.metrics.scan_results = self.security.findings_count().await;
let frame_time = Instant::now().duration_since(self.animation.last_update);
self.metrics.frame_time = frame_time;
self.metrics.fps = 1.0 / frame_time.as_secs_f64();
Ok(())
}
pub async fn handle_input(&mut self, event: Event) -> CryptoResult<()> {
match event {
Event::Key(key) => {
if self.command_mode.active {
self.handle_command_mode_input(key).await?;
} else {
self.handle_normal_mode_input(key).await?;
}
}
Event::Mouse(_) => {
}
Event::Resize(_, _) => {
}
_ => {}
}
Ok(())
}
async fn handle_normal_mode_input(&mut self, key: crossterm::event::KeyEvent) -> CryptoResult<()> {
match key.code {
KeyCode::Char('h') | KeyCode::Left => self.nav_left(),
KeyCode::Char('l') | KeyCode::Right => self.nav_right(),
KeyCode::Char('j') | KeyCode::Down => self.nav_down(),
KeyCode::Char('k') | KeyCode::Up => self.nav_up(),
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::NONE) => self.nav_top(),
KeyCode::Char('G') => self.nav_bottom(),
KeyCode::Char('1') => self.switch_tab(0),
KeyCode::Char('2') => self.switch_tab(1),
KeyCode::Char('3') => self.switch_tab(2),
KeyCode::Char('4') => self.switch_tab(3),
KeyCode::Char('5') => self.switch_tab(4),
KeyCode::Tab => self.next_tab(),
KeyCode::BackTab => self.prev_tab(),
KeyCode::Enter => self.activate_selected().await?,
KeyCode::Char(' ') => self.toggle_selected().await?,
KeyCode::Char('r') => self.refresh().await?,
KeyCode::Char('s') => self.start_security_scan().await?,
KeyCode::Char('c') => self.copy_to_clipboard().await?,
KeyCode::Char(':') => self.enter_command_mode(),
KeyCode::Char('/') => self.enter_search_mode(),
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.should_quit = true,
KeyCode::Esc => self.cancel_current_action(),
_ => {}
}
Ok(())
}
async fn handle_command_mode_input(&mut self, key: crossterm::event::KeyEvent) -> CryptoResult<()> {
match key.code {
KeyCode::Enter => {
self.execute_command().await?;
self.exit_command_mode();
}
KeyCode::Esc => self.exit_command_mode(),
KeyCode::Backspace => {
self.command_mode.input.pop();
}
KeyCode::Char(c) => {
self.command_mode.input.push(c);
}
_ => {}
}
Ok(())
}
fn nav_left(&mut self) {
if self.nav_state.selected_item > 0 {
self.nav_state.selected_item -= 1;
}
}
fn nav_right(&mut self) {
self.nav_state.selected_item += 1;
}
fn nav_down(&mut self) {
self.nav_state.selected_item += 1;
self.nav_state.list_state.select(Some(self.nav_state.selected_item));
}
fn nav_up(&mut self) {
if self.nav_state.selected_item > 0 {
self.nav_state.selected_item -= 1;
self.nav_state.list_state.select(Some(self.nav_state.selected_item));
}
}
fn nav_top(&mut self) {
self.nav_state.selected_item = 0;
self.nav_state.list_state.select(Some(0));
}
fn nav_bottom(&mut self) {
}
fn switch_tab(&mut self, tab: usize) {
if tab < Tab::titles().len() {
self.current_tab = tab;
self.nav_state.selected_item = 0;
self.nav_state.list_state.select(Some(0));
}
}
fn next_tab(&mut self) {
self.current_tab = (self.current_tab + 1) % Tab::titles().len();
}
fn prev_tab(&mut self) {
self.current_tab = if self.current_tab == 0 {
Tab::titles().len() - 1
} else {
self.current_tab - 1
};
}
fn enter_command_mode(&mut self) {
self.command_mode.active = true;
self.command_mode.input.clear();
}
fn enter_search_mode(&mut self) {
self.command_mode.active = true;
self.command_mode.input = "/".to_string();
}
fn exit_command_mode(&mut self) {
self.command_mode.active = false;
if !self.command_mode.input.is_empty() {
self.command_mode.history.push(self.command_mode.input.clone());
}
self.command_mode.input.clear();
}
async fn execute_command(&mut self) -> CryptoResult<()> {
let command = self.command_mode.input.trim();
match command {
"q" | "quit" => self.should_quit = true,
"w" | "write" => self.save_current().await?,
"wq" => {
self.save_current().await?;
self.should_quit = true;
}
"refresh" | "r" => self.refresh().await?,
"scan" => self.start_security_scan().await?,
"clear" => self.clear_clipboard(),
cmd if cmd.starts_with("search ") => {
let query = &cmd[7..];
self.search(query).await?;
}
cmd if cmd.starts_with("/") => {
let query = &cmd[1..];
self.search(query).await?;
}
_ => {
self.notifications.add_warning(&format!("Unknown command: {}", command));
}
}
Ok(())
}
async fn activate_selected(&mut self) -> CryptoResult<()> {
match Tab::from_index(self.current_tab) {
Some(Tab::Secrets) => {
self.secrets.activate_selected(self.nav_state.selected_item).await?;
}
Some(Tab::Security) => {
self.security.activate_selected(self.nav_state.selected_item).await?;
}
_ => {}
}
Ok(())
}
async fn toggle_selected(&mut self) -> CryptoResult<()> {
match Tab::from_index(self.current_tab) {
Some(Tab::Secrets) => {
self.secrets.toggle_selected(self.nav_state.selected_item).await?;
}
_ => {}
}
Ok(())
}
async fn refresh(&mut self) -> CryptoResult<()> {
self.dashboard.refresh().await?;
self.secrets.refresh().await?;
self.security.refresh().await?;
self.notifications.add_info("Refreshed all data");
Ok(())
}
async fn start_security_scan(&mut self) -> CryptoResult<()> {
self.security.start_scan().await?;
self.notifications.add_info("Security scan started");
Ok(())
}
async fn copy_to_clipboard(&mut self) -> CryptoResult<()> {
match Tab::from_index(self.current_tab) {
Some(Tab::Secrets) => {
if let Some(secret) = self.secrets.get_selected(self.nav_state.selected_item).await? {
self.clipboard.copy_secret(&secret).await?;
self.notifications.add_success("Secret copied to clipboard (auto-clear in 30s)");
}
}
_ => {}
}
Ok(())
}
async fn search(&mut self, query: &str) -> CryptoResult<()> {
match Tab::from_index(self.current_tab) {
Some(Tab::Secrets) => {
self.secrets.search(query).await?;
}
Some(Tab::Security) => {
self.security.search(query).await?;
}
_ => {}
}
Ok(())
}
async fn save_current(&mut self) -> CryptoResult<()> {
match Tab::from_index(self.current_tab) {
Some(Tab::Secrets) => {
self.secrets.save().await?;
self.notifications.add_success("Secrets saved");
}
_ => {}
}
Ok(())
}
fn clear_clipboard(&mut self) {
self.clipboard.clear();
self.notifications.add_info("Clipboard cleared");
}
fn cancel_current_action(&mut self) {
self.command_mode.active = false;
self.command_mode.input.clear();
}
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
.split(f.size());
self.render_header(f, chunks[0]);
self.render_content(f, chunks[1]);
self.render_footer(f, chunks[2]);
}
fn render_header<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
let titles = Tab::titles();
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title("CargoCrypt"))
.style(CargoStyle::default())
.highlight_style(CargoStyle::selected())
.select(self.current_tab);
f.render_widget(tabs, area);
}
fn render_content<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
match Tab::from_index(self.current_tab) {
Some(Tab::Dashboard) => self.dashboard.render(f, area, &self.animation),
Some(Tab::Secrets) => self.secrets.render(f, area, &self.nav_state),
Some(Tab::Security) => self.security.render(f, area, &self.nav_state, &self.animation),
Some(Tab::Repository) => self.render_repository_tab(f, area),
Some(Tab::Settings) => self.render_settings_tab(f, area),
None => {}
}
}
fn render_footer<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0), Constraint::Length(20), ])
.split(area);
let status_text = if self.command_mode.active {
format!(":{}", self.command_mode.input)
} else {
format!("Mode: {} | {} | Press ':' for commands, 'q' to quit",
match self.nav_state.mode {
NavigationMode::Normal => "NORMAL",
NavigationMode::Insert => "INSERT",
NavigationMode::Visual => "VISUAL",
NavigationMode::Command => "COMMAND",
},
match Tab::from_index(self.current_tab) {
Some(Tab::Dashboard) => "hjkl:navigate r:refresh",
Some(Tab::Secrets) => "hjkl:navigate Space:toggle c:copy",
Some(Tab::Security) => "hjkl:navigate s:scan Enter:details",
Some(Tab::Repository) => "hjkl:navigate r:refresh",
Some(Tab::Settings) => "hjkl:navigate Enter:edit",
None => "hjkl:navigate",
}
)
};
let status = Paragraph::new(status_text)
.style(CargoStyle::default())
.block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[0]);
let metrics_text = format!(
"FPS: {:.1} | Secrets: {} | Findings: {}",
self.metrics.fps,
self.metrics.active_secrets,
self.metrics.scan_results
);
let metrics = Paragraph::new(metrics_text)
.style(CargoStyle::default())
.block(Block::default().borders(Borders::ALL));
f.render_widget(metrics, chunks[1]);
self.notifications.render(f, f.size());
}
fn render_repository_tab<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
let repo_info = Paragraph::new("Repository information will be displayed here")
.style(CargoStyle::default())
.block(Block::default().borders(Borders::ALL).title("Repository"));
f.render_widget(repo_info, area);
}
fn render_settings_tab<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect) {
let settings_info = Paragraph::new("Settings will be displayed here")
.style(CargoStyle::default())
.block(Block::default().borders(Borders::ALL).title("Settings"));
f.render_widget(settings_info, area);
}
}
pub async fn run_tui(crypt: Arc<CargoCrypt>) -> CryptoResult<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = TuiApp::new();
app.init(crypt).await?;
let (tx, mut rx) = mpsc::unbounded_channel();
let event_tx = tx.clone();
tokio::spawn(async move {
loop {
if let Ok(event) = event::read() {
if event_tx.send(event).is_err() {
break;
}
}
}
});
loop {
terminal.draw(|f| app.render(f))?;
tokio::select! {
Some(event) = rx.recv() => {
app.handle_input(event).await?;
}
_ = tokio::time::sleep(Duration::from_millis(16)) => {
app.update().await?;
}
}
if app.should_quit {
break;
}
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}