mod compliance;
mod components;
mod dependencies;
mod graph_changes;
mod helpers;
mod licenses;
mod matrix;
pub mod mouse;
mod multi_diff;
mod quality;
mod sidebyside;
mod source;
mod timeline;
mod vulnerabilities;
use crate::config::TuiPreferences;
use crate::tui::toggle_theme;
use crossterm::event::{
self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
};
use std::time::Duration;
pub use mouse::handle_mouse_event;
#[derive(Debug)]
pub enum Event {
Key(KeyEvent),
Mouse(MouseEvent),
Tick,
Resize(u16, u16),
}
pub struct EventHandler {
tick_rate: Duration,
}
impl EventHandler {
pub const fn new(tick_rate: u64) -> Self {
Self {
tick_rate: Duration::from_millis(tick_rate),
}
}
pub fn next(&self) -> Result<Event, std::io::Error> {
if event::poll(self.tick_rate)? {
match event::read()? {
CrosstermEvent::Key(key) => Ok(Event::Key(key)),
CrosstermEvent::Mouse(mouse) => Ok(Event::Mouse(mouse)),
CrosstermEvent::Resize(width, height) => Ok(Event::Resize(width, height)),
_ => Ok(Event::Tick),
}
} else {
Ok(Event::Tick)
}
}
}
impl Default for EventHandler {
fn default() -> Self {
Self::new(250)
}
}
pub fn handle_key_event(app: &mut super::App, key: KeyEvent) {
app.clear_status_message();
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
handle_yank(app);
return;
}
if app.overlays.search.active {
match key.code {
KeyCode::Esc => app.stop_search(),
KeyCode::Enter => {
app.jump_to_search_result();
}
KeyCode::Backspace => {
app.search_pop();
app.execute_search();
}
KeyCode::Up => app.overlays.search.select_prev(),
KeyCode::Down => app.overlays.search.select_next(),
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
use crate::tui::app_states::SearchMode;
app.overlays.search.mode = match app.overlays.search.mode {
SearchMode::Substring => SearchMode::Regex,
SearchMode::Regex => SearchMode::Substring,
};
app.execute_search();
let mode_name = app.overlays.search.mode.label();
app.set_status_message(format!("Search mode: {mode_name}"));
}
KeyCode::Char(c) => {
app.search_push(c);
app.execute_search();
}
_ => {}
}
return;
}
if app.overlays.threshold_tuning.visible {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.overlays.threshold_tuning.visible = false;
}
KeyCode::Up | KeyCode::Char('k') => {
app.overlays.threshold_tuning.increase();
app.update_threshold_preview();
}
KeyCode::Down | KeyCode::Char('j') => {
app.overlays.threshold_tuning.decrease();
app.update_threshold_preview();
}
KeyCode::Right | KeyCode::Char('l') => {
app.overlays.threshold_tuning.fine_increase();
app.update_threshold_preview();
}
KeyCode::Left | KeyCode::Char('h') => {
app.overlays.threshold_tuning.fine_decrease();
app.update_threshold_preview();
}
KeyCode::Char('r') => {
app.overlays.threshold_tuning.reset();
app.update_threshold_preview();
}
KeyCode::Enter => {
app.apply_threshold();
}
_ => {}
}
return;
}
if app.has_overlay() {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
KeyCode::Char('?') if app.overlays.show_help => app.toggle_help(),
KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
KeyCode::Char('l') if app.overlays.show_legend => app.toggle_legend(),
KeyCode::Char('j') if app.overlays.show_export => {
app.close_overlays();
dispatch_export(app, super::export::ExportFormat::Json);
}
KeyCode::Char('m') if app.overlays.show_export => {
app.close_overlays();
dispatch_export(app, super::export::ExportFormat::Markdown);
}
KeyCode::Char('h') if app.overlays.show_export => {
app.close_overlays();
dispatch_export(app, super::export::ExportFormat::Html);
}
KeyCode::Char('s') if app.overlays.show_export => {
app.close_overlays();
dispatch_export(app, super::export::ExportFormat::Sarif);
}
KeyCode::Char('c') if app.overlays.show_export => {
app.close_overlays();
dispatch_export(app, super::export::ExportFormat::Csv);
}
_ => {}
}
return;
}
if app.overlays.view_switcher.visible {
match key.code {
KeyCode::Esc => app.overlays.view_switcher.hide(),
KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(view) = app.overlays.view_switcher.current_view() {
app.overlays.view_switcher.hide();
mouse::switch_to_view(app, view);
}
}
KeyCode::Char('1') => {
app.overlays.view_switcher.hide();
mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
}
KeyCode::Char('2') => {
app.overlays.view_switcher.hide();
mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
}
KeyCode::Char('3') => {
app.overlays.view_switcher.hide();
mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
}
_ => {}
}
return;
}
if app.overlays.shortcuts.visible {
match key.code {
KeyCode::Esc | KeyCode::Char('K') | KeyCode::F(1) => app.overlays.shortcuts.hide(),
_ => {}
}
return;
}
if app.overlays.component_deep_dive.visible {
match key.code {
KeyCode::Esc => app.overlays.component_deep_dive.close(),
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
app.overlays.component_deep_dive.next_section();
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
app.overlays.component_deep_dive.prev_section();
}
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') => {
let mut prefs = crate::config::TuiPreferences::load();
prefs.last_tab = Some(app.active_tab.as_str().to_string());
let _ = prefs.save();
app.should_quit = true;
}
KeyCode::Char('?') => app.toggle_help(),
KeyCode::Char('e') => app.toggle_export(),
KeyCode::Char('l') => app.toggle_legend(),
KeyCode::Char('T') => {
let theme_name = toggle_theme();
let mut prefs = TuiPreferences::load();
prefs.theme = theme_name.parse().unwrap_or_default();
let _ = prefs.save();
}
KeyCode::Char('V') => {
if matches!(
app.mode,
super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
) {
app.overlays.view_switcher.toggle();
}
}
KeyCode::Char('K') | KeyCode::F(1) => {
let context = match app.mode {
super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
super::AppMode::Diff => super::app::ShortcutsContext::Diff,
super::AppMode::View => super::app::ShortcutsContext::View,
};
app.overlays.shortcuts.show(context);
}
KeyCode::Char('D') => {
if let Some(component_name) = helpers::get_selected_component_name(app) {
app.overlays.component_deep_dive.open(component_name, None);
}
}
KeyCode::Char('P') => {
if matches!(app.mode, super::AppMode::Diff) {
app.run_compliance_check();
}
}
KeyCode::Char('p') => {
if matches!(app.mode, super::AppMode::Diff) {
app.next_policy();
}
}
KeyCode::Char('y') => {
handle_yank(app);
}
KeyCode::Esc => app.close_overlays(),
KeyCode::Char('b') | KeyCode::Backspace => {
if app.has_navigation_history() {
app.navigate_back();
}
}
KeyCode::Tab => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
app.prev_tab();
} else {
app.next_tab();
}
}
KeyCode::Char('/') => app.start_search(),
KeyCode::Char('1') => app.select_tab(super::TabKind::Summary),
KeyCode::Char('2') => app.select_tab(super::TabKind::Components),
KeyCode::Char('3') => app.select_tab(super::TabKind::Dependencies),
KeyCode::Char('4') => app.select_tab(super::TabKind::Licenses),
KeyCode::Char('5') => app.select_tab(super::TabKind::Vulnerabilities),
KeyCode::Char('6') => app.select_tab(super::TabKind::Quality),
KeyCode::Char('7') => {
if app.mode == super::AppMode::Diff {
app.select_tab(super::TabKind::Compliance);
}
}
KeyCode::Char('8') => {
if app.mode == super::AppMode::Diff {
app.select_tab(super::TabKind::SideBySide);
}
}
KeyCode::Char('9') => {
let has_graph = app
.data
.diff_result
.as_ref()
.is_some_and(|r| !r.graph_changes.is_empty());
if has_graph {
app.select_tab(super::TabKind::GraphChanges);
} else if app.mode == super::AppMode::Diff {
app.select_tab(super::TabKind::Source);
}
}
KeyCode::Char('0') => {
let has_graph = app
.data
.diff_result
.as_ref()
.is_some_and(|r| !r.graph_changes.is_empty());
if has_graph && app.mode == super::AppMode::Diff {
app.select_tab(super::TabKind::Source);
}
}
KeyCode::Up | KeyCode::Char('k') => app.select_up(),
KeyCode::Down | KeyCode::Char('j') => app.select_down(),
KeyCode::PageUp => app.page_up(),
KeyCode::PageDown => app.page_down(),
KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
app.select_first();
}
KeyCode::End | KeyCode::Char('G') => app.select_last(),
_ => {}
}
match app.active_tab {
super::TabKind::Components => components::handle_components_keys(app, key),
super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
super::TabKind::Quality => quality::handle_quality_keys(app, key),
super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
super::TabKind::Source => source::handle_source_keys(app, key),
super::TabKind::Summary | super::TabKind::Overview | super::TabKind::Tree => {}
}
match app.mode {
super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
_ => {}
}
}
pub fn get_yank_text(app: &super::App) -> Option<String> {
match app.active_tab {
super::TabKind::Components => helpers::get_selected_component_name(app),
super::TabKind::Vulnerabilities => {
let idx = app.vulnerabilities_state().selected;
let result = app.data.diff_result.as_ref()?;
let vulns: Vec<_> = result
.vulnerabilities
.introduced
.iter()
.chain(result.vulnerabilities.resolved.iter())
.collect();
vulns.get(idx).map(|v| v.id.clone())
}
super::TabKind::Dependencies => {
let idx = app.dependencies_state().selected;
let result = app.data.diff_result.as_ref()?;
let deps: Vec<_> = result
.dependencies
.added
.iter()
.chain(result.dependencies.removed.iter())
.collect();
deps.get(idx)
.map(|dep| format!("{} → {}", dep.from, dep.to))
}
super::TabKind::Licenses => {
let idx = app.licenses_state().selected;
let result = app.data.diff_result.as_ref()?;
let licenses: Vec<_> = result
.licenses
.new_licenses
.iter()
.chain(result.licenses.removed_licenses.iter())
.collect();
licenses.get(idx).map(|lic| lic.license.clone())
}
super::TabKind::Quality => {
let report = app
.data
.new_quality
.as_ref()
.or(app.data.old_quality.as_ref())?;
report
.recommendations
.get(app.quality_state().selected_recommendation)
.map(|rec| rec.message.clone())
}
super::TabKind::Compliance => {
let results = app
.data
.new_compliance_results
.as_ref()
.or(app.data.old_compliance_results.as_ref())?;
let result = results.get(app.diff_compliance_state().selected_standard)?;
result
.violations
.get(app.diff_compliance_state().selected_violation)
.map(|v| v.message.clone())
}
super::TabKind::Source => {
let source = app.source_state();
let panel = match source.active_side {
crate::tui::app_states::SourceSide::Old => &source.old_panel,
crate::tui::app_states::SourceSide::New => &source.new_panel,
};
match panel.view_mode {
super::app_states::SourceViewMode::Tree => {
panel.cached_flat_items.get(panel.selected).map(|item| {
if !item.value_preview.is_empty() {
let v = &item.value_preview;
if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
v[1..v.len() - 1].to_string()
} else {
v.clone()
}
} else {
item.node_id.clone()
}
})
}
super::app_states::SourceViewMode::Raw => panel
.raw_lines
.get(panel.selected)
.map(|line| line.trim().to_string()),
}
}
_ => None,
}
}
fn handle_yank(app: &mut super::App) {
let Some(text) = get_yank_text(app) else {
app.set_status_message("Nothing selected to copy");
return;
};
if crate::tui::clipboard::copy_to_clipboard(&text) {
let display = if text.len() > 50 {
let end = crate::tui::shared::floor_char_boundary(&text, 47);
format!("{}...", &text[..end])
} else {
text
};
app.set_status_message(format!("Copied: {display}"));
} else {
app.set_status_message("Failed to copy to clipboard");
}
}
fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
if app.active_tab == super::TabKind::Compliance {
app.export_compliance(format);
} else {
app.export(format);
}
}