1mod compliance;
9mod components;
10mod dependencies;
11mod graph_changes;
12mod helpers;
13mod licenses;
14mod matrix;
15pub mod mouse;
16mod multi_diff;
17mod quality;
18mod sidebyside;
19mod source;
20mod timeline;
21mod vulnerabilities;
22
23use crate::config::TuiPreferences;
24use crate::tui::toggle_theme;
25use crossterm::event::{
26 self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent,
27};
28use std::time::Duration;
29
30pub use mouse::handle_mouse_event;
31
32#[derive(Debug)]
34pub enum Event {
35 Key(KeyEvent),
37 Mouse(MouseEvent),
39 Tick,
41 Resize(u16, u16),
43}
44
45pub struct EventHandler {
47 tick_rate: Duration,
49}
50
51impl EventHandler {
52 pub const fn new(tick_rate: u64) -> Self {
54 Self {
55 tick_rate: Duration::from_millis(tick_rate),
56 }
57 }
58
59 pub fn next(&self) -> Result<Event, std::io::Error> {
61 if event::poll(self.tick_rate)? {
62 match event::read()? {
63 CrosstermEvent::Key(key) => Ok(Event::Key(key)),
64 CrosstermEvent::Mouse(mouse) => Ok(Event::Mouse(mouse)),
65 CrosstermEvent::Resize(width, height) => Ok(Event::Resize(width, height)),
66 _ => Ok(Event::Tick),
67 }
68 } else {
69 Ok(Event::Tick)
70 }
71 }
72}
73
74impl Default for EventHandler {
75 fn default() -> Self {
76 Self::new(250)
77 }
78}
79
80pub fn handle_key_event(app: &mut super::App, key: KeyEvent) {
82 app.clear_status_message();
84
85 if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
87 handle_yank(app);
88 return;
89 }
90
91 if app.overlays.search.active {
93 match key.code {
94 KeyCode::Esc => app.stop_search(),
95 KeyCode::Enter => {
96 app.jump_to_search_result();
98 }
99 KeyCode::Backspace => {
100 app.search_pop();
101 app.execute_search();
103 }
104 KeyCode::Up => app.overlays.search.select_prev(),
105 KeyCode::Down => app.overlays.search.select_next(),
106 KeyCode::Char(c) => {
107 app.search_push(c);
108 app.execute_search();
110 }
111 _ => {}
112 }
113 return;
114 }
115
116 if app.overlays.threshold_tuning.visible {
118 match key.code {
119 KeyCode::Esc | KeyCode::Char('q') => {
120 app.overlays.threshold_tuning.visible = false;
121 }
122 KeyCode::Up | KeyCode::Char('k') => {
123 app.overlays.threshold_tuning.increase();
124 app.update_threshold_preview();
125 }
126 KeyCode::Down | KeyCode::Char('j') => {
127 app.overlays.threshold_tuning.decrease();
128 app.update_threshold_preview();
129 }
130 KeyCode::Right | KeyCode::Char('l') => {
131 app.overlays.threshold_tuning.fine_increase();
132 app.update_threshold_preview();
133 }
134 KeyCode::Left | KeyCode::Char('h') => {
135 app.overlays.threshold_tuning.fine_decrease();
136 app.update_threshold_preview();
137 }
138 KeyCode::Char('r') => {
139 app.overlays.threshold_tuning.reset();
140 app.update_threshold_preview();
141 }
142 KeyCode::Enter => {
143 app.apply_threshold();
144 }
145 _ => {}
146 }
147 return;
148 }
149
150 if app.has_overlay() {
152 match key.code {
153 KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
154 KeyCode::Char('?') if app.overlays.show_help => app.toggle_help(),
155 KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
156 KeyCode::Char('l') if app.overlays.show_legend => app.toggle_legend(),
157 KeyCode::Char('j') if app.overlays.show_export => {
159 app.close_overlays();
160 dispatch_export(app, super::export::ExportFormat::Json);
161 }
162 KeyCode::Char('m') if app.overlays.show_export => {
163 app.close_overlays();
164 dispatch_export(app, super::export::ExportFormat::Markdown);
165 }
166 KeyCode::Char('h') if app.overlays.show_export => {
167 app.close_overlays();
168 dispatch_export(app, super::export::ExportFormat::Html);
169 }
170 KeyCode::Char('s') if app.overlays.show_export => {
171 app.close_overlays();
172 dispatch_export(app, super::export::ExportFormat::Sarif);
173 }
174 KeyCode::Char('c') if app.overlays.show_export => {
175 app.close_overlays();
176 dispatch_export(app, super::export::ExportFormat::Csv);
177 }
178 _ => {}
179 }
180 return;
181 }
182
183 if app.overlays.view_switcher.visible {
185 match key.code {
186 KeyCode::Esc => app.overlays.view_switcher.hide(),
187 KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
188 KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
189 KeyCode::Enter | KeyCode::Char(' ') => {
190 if let Some(view) = app.overlays.view_switcher.current_view() {
191 app.overlays.view_switcher.hide();
192 mouse::switch_to_view(app, view);
193 }
194 }
195 KeyCode::Char('1') => {
196 app.overlays.view_switcher.hide();
197 mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
198 }
199 KeyCode::Char('2') => {
200 app.overlays.view_switcher.hide();
201 mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
202 }
203 KeyCode::Char('3') => {
204 app.overlays.view_switcher.hide();
205 mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
206 }
207 _ => {}
208 }
209 return;
210 }
211
212 if app.overlays.shortcuts.visible {
214 match key.code {
215 KeyCode::Esc | KeyCode::Char('K') | KeyCode::F(1) => app.overlays.shortcuts.hide(),
216 _ => {}
217 }
218 return;
219 }
220
221 if app.overlays.component_deep_dive.visible {
223 match key.code {
224 KeyCode::Esc => app.overlays.component_deep_dive.close(),
225 KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
226 app.overlays.component_deep_dive.next_section();
227 }
228 KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
229 app.overlays.component_deep_dive.prev_section();
230 }
231 _ => {}
232 }
233 return;
234 }
235
236 match key.code {
238 KeyCode::Char('q') => {
239 let mut prefs = crate::config::TuiPreferences::load();
241 prefs.last_tab = Some(app.active_tab.as_str().to_string());
242 let _ = prefs.save();
243 app.should_quit = true;
244 }
245 KeyCode::Char('?') => app.toggle_help(),
246 KeyCode::Char('e') => app.toggle_export(),
247 KeyCode::Char('l') => app.toggle_legend(),
248 KeyCode::Char('T') => {
249 let theme_name = toggle_theme();
251 let mut prefs = TuiPreferences::load();
252 prefs.theme = theme_name.to_string();
253 let _ = prefs.save();
254 }
255 KeyCode::Char('V') => {
257 if matches!(
258 app.mode,
259 super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
260 ) {
261 app.overlays.view_switcher.toggle();
262 }
263 }
264 KeyCode::Char('K') | KeyCode::F(1) => {
266 let context = match app.mode {
267 super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
268 super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
269 super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
270 super::AppMode::Diff => super::app::ShortcutsContext::Diff,
271 super::AppMode::View => super::app::ShortcutsContext::Global,
272 };
273 app.overlays.shortcuts.show(context);
274 }
275 KeyCode::Char('D') => {
277 if let Some(component_name) = helpers::get_selected_component_name(app) {
278 app.overlays.component_deep_dive.open(component_name, None);
279 }
280 }
281 KeyCode::Char('P') => {
283 if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
284 app.run_compliance_check();
285 }
286 }
287 KeyCode::Char('p') => {
289 if matches!(app.mode, super::AppMode::Diff | super::AppMode::View) {
290 app.next_policy();
291 }
292 }
293 KeyCode::Char('y') => {
295 handle_yank(app);
296 }
297 KeyCode::Esc => app.close_overlays(),
298 KeyCode::Char('b') | KeyCode::Backspace => {
299 if app.has_navigation_history() {
301 app.navigate_back();
302 }
303 }
304 KeyCode::Tab => {
305 if key.modifiers.contains(KeyModifiers::SHIFT) {
306 app.prev_tab();
307 } else {
308 app.next_tab();
309 }
310 }
311 KeyCode::Char('/') => app.start_search(),
312 KeyCode::Char('1') => app.select_tab(super::TabKind::Summary),
313 KeyCode::Char('2') => app.select_tab(super::TabKind::Components),
314 KeyCode::Char('3') => app.select_tab(super::TabKind::Dependencies),
315 KeyCode::Char('4') => app.select_tab(super::TabKind::Licenses),
316 KeyCode::Char('5') => app.select_tab(super::TabKind::Vulnerabilities),
317 KeyCode::Char('6') => app.select_tab(super::TabKind::Quality),
318 KeyCode::Char('7') => {
319 if app.mode == super::AppMode::Diff {
321 app.select_tab(super::TabKind::Compliance);
322 }
323 }
324 KeyCode::Char('8') => {
325 if app.mode == super::AppMode::Diff {
327 app.select_tab(super::TabKind::SideBySide);
328 }
329 }
330 KeyCode::Char('9') => {
331 let has_graph = app
333 .data
334 .diff_result
335 .as_ref()
336 .is_some_and(|r| !r.graph_changes.is_empty());
337 if has_graph {
338 app.select_tab(super::TabKind::GraphChanges);
339 } else if app.mode == super::AppMode::Diff {
340 app.select_tab(super::TabKind::Source);
341 }
342 }
343 KeyCode::Char('0') => {
344 let has_graph = app
346 .data
347 .diff_result
348 .as_ref()
349 .is_some_and(|r| !r.graph_changes.is_empty());
350 if has_graph && app.mode == super::AppMode::Diff {
351 app.select_tab(super::TabKind::Source);
352 }
353 }
354 KeyCode::Up | KeyCode::Char('k') => app.select_up(),
356 KeyCode::Down | KeyCode::Char('j') => app.select_down(),
357 KeyCode::PageUp => app.page_up(),
358 KeyCode::PageDown => app.page_down(),
359 KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
360 app.select_first();
361 }
362 KeyCode::End | KeyCode::Char('G') => app.select_last(),
363 _ => {}
364 }
365
366 match app.active_tab {
368 super::TabKind::Components => components::handle_components_keys(app, key),
369 super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
370 super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
371 super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
372 super::TabKind::Quality => quality::handle_quality_keys(app, key),
373 super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
374 super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
375 super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
376 super::TabKind::Source => source::handle_source_keys(app, key),
377 super::TabKind::Summary => {}
378 }
379
380 match app.mode {
382 super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
383 super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
384 super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
385 _ => {}
386 }
387}
388
389pub fn get_yank_text(app: &super::App) -> Option<String> {
393 match app.active_tab {
394 super::TabKind::Components => helpers::get_selected_component_name(app),
395 super::TabKind::Vulnerabilities => {
396 let idx = app.tabs.vulnerabilities.selected;
397 let result = app.data.diff_result.as_ref()?;
398 let vulns: Vec<_> = result
399 .vulnerabilities
400 .introduced
401 .iter()
402 .chain(result.vulnerabilities.resolved.iter())
403 .collect();
404 vulns.get(idx).map(|v| v.id.clone())
405 }
406 super::TabKind::Dependencies => {
407 let idx = app.tabs.dependencies.selected;
408 let result = app.data.diff_result.as_ref()?;
409 let deps: Vec<_> = result
410 .dependencies
411 .added
412 .iter()
413 .chain(result.dependencies.removed.iter())
414 .collect();
415 deps.get(idx)
416 .map(|dep| format!("{} → {}", dep.from, dep.to))
417 }
418 super::TabKind::Licenses => {
419 let idx = app.tabs.licenses.selected;
420 let result = app.data.diff_result.as_ref()?;
421 let licenses: Vec<_> = result
422 .licenses
423 .new_licenses
424 .iter()
425 .chain(result.licenses.removed_licenses.iter())
426 .collect();
427 licenses.get(idx).map(|lic| lic.license.clone())
428 }
429 super::TabKind::Quality => {
430 let report = app
431 .data
432 .new_quality
433 .as_ref()
434 .or(app.data.old_quality.as_ref())?;
435 report
436 .recommendations
437 .get(app.tabs.quality.selected_recommendation)
438 .map(|rec| rec.message.clone())
439 }
440 super::TabKind::Compliance => {
441 let results = app
442 .data
443 .new_compliance_results
444 .as_ref()
445 .or(app.data.old_compliance_results.as_ref())?;
446 let result = results.get(app.tabs.diff_compliance.selected_standard)?;
447 result
448 .violations
449 .get(app.tabs.diff_compliance.selected_violation)
450 .map(|v| v.message.clone())
451 }
452 super::TabKind::Source => {
453 let panel = match app.tabs.source.active_side {
454 crate::tui::app_states::SourceSide::Old => &app.tabs.source.old_panel,
455 crate::tui::app_states::SourceSide::New => &app.tabs.source.new_panel,
456 };
457 match panel.view_mode {
458 super::app_states::SourceViewMode::Tree => {
459 panel.cached_flat_items.get(panel.selected).map(|item| {
461 if !item.value_preview.is_empty() {
462 let v = &item.value_preview;
464 if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
465 v[1..v.len() - 1].to_string()
466 } else {
467 v.clone()
468 }
469 } else {
470 item.node_id.clone()
471 }
472 })
473 }
474 super::app_states::SourceViewMode::Raw => panel
475 .raw_lines
476 .get(panel.selected)
477 .map(|line| line.trim().to_string()),
478 }
479 }
480 _ => None,
481 }
482}
483
484fn handle_yank(app: &mut super::App) {
486 let Some(text) = get_yank_text(app) else {
487 app.set_status_message("Nothing selected to copy");
488 return;
489 };
490
491 if crate::tui::clipboard::copy_to_clipboard(&text) {
492 let display = if text.len() > 50 {
493 format!("{}...", &text[..47])
494 } else {
495 text
496 };
497 app.set_status_message(format!("Copied: {display}"));
498 } else {
499 app.set_status_message("Failed to copy to clipboard");
500 }
501}
502
503fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
506 if app.active_tab == super::TabKind::Compliance {
507 app.export_compliance(format);
508 } else {
509 app.export(format);
510 }
511}