Skip to main content

sbom_tools/tui/views/
timeline.rs

1//! Timeline analysis view.
2//!
3//! Displays SBOM evolution over time with version tracking.
4
5use crate::diff::{TimelineResult, VersionChangeType};
6use crate::tui::app::{TimelineComponentFilter, TimelineState};
7use crate::tui::theme::colors;
8use ratatui::{
9    layout::{Constraint, Direction, Layout, Rect},
10    style::{Modifier, Style},
11    text::{Line, Span},
12    widgets::{Bar, BarChart, BarGroup, Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
13    Frame,
14};
15
16/// Render the timeline analysis view
17pub fn render_timeline(f: &mut Frame, area: Rect, result: &TimelineResult, state: &TimelineState) {
18    let chunks = if state.show_statistics {
19        Layout::default()
20            .direction(Direction::Vertical)
21            .constraints([
22                Constraint::Length(3), // Header
23                Constraint::Length(6), // Statistics panel
24                Constraint::Length(8), // Timeline bar
25                Constraint::Min(12),   // Main content
26                Constraint::Length(3), // Status bar
27            ])
28            .split(area)
29    } else {
30        Layout::default()
31            .direction(Direction::Vertical)
32            .constraints([
33                Constraint::Length(3), // Header
34                Constraint::Length(8), // Timeline bar
35                Constraint::Min(15),   // Main content
36                Constraint::Length(3), // Status bar
37            ])
38            .split(area)
39    };
40
41    // Header
42    render_header(f, chunks[0], result, state);
43
44    let (bar_chunk, main_chunk, status_chunk) = if state.show_statistics {
45        // Render statistics panel
46        render_statistics_panel(f, chunks[1], result);
47        (chunks[2], chunks[3], chunks[4])
48    } else {
49        (chunks[1], chunks[2], chunks[3])
50    };
51
52    // Timeline visualization
53    render_timeline_bar(f, bar_chunk, result, state);
54
55    // Main content - split into versions and component history
56    let main_chunks = Layout::default()
57        .direction(Direction::Horizontal)
58        .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
59        .split(main_chunk);
60
61    render_versions_list(f, main_chunks[0], result, state);
62    render_component_history(f, main_chunks[1], result, state);
63
64    // Status bar
65    render_status_bar(f, status_chunk, result, state);
66
67    // Render overlays
68    if state.show_version_diff_modal {
69        render_version_diff_modal(f, area, result, state);
70    }
71
72    if state.show_component_history {
73        render_component_history_modal(f, area, result, state);
74    }
75
76    if state.search.active {
77        render_search_overlay(f, area, state);
78    }
79
80    if state.jump_mode {
81        render_jump_overlay(f, area, state);
82    }
83}
84
85fn render_header(f: &mut Frame, area: Rect, result: &TimelineResult, state: &TimelineState) {
86    let scheme = colors();
87    let first = result.sboms.first().map(|s| s.name.as_str()).unwrap_or("?");
88    let last = result.sboms.last().map(|s| s.name.as_str()).unwrap_or("?");
89
90    let title = format!(
91        " Timeline: {} → {} ({} versions) ",
92        first,
93        last,
94        result.sboms.len()
95    );
96
97    let text = vec![Line::from(vec![
98        Span::styled(
99            title,
100            Style::default()
101                .fg(scheme.primary)
102                .add_modifier(Modifier::BOLD),
103        ),
104        Span::raw(" │ "),
105        Span::styled("Sort: ", Style::default().fg(scheme.text_muted)),
106        Span::styled(
107            format!(
108                "{} {}",
109                state.sort_by.label(),
110                state.sort_direction.indicator()
111            ),
112            Style::default().fg(scheme.accent),
113        ),
114        Span::raw(" │ "),
115        Span::styled("Filter: ", Style::default().fg(scheme.text_muted)),
116        Span::styled(
117            state.component_filter.label(),
118            Style::default().fg(scheme.accent),
119        ),
120        if state.show_statistics {
121            Span::styled(" │ Stats", Style::default().fg(scheme.info))
122        } else {
123            Span::raw("")
124        },
125    ])];
126
127    let header = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
128
129    f.render_widget(header, area);
130}
131
132fn render_statistics_panel(f: &mut Frame, area: Rect, result: &TimelineResult) {
133    let scheme = colors();
134
135    let total_added: usize = result
136        .incremental_diffs
137        .iter()
138        .map(|d| d.summary.components_added)
139        .sum();
140    let total_removed: usize = result
141        .incremental_diffs
142        .iter()
143        .map(|d| d.summary.components_removed)
144        .sum();
145    let total_modified: usize = result
146        .incremental_diffs
147        .iter()
148        .map(|d| d.summary.components_modified)
149        .sum();
150
151    let avg_components: usize = if !result.sboms.is_empty() {
152        result
153            .sboms
154            .iter()
155            .map(|s| s.component_count)
156            .sum::<usize>()
157            / result.sboms.len()
158    } else {
159        0
160    };
161
162    // Compliance trend summary: count how many versions pass CRA Phase 2
163    let compliance_trend = &result.evolution_summary.compliance_trend;
164    let cra_pass_count = compliance_trend
165        .iter()
166        .filter(|snap| {
167            snap.scores
168                .iter()
169                .any(|s| s.standard.contains("CRA Phase 2") && s.is_compliant)
170        })
171        .count();
172    let compliance_trend_str = if compliance_trend.is_empty() {
173        "N/A".to_string()
174    } else {
175        format!("{}/{} pass CRA", cra_pass_count, compliance_trend.len())
176    };
177    let compliance_color = if cra_pass_count == compliance_trend.len() && !compliance_trend.is_empty() {
178        scheme.success
179    } else if cra_pass_count > 0 {
180        scheme.warning
181    } else {
182        scheme.error
183    };
184
185    let text = vec![
186        Line::from(vec![
187            Span::styled("Total Added: ", Style::default().fg(scheme.added)),
188            Span::raw(total_added.to_string()),
189            Span::raw("  "),
190            Span::styled("Total Removed: ", Style::default().fg(scheme.removed)),
191            Span::raw(total_removed.to_string()),
192            Span::raw("  "),
193            Span::styled("Total Modified: ", Style::default().fg(scheme.modified)),
194            Span::raw(total_modified.to_string()),
195        ]),
196        Line::from(vec![
197            Span::styled("Avg Components: ", Style::default().fg(scheme.text_muted)),
198            Span::styled(
199                avg_components.to_string(),
200                Style::default().fg(scheme.primary),
201            ),
202            Span::raw("  "),
203            Span::styled("Version Changes: ", Style::default().fg(scheme.text_muted)),
204            Span::styled(
205                result.evolution_summary.version_history.len().to_string(),
206                Style::default().fg(scheme.accent),
207            ),
208            Span::raw("  "),
209            Span::styled("Compliance: ", Style::default().fg(scheme.text_muted)),
210            Span::styled(compliance_trend_str, Style::default().fg(compliance_color)),
211        ]),
212    ];
213
214    let block = Block::default()
215        .title(" Statistics [t: toggle] ")
216        .borders(Borders::ALL)
217        .border_style(Style::default().fg(scheme.info));
218
219    let paragraph = Paragraph::new(text).block(block);
220    f.render_widget(paragraph, area);
221}
222
223fn render_timeline_bar(f: &mut Frame, area: Rect, result: &TimelineResult, state: &TimelineState) {
224    let scheme = colors();
225    let selected = state.selected_version;
226
227    // Calculate bar width based on zoom level
228    let bar_width = 5 + (state.chart_zoom as u16 * 2);
229
230    // Calculate visible range based on scroll
231    let visible_count = (area.width.saturating_sub(4)) / (bar_width + 1);
232    let start_idx = state
233        .chart_scroll
234        .min(result.sboms.len().saturating_sub(visible_count as usize));
235    let end_idx = (start_idx + visible_count as usize).min(result.sboms.len());
236
237    let bars: Vec<Bar> = result
238        .sboms
239        .iter()
240        .enumerate()
241        .skip(start_idx)
242        .take(end_idx - start_idx)
243        .map(|(i, sbom)| {
244            let style = if i == selected {
245                Style::default().fg(scheme.accent)
246            } else if state.compare_version == Some(i) {
247                Style::default().fg(scheme.warning)
248            } else {
249                Style::default().fg(scheme.primary)
250            };
251
252            Bar::default()
253                .value(sbom.component_count as u64)
254                .label(Line::from(
255                    sbom.name
256                        .chars()
257                        .take(bar_width as usize - 1)
258                        .collect::<String>(),
259                ))
260                .style(style)
261        })
262        .collect();
263
264    let title = format!(
265        " Component Count Evolution ({}-{}/{}) [+/-: zoom, h/l: scroll] ",
266        start_idx + 1,
267        end_idx,
268        result.sboms.len()
269    );
270
271    let barchart = BarChart::default()
272        .block(
273            Block::default()
274                .title(title)
275                .borders(Borders::ALL)
276                .border_style(Style::default().fg(scheme.info)),
277        )
278        .data(BarGroup::default().bars(&bars))
279        .bar_width(bar_width)
280        .bar_gap(1)
281        .max(
282            result
283                .sboms
284                .iter()
285                .map(|s| s.component_count)
286                .max()
287                .unwrap_or(100) as u64
288                + 10,
289        );
290
291    f.render_widget(barchart, area);
292}
293
294fn render_versions_list(f: &mut Frame, area: Rect, result: &TimelineResult, state: &TimelineState) {
295    let scheme = colors();
296    let is_active = matches!(state.active_panel, TimelinePanel::Versions);
297    let selected = state.selected_version;
298
299    let rows: Vec<Row> = result
300        .sboms
301        .iter()
302        .enumerate()
303        .map(|(i, sbom)| {
304            // Get diff info if available
305            let (added, removed) = if i > 0 {
306                result
307                    .incremental_diffs
308                    .get(i - 1)
309                    .map(|d| (d.summary.components_added, d.summary.components_removed))
310                    .unwrap_or((0, 0))
311            } else {
312                (sbom.component_count, 0)
313            };
314
315            let is_compare_target = state.compare_version == Some(i);
316            let style = if i == selected {
317                Style::default()
318                    .bg(scheme.selection)
319                    .add_modifier(Modifier::BOLD)
320            } else if is_compare_target {
321                Style::default()
322                    .bg(scheme.warning)
323                    .add_modifier(Modifier::ITALIC)
324            } else {
325                Style::default()
326            };
327
328            // Highlight search matches
329            let name_style = if state.search.matches.contains(&i) {
330                style.fg(scheme.accent).add_modifier(Modifier::BOLD)
331            } else {
332                style
333            };
334
335            let change_str = if i == 0 {
336                "initial".to_string()
337            } else {
338                format!("+{} -{}", added, removed)
339            };
340
341            let change_color = if added > removed {
342                scheme.added
343            } else if removed > added {
344                scheme.removed
345            } else {
346                scheme.text_muted
347            };
348
349            // CRA Phase 2 compliance indicator for this version
350            let compliance_indicator = result
351                .evolution_summary
352                .compliance_trend
353                .get(i)
354                .map(|snap| {
355                    // Find CRA Phase 2 score
356                    let cra = snap.scores.iter().find(|s| s.standard.contains("CRA Phase 2"));
357                    match cra {
358                        Some(s) if s.is_compliant && s.warning_count == 0 => ("✓", scheme.success),
359                        Some(s) if s.is_compliant => ("⚠", scheme.warning),
360                        Some(_) => ("✗", scheme.error),
361                        None => ("-", scheme.text_muted),
362                    }
363                })
364                .unwrap_or(("-", scheme.text_muted));
365
366            Row::new(vec![
367                Cell::from(format!("{}.", i + 1)).style(style),
368                Cell::from(sbom.name.clone()).style(name_style),
369                Cell::from(sbom.component_count.to_string()).style(style),
370                Cell::from(change_str).style(style.fg(change_color)),
371                Cell::from(compliance_indicator.0)
372                    .style(style.fg(compliance_indicator.1)),
373            ])
374        })
375        .collect();
376
377    let header = Row::new(vec!["#", "Version", "Comps", "Changes", "CRA"])
378        .style(
379            Style::default()
380                .fg(scheme.primary)
381                .add_modifier(Modifier::BOLD),
382        )
383        .bottom_margin(1);
384
385    let widths = [
386        Constraint::Length(4),
387        Constraint::Min(10),
388        Constraint::Length(6),
389        Constraint::Length(10),
390        Constraint::Length(4),
391    ];
392
393    let border_color = if is_active {
394        scheme.accent
395    } else {
396        scheme.text
397    };
398    let title = " Versions [g: jump, d: diff] ".to_string();
399
400    let table = Table::new(rows, widths)
401        .header(header)
402        .block(
403            Block::default()
404                .title(title)
405                .borders(Borders::ALL)
406                .border_style(Style::default().fg(border_color)),
407        )
408        .row_highlight_style(Style::default().add_modifier(Modifier::BOLD));
409
410    f.render_widget(table, area);
411}
412
413fn render_component_history(
414    f: &mut Frame,
415    area: Rect,
416    result: &TimelineResult,
417    state: &TimelineState,
418) {
419    let scheme = colors();
420    let is_active = matches!(state.active_panel, TimelinePanel::Components);
421    let selected = state.selected_component;
422
423    // Get component evolution data with filtering
424    let all_evolutions: Vec<_> = result
425        .evolution_summary
426        .components_added
427        .iter()
428        .map(|e| (e, false)) // (evolution, is_removed)
429        .chain(
430            result
431                .evolution_summary
432                .components_removed
433                .iter()
434                .map(|e| (e, true)),
435        )
436        .collect();
437
438    let filtered_evolutions: Vec<_> = all_evolutions
439        .iter()
440        .filter(|(evo, is_removed)| {
441            match state.component_filter {
442                TimelineComponentFilter::All => true,
443                TimelineComponentFilter::Added => !*is_removed,
444                TimelineComponentFilter::Removed => *is_removed,
445                TimelineComponentFilter::VersionChanged => {
446                    // Check if version changed
447                    evo.current_version.as_ref() != Some(&evo.first_seen_version)
448                }
449                TimelineComponentFilter::Stable => {
450                    !*is_removed && evo.current_version.as_ref() == Some(&evo.first_seen_version)
451                }
452            }
453        })
454        .collect();
455
456    let rows: Vec<Row> = filtered_evolutions
457        .iter()
458        .enumerate()
459        .take(20)
460        .map(|(i, (evo, is_removed))| {
461            let style = if i == selected {
462                Style::default()
463                    .bg(scheme.selection)
464                    .add_modifier(Modifier::BOLD)
465            } else {
466                Style::default()
467            };
468
469            let status_style = if *is_removed {
470                Style::default().fg(scheme.removed)
471            } else {
472                Style::default().fg(scheme.added)
473            };
474
475            let status = if *is_removed { "Removed" } else { "Added" };
476            let version_info = if *is_removed {
477                format!("{} @ v{}", evo.first_seen_version, evo.first_seen_index + 1)
478            } else {
479                evo.current_version
480                    .clone()
481                    .unwrap_or(evo.first_seen_version.clone())
482            };
483
484            Row::new(vec![
485                Cell::from(evo.name.clone()).style(style),
486                Cell::from(version_info).style(style),
487                Cell::from(format!("v{}", evo.first_seen_index + 1)).style(style),
488                Cell::from(status).style(status_style),
489            ])
490        })
491        .collect();
492
493    let header = Row::new(vec!["Component", "Version", "Since", "Status"])
494        .style(
495            Style::default()
496                .fg(scheme.primary)
497                .add_modifier(Modifier::BOLD),
498        )
499        .bottom_margin(1);
500
501    let widths = [
502        Constraint::Percentage(40),
503        Constraint::Percentage(25),
504        Constraint::Percentage(15),
505        Constraint::Percentage(20),
506    ];
507
508    let border_color = if is_active {
509        scheme.accent
510    } else {
511        scheme.text
512    };
513
514    let title = format!(
515        " Component Evolution ({}/{}) [f: filter, Enter: detail] ",
516        filtered_evolutions.len(),
517        all_evolutions.len()
518    );
519
520    let table = Table::new(rows, widths)
521        .header(header)
522        .block(
523            Block::default()
524                .title(title)
525                .borders(Borders::ALL)
526                .border_style(Style::default().fg(border_color)),
527        )
528        .row_highlight_style(Style::default().add_modifier(Modifier::BOLD));
529
530    f.render_widget(table, area);
531}
532
533fn render_status_bar(f: &mut Frame, area: Rect, result: &TimelineResult, _state: &TimelineState) {
534    let scheme = colors();
535    let total_added: usize = result
536        .incremental_diffs
537        .iter()
538        .map(|d| d.summary.components_added)
539        .sum();
540    let total_removed: usize = result
541        .incremental_diffs
542        .iter()
543        .map(|d| d.summary.components_removed)
544        .sum();
545
546    let status = Line::from(vec![
547        Span::styled("Added: ", Style::default().fg(scheme.text_muted)),
548        Span::styled(total_added.to_string(), Style::default().fg(scheme.added)),
549        Span::raw("  "),
550        Span::styled("Removed: ", Style::default().fg(scheme.text_muted)),
551        Span::styled(
552            total_removed.to_string(),
553            Style::default().fg(scheme.removed),
554        ),
555        Span::raw("  │  "),
556        Span::styled("/", Style::default().fg(scheme.primary)),
557        Span::raw(": search  "),
558        Span::styled("g", Style::default().fg(scheme.primary)),
559        Span::raw(": jump  "),
560        Span::styled("d", Style::default().fg(scheme.primary)),
561        Span::raw(": diff  "),
562        Span::styled("t", Style::default().fg(scheme.primary)),
563        Span::raw(": stats  "),
564        Span::styled("f", Style::default().fg(scheme.primary)),
565        Span::raw(": filter"),
566    ]);
567
568    let block = Block::default().borders(Borders::ALL);
569    let paragraph = Paragraph::new(status).block(block);
570    f.render_widget(paragraph, area);
571}
572
573/// Render version diff modal
574fn render_version_diff_modal(
575    f: &mut Frame,
576    area: Rect,
577    result: &TimelineResult,
578    state: &TimelineState,
579) {
580    let scheme = colors();
581
582    // Modal area
583    let modal_width = area.width * 80 / 100;
584    let modal_height = area.height * 70 / 100;
585    let modal_x = (area.width - modal_width) / 2;
586    let modal_y = (area.height - modal_height) / 2;
587    let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
588
589    f.render_widget(Clear, modal_area);
590
591    let selected = state.selected_version;
592    let compare = state.compare_version.unwrap_or(0);
593
594    let sbom_a = result.sboms.get(selected);
595    let sbom_b = result.sboms.get(compare);
596
597    let (name_a, name_b) = match (sbom_a, sbom_b) {
598        (Some(a), Some(b)) => (a.name.clone(), b.name.clone()),
599        _ => return,
600    };
601
602    // Get diff between versions if available
603    let diff_info = if selected > 0 && compare == selected - 1 {
604        result.incremental_diffs.get(compare)
605    } else if compare > 0 && selected == compare - 1 {
606        result.incremental_diffs.get(selected)
607    } else {
608        None
609    };
610
611    let mut lines = vec![
612        Line::from(vec![
613            Span::styled("Comparing: ", Style::default().fg(scheme.text_muted)),
614            Span::styled(
615                &name_a,
616                Style::default()
617                    .fg(scheme.primary)
618                    .add_modifier(Modifier::BOLD),
619            ),
620            Span::raw(" ↔ "),
621            Span::styled(
622                &name_b,
623                Style::default()
624                    .fg(scheme.warning)
625                    .add_modifier(Modifier::BOLD),
626            ),
627        ]),
628        Line::from(""),
629    ];
630
631    if let (Some(a), Some(b)) = (sbom_a, sbom_b) {
632        lines.push(Line::from(vec![
633            Span::styled("Components: ", Style::default().fg(scheme.text_muted)),
634            Span::styled(
635                a.component_count.to_string(),
636                Style::default().fg(scheme.primary),
637            ),
638            Span::raw(" vs "),
639            Span::styled(
640                b.component_count.to_string(),
641                Style::default().fg(scheme.warning),
642            ),
643        ]));
644    }
645
646    if let Some(diff) = diff_info {
647        lines.push(Line::from(""));
648        lines.push(Line::from(vec![Span::styled(
649            "Changes:",
650            Style::default().fg(scheme.text_muted),
651        )]));
652        lines.push(Line::from(vec![
653            Span::styled("  + Added: ", Style::default().fg(scheme.added)),
654            Span::raw(diff.summary.components_added.to_string()),
655        ]));
656        lines.push(Line::from(vec![
657            Span::styled("  - Removed: ", Style::default().fg(scheme.removed)),
658            Span::raw(diff.summary.components_removed.to_string()),
659        ]));
660        lines.push(Line::from(vec![
661            Span::styled("  ~ Modified: ", Style::default().fg(scheme.modified)),
662            Span::raw(diff.summary.components_modified.to_string()),
663        ]));
664
665        // Show some added components
666        lines.push(Line::from(""));
667        lines.push(Line::from(vec![Span::styled(
668            "Added Components:",
669            Style::default().fg(scheme.added),
670        )]));
671        for comp in diff.components.added.iter().take(5) {
672            lines.push(Line::from(vec![
673                Span::raw("  + "),
674                Span::styled(&comp.name, Style::default().fg(scheme.text)),
675            ]));
676        }
677
678        lines.push(Line::from(""));
679        lines.push(Line::from(vec![Span::styled(
680            "Removed Components:",
681            Style::default().fg(scheme.removed),
682        )]));
683        for comp in diff.components.removed.iter().take(5) {
684            lines.push(Line::from(vec![
685                Span::raw("  - "),
686                Span::styled(&comp.name, Style::default().fg(scheme.text)),
687            ]));
688        }
689    } else {
690        lines.push(Line::from(""));
691        lines.push(Line::from(vec![Span::styled(
692            "No direct diff available between these versions.",
693            Style::default().fg(scheme.text_muted),
694        )]));
695        lines.push(Line::from(vec![Span::styled(
696            "Select adjacent versions for detailed diff.",
697            Style::default().fg(scheme.text_muted),
698        )]));
699    }
700
701    lines.push(Line::from(""));
702    lines.push(Line::from(vec![
703        Span::styled("←/→", Style::default().fg(scheme.primary)),
704        Span::raw(": change compare version  "),
705        Span::styled("Esc", Style::default().fg(scheme.primary)),
706        Span::raw(": close"),
707    ]));
708
709    let block = Block::default()
710        .title(format!(" Version Diff: {} ↔ {} ", name_a, name_b))
711        .borders(Borders::ALL)
712        .border_style(Style::default().fg(scheme.accent))
713        .style(Style::default().bg(scheme.muted));
714
715    let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
716    f.render_widget(paragraph, modal_area);
717}
718
719/// Render component history modal
720fn render_component_history_modal(
721    f: &mut Frame,
722    area: Rect,
723    result: &TimelineResult,
724    state: &TimelineState,
725) {
726    let scheme = colors();
727
728    let modal_width = area.width * 75 / 100;
729    let modal_height = area.height * 60 / 100;
730    let modal_x = (area.width - modal_width) / 2;
731    let modal_y = (area.height - modal_height) / 2;
732    let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
733
734    f.render_widget(Clear, modal_area);
735
736    // Get selected component
737    let all_evolutions: Vec<_> = result
738        .evolution_summary
739        .components_added
740        .iter()
741        .chain(result.evolution_summary.components_removed.iter())
742        .collect();
743
744    let evo = match all_evolutions.get(state.selected_component) {
745        Some(e) => *e,
746        None => return,
747    };
748
749    let mut lines = vec![
750        Line::from(vec![
751            Span::styled("Component: ", Style::default().fg(scheme.text_muted)),
752            Span::styled(
753                &evo.name,
754                Style::default()
755                    .fg(scheme.primary)
756                    .add_modifier(Modifier::BOLD),
757            ),
758        ]),
759        Line::from(""),
760        Line::from(vec![
761            Span::styled("First Seen: ", Style::default().fg(scheme.text_muted)),
762            Span::styled(
763                format!("v{} ({})", evo.first_seen_index + 1, evo.first_seen_version),
764                Style::default().fg(scheme.accent),
765            ),
766        ]),
767    ];
768
769    if let Some(current) = &evo.current_version {
770        lines.push(Line::from(vec![
771            Span::styled("Current Version: ", Style::default().fg(scheme.text_muted)),
772            Span::styled(current, Style::default().fg(scheme.added)),
773        ]));
774    }
775
776    if let Some(last_seen) = evo.last_seen_index {
777        lines.push(Line::from(vec![
778            Span::styled("Last Seen: ", Style::default().fg(scheme.text_muted)),
779            Span::styled(
780                format!("v{}", last_seen + 1),
781                Style::default().fg(scheme.removed),
782            ),
783        ]));
784    }
785
786    // Show version history if available
787    if let Some(history) = result.evolution_summary.version_history.get(&evo.name) {
788        lines.push(Line::from(""));
789        lines.push(Line::from(vec![Span::styled(
790            "Version History:",
791            Style::default().fg(scheme.text_muted),
792        )]));
793
794        for point in history.iter().take(10) {
795            let change_style = match point.change_type {
796                VersionChangeType::Initial => Style::default().fg(scheme.info),
797                VersionChangeType::MajorUpgrade => Style::default().fg(scheme.critical),
798                VersionChangeType::MinorUpgrade => Style::default().fg(scheme.added),
799                VersionChangeType::PatchUpgrade => Style::default().fg(scheme.primary),
800                VersionChangeType::Downgrade => Style::default().fg(scheme.removed),
801                VersionChangeType::Unchanged => Style::default().fg(scheme.text_muted),
802                VersionChangeType::Removed => Style::default().fg(scheme.muted),
803                VersionChangeType::Absent => Style::default().fg(scheme.muted),
804            };
805
806            lines.push(Line::from(vec![
807                Span::raw("  "),
808                Span::styled(&point.sbom_name, Style::default().fg(scheme.text)),
809                Span::raw(": "),
810                Span::styled(
811                    point.version.as_deref().unwrap_or("-"),
812                    Style::default().fg(scheme.accent),
813                ),
814                Span::raw(" "),
815                Span::styled(point.change_type.symbol(), change_style),
816            ]));
817        }
818    }
819
820    lines.push(Line::from(""));
821    lines.push(Line::from(vec![
822        Span::styled("Esc", Style::default().fg(scheme.primary)),
823        Span::raw(": close"),
824    ]));
825
826    let block = Block::default()
827        .title(format!(" Component: {} ", evo.name))
828        .borders(Borders::ALL)
829        .border_style(Style::default().fg(scheme.info))
830        .style(Style::default().bg(scheme.muted));
831
832    let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
833    f.render_widget(paragraph, modal_area);
834}
835
836/// Render search overlay
837fn render_search_overlay(f: &mut Frame, area: Rect, state: &TimelineState) {
838    let scheme = colors();
839
840    let search_area = Rect::new(area.x, area.height - 3, area.width, 3);
841    f.render_widget(Clear, search_area);
842
843    let search_text = Line::from(vec![
844        Span::styled("Search: ", Style::default().fg(scheme.text_muted)),
845        Span::styled(&state.search.query, Style::default().fg(scheme.text)),
846        Span::styled("│", Style::default().fg(scheme.accent)),
847        Span::raw("  "),
848        Span::styled(
849            state.search.match_position(),
850            Style::default().fg(scheme.text_muted),
851        ),
852    ]);
853
854    let block = Block::default()
855        .borders(Borders::ALL)
856        .border_style(Style::default().fg(scheme.accent))
857        .style(Style::default().bg(scheme.muted));
858
859    let paragraph = Paragraph::new(search_text).block(block);
860    f.render_widget(paragraph, search_area);
861}
862
863/// Render jump overlay
864fn render_jump_overlay(f: &mut Frame, area: Rect, state: &TimelineState) {
865    let scheme = colors();
866
867    let jump_area = Rect::new(area.x, area.height - 3, area.width, 3);
868    f.render_widget(Clear, jump_area);
869
870    let jump_text = Line::from(vec![
871        Span::styled("Jump to version: ", Style::default().fg(scheme.text_muted)),
872        Span::styled(&state.jump_input, Style::default().fg(scheme.text)),
873        Span::styled("│", Style::default().fg(scheme.accent)),
874        Span::raw("  "),
875        Span::styled(
876            format!("(1-{})", state.total_versions),
877            Style::default().fg(scheme.text_muted),
878        ),
879    ]);
880
881    let block = Block::default()
882        .borders(Borders::ALL)
883        .border_style(Style::default().fg(scheme.warning))
884        .style(Style::default().bg(scheme.muted));
885
886    let paragraph = Paragraph::new(jump_text).block(block);
887    f.render_widget(paragraph, jump_area);
888}
889
890/// Panels in the timeline view
891#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892pub enum TimelinePanel {
893    Versions,
894    Components,
895}