use crate::tui::state::ListNavigation;
use std::collections::HashSet;
pub struct ComponentsState {
pub selected: usize,
pub total: usize,
pub filter: ComponentFilter,
pub sort_by: ComponentSort,
pub multi_selected: HashSet<usize>,
pub multi_select_mode: bool,
pub focus_detail: bool,
pub security_filter: crate::tui::viewmodel::security_filter::SecurityFilterState,
pub scroll_offset: usize,
}
impl ComponentsState {
pub fn new(total: usize) -> Self {
Self {
selected: 0,
total,
filter: ComponentFilter::All,
sort_by: ComponentSort::Name,
multi_selected: HashSet::new(),
multi_select_mode: false,
focus_detail: false,
security_filter: crate::tui::viewmodel::security_filter::SecurityFilterState::new(),
scroll_offset: 0,
}
}
pub const fn toggle_focus(&mut self) {
self.focus_detail = !self.focus_detail;
}
pub fn toggle_filter(&mut self) {
self.filter = match self.filter {
ComponentFilter::All => ComponentFilter::Added,
ComponentFilter::Added => ComponentFilter::Removed,
ComponentFilter::Removed => ComponentFilter::Modified,
ComponentFilter::Modified => ComponentFilter::EolOnly,
ComponentFilter::EolOnly => ComponentFilter::EolRisk,
ComponentFilter::EolRisk => ComponentFilter::All,
};
self.selected = 0; self.multi_selected.clear(); }
pub const fn toggle_sort(&mut self) {
self.sort_by = match self.sort_by {
ComponentSort::Name => ComponentSort::Version,
ComponentSort::Version => ComponentSort::Ecosystem,
ComponentSort::Ecosystem => ComponentSort::MatchScore,
ComponentSort::MatchScore => ComponentSort::Name,
};
}
pub fn toggle_current_selection(&mut self) {
if self.multi_selected.contains(&self.selected) {
self.multi_selected.remove(&self.selected);
} else {
self.multi_selected.insert(self.selected);
}
}
pub fn toggle_multi_select_mode(&mut self) {
self.multi_select_mode = !self.multi_select_mode;
if !self.multi_select_mode {
self.multi_selected.clear();
}
}
pub fn select_all(&mut self) {
self.multi_selected = (0..self.total).collect();
}
pub fn is_selected(&self, index: usize) -> bool {
self.multi_selected.contains(&index)
}
pub fn selection_count(&self) -> usize {
self.multi_selected.len()
}
}
impl ListNavigation for ComponentsState {
fn selected(&self) -> usize {
self.selected
}
fn set_selected(&mut self, idx: usize) {
self.selected = idx;
}
fn total(&self) -> usize {
self.total
}
fn set_total(&mut self, total: usize) {
self.total = total;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComponentFilter {
All,
Added,
Removed,
Modified,
EolOnly,
EolRisk,
}
impl ComponentFilter {
pub const fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Added => "Added",
Self::Removed => "Removed",
Self::Modified => "Modified",
Self::EolOnly => "EOL",
Self::EolRisk => "EOL Risk",
}
}
pub const fn is_view_filter(self) -> bool {
matches!(self, Self::All | Self::EolOnly | Self::EolRisk)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComponentSort {
Name,
Version,
Ecosystem,
MatchScore,
}
pub fn sort_component_changes(
items: &mut Vec<&crate::diff::ComponentChange>,
sort_by: ComponentSort,
) {
match sort_by {
ComponentSort::MatchScore => {
let score = |c: &crate::diff::ComponentChange| {
c.match_info.as_ref().map_or(2.0_f64, |m| m.score)
};
items.sort_by(|a, b| {
score(a).total_cmp(&score(b)).then_with(|| {
(a.name.to_lowercase(), a.id.to_lowercase())
.cmp(&(b.name.to_lowercase(), b.id.to_lowercase()))
})
});
}
ComponentSort::Name => {
items.sort_by_key(|comp| {
(
comp.name.to_lowercase(),
comp.new_version
.as_deref()
.or(comp.old_version.as_deref())
.unwrap_or("")
.to_lowercase(),
comp.id.to_lowercase(),
)
});
}
ComponentSort::Version => {
items.sort_by_key(|comp| {
(
comp.new_version
.as_deref()
.or(comp.old_version.as_deref())
.unwrap_or("")
.to_lowercase(),
comp.name.to_lowercase(),
comp.id.to_lowercase(),
)
});
}
ComponentSort::Ecosystem => {
items.sort_by_key(|comp| {
(
comp.ecosystem.as_deref().unwrap_or("").to_lowercase(),
comp.name.to_lowercase(),
comp.id.to_lowercase(),
)
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::ListNavigation;
#[test]
fn match_score_sort_orders_low_confidence_first() {
let old = crate::model::Component::new("fuzzy-lib".to_string(), "fuzzy-lib".to_string())
.with_version("1.0".to_string());
let new = old.clone();
let fuzzy = crate::diff::ComponentChange::modified(&old, &new, Vec::new(), 1)
.with_match_info(crate::diff::MatchInfo::simple(0.62, "Fuzzy", "test"));
let old = crate::model::Component::new("exact-lib".to_string(), "exact-lib".to_string())
.with_version("1.0".to_string());
let new = old.clone();
let exact = crate::diff::ComponentChange::modified(&old, &new, Vec::new(), 1)
.with_match_info(crate::diff::MatchInfo::simple(1.0, "NameIdentity", "test"));
let comp = crate::model::Component::new("aaa-new".to_string(), "aaa-new".to_string());
let added = crate::diff::ComponentChange::added(&comp, 1);
let mut items = vec![&added, &exact, &fuzzy];
sort_component_changes(&mut items, ComponentSort::MatchScore);
let names: Vec<&str> = items.iter().map(|c| c.name.as_str()).collect();
assert_eq!(
names,
vec!["fuzzy-lib", "exact-lib", "aaa-new"],
"ascending score, unmatched last"
);
}
#[test]
fn sort_cycling_includes_match_score() {
let mut state = ComponentsState::new(0);
assert_eq!(state.sort_by, ComponentSort::Name);
state.toggle_sort();
assert_eq!(state.sort_by, ComponentSort::Version);
state.toggle_sort();
assert_eq!(state.sort_by, ComponentSort::Ecosystem);
state.toggle_sort();
assert_eq!(state.sort_by, ComponentSort::MatchScore);
state.toggle_sort();
assert_eq!(state.sort_by, ComponentSort::Name, "cycle closes");
}
#[test]
fn component_filter_labels() {
assert_eq!(ComponentFilter::All.label(), "All");
assert_eq!(ComponentFilter::Added.label(), "Added");
assert_eq!(ComponentFilter::Removed.label(), "Removed");
assert_eq!(ComponentFilter::Modified.label(), "Modified");
assert_eq!(ComponentFilter::EolOnly.label(), "EOL");
assert_eq!(ComponentFilter::EolRisk.label(), "EOL Risk");
}
#[test]
fn component_filter_is_view_filter() {
assert!(ComponentFilter::All.is_view_filter());
assert!(ComponentFilter::EolOnly.is_view_filter());
assert!(ComponentFilter::EolRisk.is_view_filter());
assert!(!ComponentFilter::Added.is_view_filter());
assert!(!ComponentFilter::Removed.is_view_filter());
assert!(!ComponentFilter::Modified.is_view_filter());
}
#[test]
fn diff_filter_cycling_includes_eol() {
let mut state = ComponentsState::new(10);
assert_eq!(state.filter, ComponentFilter::All);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::Added);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::Removed);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::Modified);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::EolOnly);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::EolRisk);
state.toggle_filter();
assert_eq!(state.filter, ComponentFilter::All);
}
#[test]
fn filter_change_resets_selection() {
let mut state = ComponentsState::new(10);
state.set_selected(5);
state.multi_selected.insert(3);
state.toggle_filter();
assert_eq!(
state.selected(),
0,
"Selection should reset on filter change"
);
assert!(
state.multi_selected.is_empty(),
"Multi-selection should clear on filter change"
);
}
}