1use crate::tui::app::{
7 ComponentDeepDiveState, ShortcutsContext, ShortcutsOverlayState, ViewSwitcherState,
8};
9use crate::tui::theme::colors;
10use ratatui::{
11 layout::{Alignment, Constraint, Direction, Layout, Rect},
12 style::{Modifier, Style},
13 text::{Line, Span},
14 widgets::{Block, Borders, Clear, Paragraph, Wrap},
15 Frame,
16};
17
18pub fn render_view_switcher(f: &mut Frame, state: &ViewSwitcherState) {
20 if !state.visible {
21 return;
22 }
23
24 let scheme = colors();
25 let area = f.area();
26
27 let overlay_width = 50;
29 let overlay_height = 10;
30 let overlay_area = centered_rect(overlay_width, overlay_height, area);
31
32 f.render_widget(Clear, overlay_area);
34
35 let block = Block::default()
37 .title(" Switch View (V) ")
38 .title_alignment(Alignment::Center)
39 .borders(Borders::ALL)
40 .border_style(Style::default().fg(scheme.accent))
41 .style(Style::default().bg(scheme.background_alt));
42
43 let inner_area = block.inner(overlay_area);
44 f.render_widget(block, overlay_area);
45
46 let mut lines = vec![
48 Line::from(Span::styled(
49 "Select a view to switch to:",
50 Style::default().fg(scheme.text_muted),
51 )),
52 Line::from(""),
53 ];
54
55 for (i, view) in state.available_views.iter().enumerate() {
56 let is_selected = i == state.selected;
57 let prefix = if is_selected { "> " } else { " " };
58 let style = if is_selected {
59 Style::default()
60 .fg(scheme.accent)
61 .add_modifier(Modifier::BOLD)
62 } else {
63 Style::default().fg(scheme.text)
64 };
65
66 lines.push(Line::from(vec![
67 Span::styled(prefix, style),
68 Span::styled(
69 format!("[{}] ", view.shortcut()),
70 Style::default().fg(scheme.text_muted),
71 ),
72 Span::styled(view.icon(), style),
73 Span::raw(" "),
74 Span::styled(view.label(), style),
75 ]));
76 }
77
78 lines.push(Line::from(""));
79 lines.push(Line::from(vec![
80 Span::styled("Enter", Style::default().fg(scheme.accent)),
81 Span::styled(" select ", Style::default().fg(scheme.text_muted)),
82 Span::styled("Esc", Style::default().fg(scheme.accent)),
83 Span::styled(" cancel", Style::default().fg(scheme.text_muted)),
84 ]));
85
86 let paragraph = Paragraph::new(lines)
87 .alignment(Alignment::Left)
88 .wrap(Wrap { trim: true });
89 f.render_widget(paragraph, inner_area);
90}
91
92pub fn render_shortcuts_overlay(f: &mut Frame, state: &ShortcutsOverlayState) {
94 if !state.visible {
95 return;
96 }
97
98 let scheme = colors();
99 let area = f.area();
100
101 let overlay_width = 70;
103 let overlay_height = 30.min(area.height.saturating_sub(4));
104 let overlay_area = centered_rect(overlay_width, overlay_height, area);
105
106 f.render_widget(Clear, overlay_area);
108
109 let title = format!(" Keyboard Shortcuts ({}) ", context_name(state.context));
111 let block = Block::default()
112 .title(title)
113 .title_alignment(Alignment::Center)
114 .borders(Borders::ALL)
115 .border_style(Style::default().fg(scheme.accent))
116 .style(Style::default().bg(scheme.background_alt));
117
118 let inner_area = block.inner(overlay_area);
119 f.render_widget(block, overlay_area);
120
121 let shortcuts = get_shortcuts_for_context(state.context);
123
124 let mut lines: Vec<Line> = Vec::new();
125
126 for section in shortcuts {
127 lines.push(Line::from(Span::styled(
129 section.title,
130 Style::default()
131 .fg(scheme.accent)
132 .add_modifier(Modifier::BOLD),
133 )));
134 lines.push(Line::from(""));
135
136 for (key, description) in section.items {
138 lines.push(Line::from(vec![
139 Span::styled(
140 format!("{:>12}", key),
141 Style::default()
142 .fg(scheme.primary)
143 .add_modifier(Modifier::BOLD),
144 ),
145 Span::styled(" ", Style::default()),
146 Span::styled(description, Style::default().fg(scheme.text)),
147 ]));
148 }
149 lines.push(Line::from(""));
150 }
151
152 lines.push(Line::from(vec![
154 Span::styled("Press ", Style::default().fg(scheme.text_muted)),
155 Span::styled("Esc", Style::default().fg(scheme.accent)),
156 Span::styled(" or ", Style::default().fg(scheme.text_muted)),
157 Span::styled("K", Style::default().fg(scheme.accent)),
158 Span::styled(" to close", Style::default().fg(scheme.text_muted)),
159 ]));
160
161 let paragraph = Paragraph::new(lines)
162 .alignment(Alignment::Left)
163 .wrap(Wrap { trim: true });
164 f.render_widget(paragraph, inner_area);
165}
166
167pub fn render_component_deep_dive(f: &mut Frame, state: &ComponentDeepDiveState) {
169 if !state.visible {
170 return;
171 }
172
173 let scheme = colors();
174 let area = f.area();
175
176 let overlay_width = 80.min(area.width.saturating_sub(4));
178 let overlay_height = 35.min(area.height.saturating_sub(4));
179 let overlay_area = centered_rect(overlay_width, overlay_height, area);
180
181 f.render_widget(Clear, overlay_area);
183
184 let title = format!(" Component Deep Dive: {} ", state.component_name);
186 let block = Block::default()
187 .title(title)
188 .title_alignment(Alignment::Center)
189 .borders(Borders::ALL)
190 .border_style(Style::default().fg(scheme.accent))
191 .style(Style::default().bg(scheme.background_alt));
192
193 let inner_area = block.inner(overlay_area);
194 f.render_widget(block, overlay_area);
195
196 let chunks = Layout::default()
198 .direction(Direction::Vertical)
199 .constraints([
200 Constraint::Length(3),
201 Constraint::Min(0),
202 Constraint::Length(2),
203 ])
204 .split(inner_area);
205
206 render_deep_dive_tabs(f, chunks[0], state);
208
209 render_deep_dive_content(f, chunks[1], state);
211
212 let footer = Line::from(vec![
214 Span::styled("Tab/Arrow", Style::default().fg(scheme.accent)),
215 Span::styled(" switch section ", Style::default().fg(scheme.text_muted)),
216 Span::styled("Esc", Style::default().fg(scheme.accent)),
217 Span::styled(" close", Style::default().fg(scheme.text_muted)),
218 ]);
219 let footer_para = Paragraph::new(footer).alignment(Alignment::Center);
220 f.render_widget(footer_para, chunks[2]);
221}
222
223fn render_deep_dive_tabs(f: &mut Frame, area: Rect, state: &ComponentDeepDiveState) {
224 let scheme = colors();
225 let labels = ComponentDeepDiveState::section_labels();
226
227 let tabs: Vec<Span> = labels
228 .iter()
229 .enumerate()
230 .map(|(i, label)| {
231 let is_selected = i == state.active_section;
232 if is_selected {
233 Span::styled(
234 format!(" {} ", label),
235 Style::default()
236 .bg(scheme.accent)
237 .fg(scheme.badge_fg_dark)
238 .add_modifier(Modifier::BOLD),
239 )
240 } else {
241 Span::styled(
242 format!(" {} ", label),
243 Style::default().fg(scheme.text_muted),
244 )
245 }
246 })
247 .collect();
248
249 let mut line_spans = vec![Span::raw(" ")];
250 for (i, tab) in tabs.into_iter().enumerate() {
251 line_spans.push(tab);
252 if i < labels.len() - 1 {
253 line_spans.push(Span::raw(" | "));
254 }
255 }
256
257 let line = Line::from(line_spans);
258 let para = Paragraph::new(line).alignment(Alignment::Center);
259 f.render_widget(para, area);
260}
261
262fn render_deep_dive_content(f: &mut Frame, area: Rect, state: &ComponentDeepDiveState) {
263 let scheme = colors();
264 let data = &state.collected_data;
265
266 let lines: Vec<Line> = match state.active_section {
267 0 => {
268 vec![
270 Line::from(Span::styled(
271 "Component Overview",
272 Style::default()
273 .fg(scheme.accent)
274 .add_modifier(Modifier::BOLD),
275 )),
276 Line::from(""),
277 Line::from(vec![
278 Span::styled("Name: ", Style::default().fg(scheme.text_muted)),
279 Span::styled(&state.component_name, Style::default().fg(scheme.text)),
280 ]),
281 Line::from(vec![
282 Span::styled("ID: ", Style::default().fg(scheme.text_muted)),
283 Span::styled(
284 state.component_id.as_deref().unwrap_or("Unknown"),
285 Style::default().fg(scheme.text),
286 ),
287 ]),
288 Line::from(""),
289 Line::from(vec![
290 Span::styled("Versions tracked: ", Style::default().fg(scheme.text_muted)),
291 Span::styled(
292 data.version_history.len().to_string(),
293 Style::default().fg(scheme.primary),
294 ),
295 ]),
296 Line::from(vec![
297 Span::styled("Targets present: ", Style::default().fg(scheme.text_muted)),
298 Span::styled(
299 data.target_presence
300 .iter()
301 .filter(|t| t.is_present)
302 .count()
303 .to_string(),
304 Style::default().fg(scheme.primary),
305 ),
306 ]),
307 Line::from(vec![
308 Span::styled("Vulnerabilities: ", Style::default().fg(scheme.text_muted)),
309 Span::styled(
310 data.vulnerabilities.len().to_string(),
311 Style::default().fg(if data.vulnerabilities.is_empty() {
312 scheme.added
313 } else {
314 scheme.warning
315 }),
316 ),
317 ]),
318 ]
319 }
320 1 => {
321 let mut lines = vec![
323 Line::from(Span::styled(
324 "Version History",
325 Style::default()
326 .fg(scheme.accent)
327 .add_modifier(Modifier::BOLD),
328 )),
329 Line::from(""),
330 ];
331
332 if data.version_history.is_empty() {
333 lines.push(Line::from(Span::styled(
334 "No version history available",
335 Style::default().fg(scheme.text_muted),
336 )));
337 } else {
338 for entry in data.version_history.iter().take(15) {
339 let change_style = match entry.change_type.as_str() {
340 "added" => Style::default().fg(scheme.added),
341 "removed" => Style::default().fg(scheme.removed),
342 "modified" => Style::default().fg(scheme.modified),
343 _ => Style::default().fg(scheme.text_muted),
344 };
345
346 lines.push(Line::from(vec![
347 Span::styled(&entry.version, Style::default().fg(scheme.text)),
348 Span::raw(" - "),
349 Span::styled(&entry.sbom_label, Style::default().fg(scheme.text_muted)),
350 Span::raw(" ["),
351 Span::styled(&entry.change_type, change_style),
352 Span::raw("]"),
353 ]));
354 }
355 }
356 lines
357 }
358 2 => {
359 let mut lines = vec![
361 Line::from(Span::styled(
362 "Dependencies",
363 Style::default()
364 .fg(scheme.accent)
365 .add_modifier(Modifier::BOLD),
366 )),
367 Line::from(""),
368 ];
369
370 lines.push(Line::from(Span::styled(
371 "Direct Dependencies:",
372 Style::default().fg(scheme.text_muted),
373 )));
374 if data.dependencies.is_empty() {
375 lines.push(Line::from(" (none)"));
376 } else {
377 for dep in data.dependencies.iter().take(10) {
378 lines.push(Line::from(format!(" - {}", dep)));
379 }
380 }
381
382 lines.push(Line::from(""));
383 lines.push(Line::from(Span::styled(
384 "Dependents (packages that depend on this):",
385 Style::default().fg(scheme.text_muted),
386 )));
387 if data.dependents.is_empty() {
388 lines.push(Line::from(" (none)"));
389 } else {
390 for dep in data.dependents.iter().take(10) {
391 lines.push(Line::from(format!(" - {}", dep)));
392 }
393 }
394 lines
395 }
396 3 => {
397 let mut lines = vec![
399 Line::from(Span::styled(
400 "Associated Vulnerabilities",
401 Style::default()
402 .fg(scheme.accent)
403 .add_modifier(Modifier::BOLD),
404 )),
405 Line::from(""),
406 ];
407
408 if data.vulnerabilities.is_empty() {
409 lines.push(Line::from(Span::styled(
410 "No vulnerabilities found",
411 Style::default().fg(scheme.added),
412 )));
413 } else {
414 for vuln in data.vulnerabilities.iter().take(15) {
415 let severity_style = match vuln.severity.to_lowercase().as_str() {
416 "critical" => Style::default()
417 .fg(scheme.removed)
418 .add_modifier(Modifier::BOLD),
419 "high" => Style::default().fg(scheme.removed),
420 "medium" => Style::default().fg(scheme.warning),
421 "low" => Style::default().fg(scheme.modified),
422 _ => Style::default().fg(scheme.text_muted),
423 };
424
425 lines.push(Line::from(vec![
426 Span::styled(&vuln.vuln_id, Style::default().fg(scheme.primary)),
427 Span::raw(" ["),
428 Span::styled(&vuln.severity, severity_style),
429 Span::raw("] - "),
430 Span::styled(&vuln.status, Style::default().fg(scheme.text_muted)),
431 ]));
432 }
433 }
434 lines
435 }
436 _ => vec![],
437 };
438
439 let paragraph = Paragraph::new(lines).wrap(Wrap { trim: true });
440 f.render_widget(paragraph, area);
441}
442
443pub fn render_breadcrumbs(f: &mut Frame, area: Rect, breadcrumbs: &[String]) {
445 if breadcrumbs.is_empty() {
446 return;
447 }
448
449 let scheme = colors();
450
451 let mut spans = vec![Span::styled("< ", Style::default().fg(scheme.text_muted))];
452
453 for (i, crumb) in breadcrumbs.iter().enumerate() {
454 if i > 0 {
455 spans.push(Span::styled(" > ", Style::default().fg(scheme.text_muted)));
456 }
457 spans.push(Span::styled(crumb, Style::default().fg(scheme.accent)));
458 }
459
460 let line = Line::from(spans);
461 let paragraph = Paragraph::new(line);
462 f.render_widget(paragraph, area);
463}
464
465fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
468 let x = area.x + (area.width.saturating_sub(width)) / 2;
469 let y = area.y + (area.height.saturating_sub(height)) / 2;
470 Rect::new(x, y, width.min(area.width), height.min(area.height))
471}
472
473fn context_name(context: ShortcutsContext) -> &'static str {
474 match context {
475 ShortcutsContext::Global => "Global",
476 ShortcutsContext::MultiDiff => "Multi-Diff",
477 ShortcutsContext::Timeline => "Timeline",
478 ShortcutsContext::Matrix => "Matrix",
479 ShortcutsContext::Diff => "Diff",
480 }
481}
482
483struct ShortcutSection {
484 title: &'static str,
485 items: Vec<(&'static str, &'static str)>,
486}
487
488fn get_shortcuts_for_context(context: ShortcutsContext) -> Vec<ShortcutSection> {
489 let mut sections = vec![
490 ShortcutSection {
491 title: "Global",
492 items: vec![
493 ("q", "Quit application"),
494 ("?", "Toggle help"),
495 ("e", "Export dialog"),
496 ("l", "Color legend"),
497 ("T", "Toggle theme"),
498 ("/", "Search"),
499 ("K", "Keyboard shortcuts"),
500 ("V", "View switcher (multi-views)"),
501 ("D", "Component deep dive"),
502 ("b/Backspace", "Navigate back"),
503 ],
504 },
505 ShortcutSection {
506 title: "Navigation",
507 items: vec![
508 ("j/k", "Up/Down"),
509 ("h/l", "Left/Right"),
510 ("g/G", "First/Last"),
511 ("PgUp/PgDn", "Page up/down"),
512 ("Tab", "Next panel/tab"),
513 ("1-8", "Jump to tab"),
514 ],
515 },
516 ];
517
518 match context {
519 ShortcutsContext::MultiDiff => {
520 sections.push(ShortcutSection {
521 title: "Multi-Diff View",
522 items: vec![
523 ("p/Tab", "Switch panel"),
524 ("Enter", "View details"),
525 ("f", "Cycle filter preset"),
526 ("s", "Cycle sort field"),
527 ("S", "Toggle sort direction"),
528 ("v", "Variable components drill-down"),
529 ("x", "Toggle cross-target analysis"),
530 ("h", "Toggle heat map mode"),
531 ],
532 });
533 }
534 ShortcutsContext::Timeline => {
535 sections.push(ShortcutSection {
536 title: "Timeline View",
537 items: vec![
538 ("p/Tab", "Switch panel"),
539 ("d", "Compare versions"),
540 ("t", "Toggle statistics"),
541 ("g", "Jump to version"),
542 ("+/-", "Zoom chart"),
543 ("h/l", "Scroll chart"),
544 ("f", "Filter components"),
545 ("s", "Sort components"),
546 ],
547 });
548 }
549 ShortcutsContext::Matrix => {
550 sections.push(ShortcutSection {
551 title: "Matrix View",
552 items: vec![
553 ("p/Tab", "Switch panel"),
554 ("Enter", "View pair diff"),
555 ("t", "Cycle threshold"),
556 ("z", "Toggle focus mode"),
557 ("H", "Toggle row/col highlight"),
558 ("C", "Show cluster details"),
559 ("x", "Export options"),
560 ],
561 });
562 }
563 ShortcutsContext::Diff => {
564 sections.push(ShortcutSection {
565 title: "Diff View",
566 items: vec![
567 ("f", "Filter/toggle options"),
568 ("s", "Sort/cycle options"),
569 ("v", "Multi-select mode"),
570 ("Enter", "View details"),
571 ("n/N", "Navigate to related"),
572 ],
573 });
574 }
575 ShortcutsContext::Global => {}
576 }
577
578 sections
579}
580
581#[derive(Debug, Clone)]
586pub struct ThresholdTuningState {
587 pub visible: bool,
589 pub threshold: f64,
591 pub original_threshold: f64,
593 pub estimated_matches: usize,
595 pub total_components: usize,
597 pub step: f64,
599}
600
601impl Default for ThresholdTuningState {
602 fn default() -> Self {
603 Self {
604 visible: false,
605 threshold: 0.85,
606 original_threshold: 0.85,
607 estimated_matches: 0,
608 total_components: 0,
609 step: 0.05,
610 }
611 }
612}
613
614impl ThresholdTuningState {
615 pub fn new(threshold: f64, total_components: usize) -> Self {
617 Self {
618 visible: true,
619 threshold,
620 original_threshold: threshold,
621 estimated_matches: 0,
622 total_components,
623 step: 0.05,
624 }
625 }
626
627 pub fn increase(&mut self) {
629 self.threshold = (self.threshold + self.step).min(0.99);
630 }
631
632 pub fn decrease(&mut self) {
634 self.threshold = (self.threshold - self.step).max(0.50);
635 }
636
637 pub fn fine_increase(&mut self) {
639 self.threshold = (self.threshold + 0.01).min(0.99);
640 }
641
642 pub fn fine_decrease(&mut self) {
644 self.threshold = (self.threshold - 0.01).max(0.50);
645 }
646
647 pub fn reset(&mut self) {
649 self.threshold = self.original_threshold;
650 }
651
652 pub fn set_estimated_matches(&mut self, matches: usize) {
654 self.estimated_matches = matches;
655 }
656
657 pub fn match_percentage(&self) -> f64 {
659 if self.total_components == 0 {
660 0.0
661 } else {
662 (self.estimated_matches as f64 / self.total_components as f64) * 100.0
663 }
664 }
665}
666
667pub fn render_threshold_tuning(f: &mut Frame, state: &ThresholdTuningState) {
671 if !state.visible {
672 return;
673 }
674
675 let scheme = colors();
676 let area = f.area();
677
678 let overlay_width = 60;
680 let overlay_height = 14;
681 let overlay_area = centered_rect(overlay_width, overlay_height, area);
682
683 f.render_widget(Clear, overlay_area);
685
686 let block = Block::default()
688 .title(" Threshold Tuning ")
689 .title_alignment(Alignment::Center)
690 .borders(Borders::ALL)
691 .border_style(Style::default().fg(scheme.accent))
692 .style(Style::default().bg(scheme.background_alt));
693
694 let inner_area = block.inner(overlay_area);
695 f.render_widget(block, overlay_area);
696
697 let mut lines = vec![
699 Line::from(Span::styled(
700 "Adjust matching threshold to control match sensitivity",
701 Style::default().fg(scheme.text_muted),
702 )),
703 Line::from(""),
704 ];
705
706 lines.push(Line::from(vec![
708 Span::styled("Current threshold: ", Style::default().fg(scheme.text)),
709 Span::styled(
710 format!("{:.0}%", state.threshold * 100.0),
711 Style::default()
712 .fg(scheme.accent)
713 .add_modifier(Modifier::BOLD),
714 ),
715 Span::styled(
716 format!(" (was {:.0}%)", state.original_threshold * 100.0),
717 Style::default().fg(scheme.text_muted),
718 ),
719 ]));
720
721 let slider_width = 40;
723 let filled_width = ((state.threshold - 0.5) / 0.49 * slider_width as f64) as usize;
724 let empty_width = slider_width - filled_width;
725
726 lines.push(Line::from(""));
727 lines.push(Line::from(vec![
728 Span::styled("50% ", Style::default().fg(scheme.text_muted)),
729 Span::styled("▓".repeat(filled_width), Style::default().fg(scheme.accent)),
730 Span::styled(
731 "░".repeat(empty_width),
732 Style::default().fg(scheme.text_muted),
733 ),
734 Span::styled(" 99%", Style::default().fg(scheme.text_muted)),
735 ]));
736
737 lines.push(Line::from(""));
739 lines.push(Line::from(vec![
740 Span::styled("Preview: ", Style::default().fg(scheme.text)),
741 Span::styled(
742 format!("~{} components", state.estimated_matches),
743 Style::default().fg(scheme.primary),
744 ),
745 Span::styled(
746 format!(" would match ({:.1}%)", state.match_percentage()),
747 Style::default().fg(scheme.text_muted),
748 ),
749 ]));
750
751 lines.push(Line::from(""));
753 lines.push(Line::from(vec![
754 Span::styled("Presets: ", Style::default().fg(scheme.text_muted)),
755 Span::styled("95%", Style::default().fg(scheme.text)),
756 Span::styled("=strict ", Style::default().fg(scheme.text_muted)),
757 Span::styled("85%", Style::default().fg(scheme.text)),
758 Span::styled("=balanced ", Style::default().fg(scheme.text_muted)),
759 Span::styled("70%", Style::default().fg(scheme.text)),
760 Span::styled("=permissive", Style::default().fg(scheme.text_muted)),
761 ]));
762
763 lines.push(Line::from(""));
765 lines.push(Line::from(vec![
766 Span::styled("↑/↓", Style::default().fg(scheme.accent)),
767 Span::styled(" adjust ", Style::default().fg(scheme.text_muted)),
768 Span::styled("+/-", Style::default().fg(scheme.accent)),
769 Span::styled(" fine ", Style::default().fg(scheme.text_muted)),
770 Span::styled("r", Style::default().fg(scheme.accent)),
771 Span::styled(" reset ", Style::default().fg(scheme.text_muted)),
772 Span::styled("Enter", Style::default().fg(scheme.accent)),
773 Span::styled(" apply ", Style::default().fg(scheme.text_muted)),
774 Span::styled("Esc", Style::default().fg(scheme.accent)),
775 Span::styled(" cancel", Style::default().fg(scheme.text_muted)),
776 ]));
777
778 let paragraph = Paragraph::new(lines)
779 .alignment(Alignment::Left)
780 .wrap(Wrap { trim: true });
781 f.render_widget(paragraph, inner_area);
782}