1use 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
16pub 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), Constraint::Length(6), Constraint::Length(8), Constraint::Min(12), Constraint::Length(3), ])
28 .split(area)
29 } else {
30 Layout::default()
31 .direction(Direction::Vertical)
32 .constraints([
33 Constraint::Length(3), Constraint::Length(8), Constraint::Min(15), Constraint::Length(3), ])
38 .split(area)
39 };
40
41 render_header(f, chunks[0], result, state);
43
44 let (bar_chunk, main_chunk, status_chunk) = if state.show_statistics {
45 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 render_timeline_bar(f, bar_chunk, result, state);
54
55 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 render_status_bar(f, status_chunk, result, state);
66
67 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 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 let bar_width = 5 + (state.chart_zoom as u16 * 2);
229
230 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 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 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 let compliance_indicator = result
351 .evolution_summary
352 .compliance_trend
353 .get(i)
354 .map(|snap| {
355 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 let all_evolutions: Vec<_> = result
425 .evolution_summary
426 .components_added
427 .iter()
428 .map(|e| (e, false)) .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 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
573fn render_version_diff_modal(
575 f: &mut Frame,
576 area: Rect,
577 result: &TimelineResult,
578 state: &TimelineState,
579) {
580 let scheme = colors();
581
582 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 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 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
719fn 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 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 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
836fn 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
863fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
892pub enum TimelinePanel {
893 Versions,
894 Components,
895}