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('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
108 use crate::tui::app_states::SearchMode;
109 app.overlays.search.mode = match app.overlays.search.mode {
110 SearchMode::Substring => SearchMode::Regex,
111 SearchMode::Regex => SearchMode::Substring,
112 };
113 app.execute_search();
115 let mode_name = app.overlays.search.mode.label();
116 app.set_status_message(format!("Search mode: {mode_name}"));
117 }
118 KeyCode::Char(c) => {
119 app.search_push(c);
120 app.execute_search();
122 }
123 _ => {}
124 }
125 return;
126 }
127
128 if app.overlays.threshold_tuning.visible {
130 match key.code {
131 KeyCode::Esc | KeyCode::Char('q') => {
132 app.overlays.threshold_tuning.visible = false;
133 }
134 KeyCode::Up | KeyCode::Char('k') => {
135 app.overlays.threshold_tuning.increase();
136 app.update_threshold_preview();
137 }
138 KeyCode::Down | KeyCode::Char('j') => {
139 app.overlays.threshold_tuning.decrease();
140 app.update_threshold_preview();
141 }
142 KeyCode::Right | KeyCode::Char('l') => {
143 app.overlays.threshold_tuning.fine_increase();
144 app.update_threshold_preview();
145 }
146 KeyCode::Left | KeyCode::Char('h') => {
147 app.overlays.threshold_tuning.fine_decrease();
148 app.update_threshold_preview();
149 }
150 KeyCode::Char('r') => {
151 app.overlays.threshold_tuning.reset();
152 app.update_threshold_preview();
153 }
154 KeyCode::Enter => {
155 app.apply_threshold();
156 }
157 _ => {}
158 }
159 return;
160 }
161
162 if app.has_overlay() {
164 match key.code {
165 KeyCode::Esc | KeyCode::Char('q') => app.close_overlays(),
166 KeyCode::Char('?') if app.overlays.show_help => app.toggle_help(),
167 KeyCode::Char('e') if app.overlays.show_export => app.toggle_export(),
168 KeyCode::Char('l') if app.overlays.show_legend => app.toggle_legend(),
169 KeyCode::Char('j') if app.overlays.show_export => {
171 app.close_overlays();
172 dispatch_export(app, super::export::ExportFormat::Json);
173 }
174 KeyCode::Char('m') if app.overlays.show_export => {
175 app.close_overlays();
176 dispatch_export(app, super::export::ExportFormat::Markdown);
177 }
178 KeyCode::Char('h') if app.overlays.show_export => {
179 app.close_overlays();
180 dispatch_export(app, super::export::ExportFormat::Html);
181 }
182 KeyCode::Char('s') if app.overlays.show_export => {
183 app.close_overlays();
184 dispatch_export(app, super::export::ExportFormat::Sarif);
185 }
186 KeyCode::Char('c') if app.overlays.show_export => {
187 app.close_overlays();
188 dispatch_export(app, super::export::ExportFormat::Csv);
189 }
190 _ => {}
191 }
192 return;
193 }
194
195 if app.overlays.view_switcher.visible {
197 match key.code {
198 KeyCode::Esc => app.overlays.view_switcher.hide(),
199 KeyCode::Up | KeyCode::Char('k') => app.overlays.view_switcher.previous(),
200 KeyCode::Down | KeyCode::Char('j') => app.overlays.view_switcher.next(),
201 KeyCode::Enter | KeyCode::Char(' ') => {
202 if let Some(view) = app.overlays.view_switcher.current_view() {
203 app.overlays.view_switcher.hide();
204 mouse::switch_to_view(app, view);
205 }
206 }
207 KeyCode::Char('1') => {
208 app.overlays.view_switcher.hide();
209 mouse::switch_to_view(app, super::app::MultiViewType::MultiDiff);
210 }
211 KeyCode::Char('2') => {
212 app.overlays.view_switcher.hide();
213 mouse::switch_to_view(app, super::app::MultiViewType::Timeline);
214 }
215 KeyCode::Char('3') => {
216 app.overlays.view_switcher.hide();
217 mouse::switch_to_view(app, super::app::MultiViewType::Matrix);
218 }
219 _ => {}
220 }
221 return;
222 }
223
224 if app.overlays.shortcuts.visible {
226 match key.code {
227 KeyCode::Esc | KeyCode::Char('K') | KeyCode::F(1) => app.overlays.shortcuts.hide(),
228 _ => {}
229 }
230 return;
231 }
232
233 if app.overlays.component_deep_dive.visible {
235 match key.code {
236 KeyCode::Esc => app.overlays.component_deep_dive.close(),
237 KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
238 app.overlays.component_deep_dive.next_section();
239 }
240 KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
241 app.overlays.component_deep_dive.prev_section();
242 }
243 _ => {}
244 }
245 return;
246 }
247
248 match key.code {
250 KeyCode::Char('q') => {
251 let mut prefs = crate::config::TuiPreferences::load();
253 prefs.last_tab = Some(app.active_tab.as_str().to_string());
254 let _ = prefs.save();
255 app.should_quit = true;
256 }
257 KeyCode::Char('?') => app.toggle_help(),
258 KeyCode::Char('e') => app.toggle_export(),
259 KeyCode::Char('l') => app.toggle_legend(),
260 KeyCode::Char('T') => {
261 let theme_name = toggle_theme();
263 let mut prefs = TuiPreferences::load();
264 prefs.theme = theme_name.parse().unwrap_or_default();
265 let _ = prefs.save();
266 }
267 KeyCode::Char('V') => {
269 if matches!(
270 app.mode,
271 super::AppMode::MultiDiff | super::AppMode::Timeline | super::AppMode::Matrix
272 ) {
273 app.overlays.view_switcher.toggle();
274 }
275 }
276 KeyCode::Char('K') | KeyCode::F(1) => {
278 let context = match app.mode {
279 super::AppMode::MultiDiff => super::app::ShortcutsContext::MultiDiff,
280 super::AppMode::Timeline => super::app::ShortcutsContext::Timeline,
281 super::AppMode::Matrix => super::app::ShortcutsContext::Matrix,
282 super::AppMode::Diff => super::app::ShortcutsContext::Diff,
283 super::AppMode::View => super::app::ShortcutsContext::View,
284 };
285 app.overlays.shortcuts.show(context);
286 }
287 KeyCode::Char('D') => {
289 if let Some(component_name) = helpers::get_selected_component_name(app) {
290 app.overlays.component_deep_dive.open(component_name, None);
291 }
292 }
293 KeyCode::Char('P') => {
295 if matches!(app.mode, super::AppMode::Diff) {
296 app.run_compliance_check();
297 }
298 }
299 KeyCode::Char('p') => {
301 if matches!(app.mode, super::AppMode::Diff) {
302 app.next_policy();
303 }
304 }
305 KeyCode::Char('y') => {
307 handle_yank(app);
308 }
309 KeyCode::Esc => app.close_overlays(),
310 KeyCode::Char('b') | KeyCode::Backspace => {
311 if app.has_navigation_history() {
313 app.navigate_back();
314 }
315 }
316 KeyCode::Tab => {
317 if key.modifiers.contains(KeyModifiers::SHIFT) {
318 app.prev_tab();
319 } else {
320 app.next_tab();
321 }
322 }
323 KeyCode::Char('/') => app.start_search(),
324 KeyCode::Char('1') => app.select_tab(super::TabKind::Summary),
325 KeyCode::Char('2') => app.select_tab(super::TabKind::Components),
326 KeyCode::Char('3') => app.select_tab(super::TabKind::Dependencies),
327 KeyCode::Char('4') => app.select_tab(super::TabKind::Licenses),
328 KeyCode::Char('5') => app.select_tab(super::TabKind::Vulnerabilities),
329 KeyCode::Char('6') => app.select_tab(super::TabKind::Quality),
330 KeyCode::Char('7') => {
331 if app.mode == super::AppMode::Diff {
333 app.select_tab(super::TabKind::Compliance);
334 }
335 }
336 KeyCode::Char('8') => {
337 if app.mode == super::AppMode::Diff {
339 app.select_tab(super::TabKind::SideBySide);
340 }
341 }
342 KeyCode::Char('9') => {
343 let has_graph = app
345 .data
346 .diff_result
347 .as_ref()
348 .is_some_and(|r| !r.graph_changes.is_empty());
349 if has_graph {
350 app.select_tab(super::TabKind::GraphChanges);
351 } else if app.mode == super::AppMode::Diff {
352 app.select_tab(super::TabKind::Source);
353 }
354 }
355 KeyCode::Char('0') => {
356 let has_graph = app
358 .data
359 .diff_result
360 .as_ref()
361 .is_some_and(|r| !r.graph_changes.is_empty());
362 if has_graph && app.mode == super::AppMode::Diff {
363 app.select_tab(super::TabKind::Source);
364 }
365 }
366 KeyCode::Up | KeyCode::Char('k') => app.select_up(),
368 KeyCode::Down | KeyCode::Char('j') => app.select_down(),
369 KeyCode::PageUp => app.page_up(),
370 KeyCode::PageDown => app.page_down(),
371 KeyCode::Home | KeyCode::Char('g') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
372 app.select_first();
373 }
374 KeyCode::End | KeyCode::Char('G') => app.select_last(),
375 _ => {}
376 }
377
378 match app.active_tab {
380 super::TabKind::Components => components::handle_components_keys(app, key),
381 super::TabKind::Dependencies => dependencies::handle_dependencies_keys(app, key),
382 super::TabKind::Licenses => licenses::handle_licenses_keys(app, key),
383 super::TabKind::Vulnerabilities => vulnerabilities::handle_vulnerabilities_keys(app, key),
384 super::TabKind::Quality => quality::handle_quality_keys(app, key),
385 super::TabKind::Compliance => compliance::handle_diff_compliance_keys(app, key),
386 super::TabKind::GraphChanges => graph_changes::handle_graph_changes_keys(app, key),
387 super::TabKind::SideBySide => sidebyside::handle_sidebyside_keys(app, key),
388 super::TabKind::Source => source::handle_source_keys(app, key),
389 super::TabKind::Summary | super::TabKind::Overview | super::TabKind::Tree => {}
390 }
391
392 match app.mode {
394 super::AppMode::MultiDiff => multi_diff::handle_multi_diff_keys(app, key),
395 super::AppMode::Timeline => timeline::handle_timeline_keys(app, key),
396 super::AppMode::Matrix => matrix::handle_matrix_keys(app, key),
397 _ => {}
398 }
399}
400
401pub fn get_yank_text(app: &super::App) -> Option<String> {
405 match app.active_tab {
406 super::TabKind::Components => helpers::get_selected_component_name(app),
407 super::TabKind::Vulnerabilities => {
408 let idx = app.vulnerabilities_state().selected;
409 let result = app.data.diff_result.as_ref()?;
410 let vulns: Vec<_> = result
411 .vulnerabilities
412 .introduced
413 .iter()
414 .chain(result.vulnerabilities.resolved.iter())
415 .collect();
416 vulns.get(idx).map(|v| v.id.clone())
417 }
418 super::TabKind::Dependencies => {
419 let idx = app.dependencies_state().selected;
420 let result = app.data.diff_result.as_ref()?;
421 let deps: Vec<_> = result
422 .dependencies
423 .added
424 .iter()
425 .chain(result.dependencies.removed.iter())
426 .collect();
427 deps.get(idx)
428 .map(|dep| format!("{} → {}", dep.from, dep.to))
429 }
430 super::TabKind::Licenses => {
431 let idx = app.licenses_state().selected;
432 let result = app.data.diff_result.as_ref()?;
433 let licenses: Vec<_> = result
434 .licenses
435 .new_licenses
436 .iter()
437 .chain(result.licenses.removed_licenses.iter())
438 .collect();
439 licenses.get(idx).map(|lic| lic.license.clone())
440 }
441 super::TabKind::Quality => {
442 let report = app
443 .data
444 .new_quality
445 .as_ref()
446 .or(app.data.old_quality.as_ref())?;
447 report
448 .recommendations
449 .get(app.quality_state().selected_recommendation)
450 .map(|rec| rec.message.clone())
451 }
452 super::TabKind::Compliance => {
453 let results = app
454 .data
455 .new_compliance_results
456 .as_ref()
457 .or(app.data.old_compliance_results.as_ref())?;
458 let result = results.get(app.diff_compliance_state().selected_standard)?;
459 result
460 .violations
461 .get(app.diff_compliance_state().selected_violation)
462 .map(|v| v.message.clone())
463 }
464 super::TabKind::Source => {
465 let source = app.source_state();
466 let panel = match source.active_side {
467 crate::tui::app_states::SourceSide::Old => &source.old_panel,
468 crate::tui::app_states::SourceSide::New => &source.new_panel,
469 };
470 match panel.view_mode {
471 super::app_states::SourceViewMode::Tree => {
472 panel.cached_flat_items.get(panel.selected).map(|item| {
474 if !item.value_preview.is_empty() {
475 let v = &item.value_preview;
477 if v.starts_with('"') && v.ends_with('"') && v.len() >= 2 {
478 v[1..v.len() - 1].to_string()
479 } else {
480 v.clone()
481 }
482 } else {
483 item.node_id.clone()
484 }
485 })
486 }
487 super::app_states::SourceViewMode::Raw => panel
488 .raw_lines
489 .get(panel.selected)
490 .map(|line| line.trim().to_string()),
491 }
492 }
493 _ => None,
494 }
495}
496
497fn handle_yank(app: &mut super::App) {
499 let Some(text) = get_yank_text(app) else {
500 app.set_status_message("Nothing selected to copy");
501 return;
502 };
503
504 if crate::tui::clipboard::copy_to_clipboard(&text) {
505 let display = if text.len() > 50 {
506 let end = crate::tui::shared::floor_char_boundary(&text, 47);
507 format!("{}...", &text[..end])
508 } else {
509 text
510 };
511 app.set_status_message(format!("Copied: {display}"));
512 } else {
513 app.set_status_message("Failed to copy to clipboard");
514 }
515}
516
517fn dispatch_export(app: &mut super::App, format: crate::tui::export::ExportFormat) {
520 if app.active_tab == super::TabKind::Compliance {
521 app.export_compliance(format);
522 } else {
523 app.export(format);
524 }
525}