use crate::diff::ChangeType;
#[derive(Debug, Clone)]
pub struct AlignedRow {
pub left_name: Option<String>,
pub left_version: Option<String>,
pub right_name: Option<String>,
pub right_version: Option<String>,
pub change_type: crate::diff::ChangeType,
pub component_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UnifiedEntry {
pub name: String,
pub old_version: Option<String>,
pub new_version: Option<String>,
pub change_type: UnifiedChangeType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnifiedChangeType {
Upgrade,
Downgrade,
Modified,
Added,
Removed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlignmentMode {
Grouped,
#[default]
Aligned,
Unified,
}
impl AlignmentMode {
pub const fn toggle(&mut self) {
*self = match self {
Self::Grouped => Self::Aligned,
Self::Aligned => Self::Unified,
Self::Unified => Self::Grouped,
};
}
pub const fn name(self) -> &'static str {
match self {
Self::Grouped => "Grouped",
Self::Aligned => "Aligned",
Self::Unified => "Unified",
}
}
pub const fn uses_row_selection(self) -> bool {
matches!(self, Self::Aligned | Self::Unified)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollSyncMode {
#[default]
Independent,
Locked,
}
impl ScrollSyncMode {
pub const fn toggle(&mut self) {
*self = match self {
Self::Independent => Self::Locked,
Self::Locked => Self::Independent,
};
}
pub const fn name(self) -> &'static str {
match self {
Self::Independent => "Independent",
Self::Locked => "Locked",
}
}
}
#[derive(Debug, Clone)]
pub struct ChangeTypeFilter {
pub show_added: bool,
pub show_removed: bool,
pub show_modified: bool,
}
impl Default for ChangeTypeFilter {
fn default() -> Self {
Self {
show_added: true,
show_removed: true,
show_modified: true,
}
}
}
impl ChangeTypeFilter {
pub const fn toggle_added(&mut self) {
self.show_added = !self.show_added;
}
pub const fn toggle_removed(&mut self) {
self.show_removed = !self.show_removed;
}
pub const fn toggle_modified(&mut self) {
self.show_modified = !self.show_modified;
}
pub const fn show_all(&mut self) {
self.show_added = true;
self.show_removed = true;
self.show_modified = true;
}
pub const fn is_filtered(&self) -> bool {
!self.show_added || !self.show_removed || !self.show_modified
}
pub fn summary(&self) -> String {
if !self.is_filtered() {
return "All".to_string();
}
let mut parts = Vec::new();
if self.show_added {
parts.push("+");
}
if self.show_removed {
parts.push("-");
}
if self.show_modified {
parts.push("~");
}
parts.join("")
}
}
pub struct SideBySideState {
pub left_scroll: usize,
pub right_scroll: usize,
pub left_total: usize,
pub right_total: usize,
pub focus_right: bool,
pub alignment_mode: AlignmentMode,
pub sync_mode: ScrollSyncMode,
pub filter: ChangeTypeFilter,
pub selected_row: usize,
pub total_rows: usize,
pub change_indices: Vec<usize>,
pub current_change_idx: Option<usize>,
pub search_query: Option<String>,
pub search_matches: Vec<usize>,
pub current_match_idx: usize,
pub search_active: bool,
pub show_detail_modal: bool,
pub detail_component_left: Option<String>,
pub detail_component_right: Option<String>,
pub aligned_rows: Vec<AlignedRow>,
pub unified_entries: Vec<UnifiedEntry>,
pub viewport_rows: usize,
}
impl SideBySideState {
pub fn new() -> Self {
Self {
left_scroll: 0,
right_scroll: 0,
left_total: 0,
right_total: 0,
focus_right: false,
alignment_mode: AlignmentMode::default(),
sync_mode: ScrollSyncMode::default(),
filter: ChangeTypeFilter::default(),
selected_row: 0,
total_rows: 0,
change_indices: Vec::new(),
current_change_idx: None,
search_query: None,
search_matches: Vec::new(),
current_match_idx: 0,
search_active: false,
show_detail_modal: false,
detail_component_left: None,
detail_component_right: None,
aligned_rows: Vec::new(),
unified_entries: Vec::new(),
viewport_rows: 20,
}
}
pub const fn set_viewport_rows(&mut self, rows: usize) {
self.viewport_rows = if rows == 0 { 1 } else { rows };
}
fn clamp_scroll_to_selection(&mut self) {
if !self.alignment_mode.uses_row_selection() {
return;
}
let mut chrome = if self.alignment_mode == AlignmentMode::Unified {
2
} else {
0
};
if self.search_active {
chrome += 4;
}
let visible = self.viewport_rows.saturating_sub(chrome).max(1);
let min_scroll = self.selected_row.saturating_sub(visible - 1);
let max_scroll = self
.total_rows
.saturating_sub(visible)
.max(min_scroll)
.min(self.selected_row);
self.left_scroll = self.left_scroll.clamp(min_scroll, max_scroll);
self.right_scroll = self.left_scroll;
}
pub fn recompute_row_model(&mut self) {
let (total, change_indices) = match self.alignment_mode {
AlignmentMode::Aligned => {
let indices = self
.aligned_rows
.iter()
.enumerate()
.filter(|(_, row)| row.change_type != ChangeType::Unchanged)
.map(|(i, _)| i)
.collect();
(self.aligned_rows.len(), indices)
}
AlignmentMode::Unified => {
let total = self.unified_entries.len();
(total, (0..total).collect())
}
AlignmentMode::Grouped => (0, Vec::new()),
};
self.total_rows = total;
self.change_indices = change_indices;
if total == 0 {
self.selected_row = 0;
} else {
self.selected_row = self.selected_row.min(total - 1);
}
if self.change_indices.is_empty() {
self.current_change_idx = None;
} else if let Some(i) = self.current_change_idx
&& i >= self.change_indices.len()
{
self.current_change_idx = Some(self.change_indices.len() - 1);
}
if self.alignment_mode.uses_row_selection() {
self.left_total = total;
self.right_total = total;
}
self.clamp_scroll_to_selection();
}
pub fn scroll_up(&mut self) {
if self.alignment_mode.uses_row_selection() {
self.selected_row = self.selected_row.saturating_sub(1);
self.clamp_scroll_to_selection();
return;
}
match self.sync_mode {
ScrollSyncMode::Independent => {
if self.focus_right {
self.right_scroll = self.right_scroll.saturating_sub(1);
} else {
self.left_scroll = self.left_scroll.saturating_sub(1);
}
}
ScrollSyncMode::Locked => {
self.scroll_both_up();
}
}
}
pub fn scroll_down(&mut self) {
if self.alignment_mode.uses_row_selection() {
if self.total_rows > 0 {
self.selected_row = (self.selected_row + 1).min(self.total_rows.saturating_sub(1));
}
self.clamp_scroll_to_selection();
return;
}
match self.sync_mode {
ScrollSyncMode::Independent => {
if self.focus_right {
if self.right_total > 0
&& self.right_scroll < self.right_total.saturating_sub(1)
{
self.right_scroll += 1;
}
} else if self.left_total > 0
&& self.left_scroll < self.left_total.saturating_sub(1)
{
self.left_scroll += 1;
}
}
ScrollSyncMode::Locked => {
self.scroll_both_down();
}
}
}
pub fn page_up(&mut self) {
let page_size = crate::tui::constants::PAGE_SIZE;
if self.alignment_mode.uses_row_selection() {
self.selected_row = self.selected_row.saturating_sub(page_size);
self.clamp_scroll_to_selection();
return;
}
match self.sync_mode {
ScrollSyncMode::Independent => {
if self.focus_right {
self.right_scroll = self.right_scroll.saturating_sub(page_size);
} else {
self.left_scroll = self.left_scroll.saturating_sub(page_size);
}
}
ScrollSyncMode::Locked => {
self.left_scroll = self.left_scroll.saturating_sub(page_size);
self.right_scroll = self.right_scroll.saturating_sub(page_size);
}
}
}
pub fn page_down(&mut self) {
let page_size = crate::tui::constants::PAGE_SIZE;
if self.alignment_mode.uses_row_selection() {
if self.total_rows > 0 {
self.selected_row =
(self.selected_row + page_size).min(self.total_rows.saturating_sub(1));
}
self.clamp_scroll_to_selection();
return;
}
match self.sync_mode {
ScrollSyncMode::Independent => {
if self.focus_right {
self.right_scroll =
(self.right_scroll + page_size).min(self.right_total.saturating_sub(1));
} else {
self.left_scroll =
(self.left_scroll + page_size).min(self.left_total.saturating_sub(1));
}
}
ScrollSyncMode::Locked => {
self.left_scroll =
(self.left_scroll + page_size).min(self.left_total.saturating_sub(1));
self.right_scroll =
(self.right_scroll + page_size).min(self.right_total.saturating_sub(1));
}
}
}
pub const fn toggle_focus(&mut self) {
self.focus_right = !self.focus_right;
}
pub const fn toggle_alignment(&mut self) {
self.alignment_mode.toggle();
}
pub const fn toggle_sync(&mut self) {
self.sync_mode.toggle();
}
pub const fn scroll_both_up(&mut self) {
self.left_scroll = self.left_scroll.saturating_sub(1);
self.right_scroll = self.right_scroll.saturating_sub(1);
}
pub const fn scroll_both_down(&mut self) {
if self.left_total > 0 && self.left_scroll < self.left_total.saturating_sub(1) {
self.left_scroll += 1;
}
if self.right_total > 0 && self.right_scroll < self.right_total.saturating_sub(1) {
self.right_scroll += 1;
}
}
pub const fn set_totals(&mut self, left: usize, right: usize) {
self.left_total = left;
self.right_total = right;
}
pub fn go_to_top(&mut self) {
if self.alignment_mode.uses_row_selection() {
self.selected_row = 0;
self.clamp_scroll_to_selection();
return;
}
if self.focus_right {
self.right_scroll = 0;
} else {
self.left_scroll = 0;
}
}
pub fn go_to_bottom(&mut self) {
if self.alignment_mode.uses_row_selection() {
if self.total_rows > 0 {
self.selected_row = self.total_rows - 1;
}
self.clamp_scroll_to_selection();
return;
}
if self.focus_right {
self.right_scroll = self.right_total.saturating_sub(1);
} else {
self.left_scroll = self.left_total.saturating_sub(1);
}
}
pub fn next_change(&mut self) {
if self.change_indices.is_empty() {
return;
}
let next_idx = match self.current_change_idx {
Some(idx) => {
if idx + 1 < self.change_indices.len() {
idx + 1
} else {
0 }
}
None => 0,
};
self.current_change_idx = Some(next_idx);
self.scroll_to_row(self.change_indices[next_idx]);
}
pub fn prev_change(&mut self) {
if self.change_indices.is_empty() {
return;
}
let prev_idx = match self.current_change_idx {
Some(idx) => {
if idx > 0 {
idx - 1
} else {
self.change_indices.len() - 1 }
}
None => self.change_indices.len() - 1,
};
self.current_change_idx = Some(prev_idx);
self.scroll_to_row(self.change_indices[prev_idx]);
}
pub fn scroll_to_row(&mut self, row: usize) {
self.selected_row = row;
self.clamp_scroll_to_selection();
}
pub fn start_search(&mut self) {
self.search_active = true;
self.search_query = Some(String::new());
self.search_matches.clear();
self.current_match_idx = 0;
}
pub fn cancel_search(&mut self) {
self.search_active = false;
self.search_query = None;
self.search_matches.clear();
}
pub const fn confirm_search(&mut self) {
self.search_active = false;
}
pub fn search_push(&mut self, c: char) {
if let Some(ref mut query) = self.search_query {
query.push(c);
}
}
pub fn search_pop(&mut self) {
if let Some(ref mut query) = self.search_query {
query.pop();
}
}
pub fn update_search_matches(&mut self, matches: Vec<usize>) {
self.search_matches = matches;
self.current_match_idx = 0;
if !self.search_matches.is_empty() {
self.scroll_to_row(self.search_matches[0]);
}
}
pub fn next_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
self.current_match_idx = (self.current_match_idx + 1) % self.search_matches.len();
self.scroll_to_row(self.search_matches[self.current_match_idx]);
}
pub fn prev_match(&mut self) {
if self.search_matches.is_empty() {
return;
}
if self.current_match_idx > 0 {
self.current_match_idx -= 1;
} else {
self.current_match_idx = self.search_matches.len() - 1;
}
self.scroll_to_row(self.search_matches[self.current_match_idx]);
}
pub const fn toggle_detail_modal(&mut self) {
self.show_detail_modal = !self.show_detail_modal;
}
pub fn close_detail_modal(&mut self) {
self.show_detail_modal = false;
self.detail_component_left = None;
self.detail_component_right = None;
}
pub fn change_position(&self) -> String {
if self.change_indices.is_empty() {
return "0/0".to_string();
}
self.current_change_idx.map_or_else(
|| format!("-/{}", self.change_indices.len()),
|idx| format!("{}/{}", idx + 1, self.change_indices.len()),
)
}
pub fn match_position(&self) -> String {
if self.search_matches.is_empty() {
return "0/0".to_string();
}
format!(
"{}/{}",
self.current_match_idx + 1,
self.search_matches.len()
)
}
}
impl Default for SideBySideState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn aligned_row(change_type: ChangeType) -> AlignedRow {
AlignedRow {
left_name: Some("pkg".to_string()),
left_version: Some("1.0".to_string()),
right_name: Some("pkg".to_string()),
right_version: Some("2.0".to_string()),
change_type,
component_id: Some("pkg".to_string()),
}
}
fn unified_entry() -> UnifiedEntry {
UnifiedEntry {
name: "pkg".to_string(),
old_version: Some("1.0".to_string()),
new_version: Some("2.0".to_string()),
change_type: UnifiedChangeType::Upgrade,
}
}
#[test]
fn recompute_row_model_aligned_populates_totals() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Aligned;
s.aligned_rows = vec![
aligned_row(ChangeType::Removed),
aligned_row(ChangeType::Modified),
aligned_row(ChangeType::Added),
];
s.recompute_row_model();
assert_eq!(s.total_rows, 3);
assert_eq!(s.change_indices, vec![0, 1, 2]);
assert_eq!(s.left_total, 3);
assert_eq!(s.right_total, 3);
}
#[test]
fn recompute_row_model_unified_populates_totals() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Unified;
s.unified_entries = vec![
unified_entry(),
unified_entry(),
unified_entry(),
unified_entry(),
];
s.recompute_row_model();
assert_eq!(s.total_rows, 4);
assert_eq!(s.change_indices, vec![0, 1, 2, 3]);
assert_eq!(s.left_total, 4);
assert_eq!(s.right_total, 4);
}
#[test]
fn default_alignment_is_aligned() {
assert_eq!(
SideBySideState::new().alignment_mode,
AlignmentMode::Aligned
);
}
#[test]
fn recompute_row_model_grouped_preserves_grouped_totals() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Grouped;
s.set_totals(5, 7);
s.recompute_row_model();
assert_eq!(s.total_rows, 0);
assert!(s.change_indices.is_empty());
assert_eq!(s.left_total, 5);
assert_eq!(s.right_total, 7);
}
#[test]
fn scroll_down_advances_selected_in_aligned_after_recompute() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Aligned;
s.aligned_rows = vec![
aligned_row(ChangeType::Removed),
aligned_row(ChangeType::Modified),
aligned_row(ChangeType::Added),
];
s.recompute_row_model();
assert_eq!(s.selected_row, 0);
s.scroll_down();
s.scroll_down();
s.scroll_down();
assert_eq!(s.selected_row, 2);
}
#[test]
fn next_change_cycles_and_wraps() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Aligned;
s.aligned_rows = vec![
aligned_row(ChangeType::Removed),
aligned_row(ChangeType::Modified),
aligned_row(ChangeType::Added),
];
s.recompute_row_model();
s.next_change();
assert_eq!(s.current_change_idx, Some(0));
assert_eq!(s.selected_row, 0);
assert_eq!(s.change_position(), "1/3");
s.next_change();
assert_eq!(s.current_change_idx, Some(1));
s.next_change();
assert_eq!(s.current_change_idx, Some(2));
s.next_change();
assert_eq!(s.current_change_idx, Some(0));
}
#[test]
fn recompute_clamps_stale_current_change_idx_no_panic() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Aligned;
s.aligned_rows = (0..5).map(|_| aligned_row(ChangeType::Modified)).collect();
s.recompute_row_model();
s.current_change_idx = Some(4);
s.aligned_rows.truncate(2);
s.recompute_row_model();
assert_eq!(s.current_change_idx, Some(1));
s.prev_change();
assert_eq!(s.current_change_idx, Some(0));
}
#[test]
fn recompute_clamps_selected_row_when_rows_shrink() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Aligned;
s.aligned_rows = (0..5).map(|_| aligned_row(ChangeType::Modified)).collect();
s.recompute_row_model();
s.selected_row = 4;
s.aligned_rows.truncate(2);
s.recompute_row_model();
assert_eq!(s.selected_row, 1);
}
#[test]
fn next_prev_change_noop_when_grouped_or_empty() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Grouped;
s.recompute_row_model();
s.next_change();
assert_eq!(s.current_change_idx, None);
s.prev_change();
assert_eq!(s.current_change_idx, None);
assert_eq!(s.change_position(), "0/0");
}
}
#[cfg(test)]
mod viewport_clamp_tests {
use super::*;
use crate::diff::ChangeType;
fn state_with_rows(n: usize, mode: AlignmentMode) -> SideBySideState {
let mut s = SideBySideState::new();
s.alignment_mode = mode;
s.aligned_rows = (0..n)
.map(|_| AlignedRow {
left_name: Some("a".to_string()),
left_version: None,
right_name: Some("a".to_string()),
right_version: None,
change_type: ChangeType::Modified,
component_id: None,
})
.collect();
s.recompute_row_model();
s
}
#[test]
fn row_scroll_follows_selection_regardless_of_focus() {
let mut s = state_with_rows(30, AlignmentMode::Aligned);
s.set_viewport_rows(10);
s.focus_right = true;
for _ in 0..15 {
s.scroll_down();
}
assert_eq!(s.selected_row, 15);
assert!(
(s.left_scroll..s.left_scroll + 10).contains(&s.selected_row),
"selection {} must be inside the window starting at {}",
s.selected_row,
s.left_scroll
);
assert_eq!(s.right_scroll, s.left_scroll, "panels stay in lockstep");
}
#[test]
fn scroll_to_row_respects_viewport_rows() {
let mut s = state_with_rows(30, AlignmentMode::Aligned);
s.set_viewport_rows(13);
s.scroll_to_row(15);
assert!(
s.left_scroll >= 3,
"row 15 must be scrolled into a 13-row window, scroll={}",
s.left_scroll
);
let mut s = state_with_rows(30, AlignmentMode::Aligned);
s.set_viewport_rows(34);
s.scroll_to_row(15);
assert_eq!(s.left_scroll, 0, "no needless jump when already visible");
}
#[test]
fn recompute_row_model_clamps_scroll() {
let mut s = state_with_rows(30, AlignmentMode::Aligned);
s.set_viewport_rows(10);
s.selected_row = 25;
s.left_scroll = 0;
s.recompute_row_model();
assert!(
(s.left_scroll..s.left_scroll + 10).contains(&s.selected_row),
"selection must be inside the window after recompute"
);
assert_eq!(s.right_scroll, s.left_scroll);
}
#[test]
fn unified_viewport_accounts_for_chrome() {
let mut s = SideBySideState::new();
s.alignment_mode = AlignmentMode::Unified;
s.unified_entries = Vec::new();
s.set_viewport_rows(10);
s.selected_row = 20;
s.clamp_scroll_to_selection();
assert!(
s.left_scroll >= 13,
"Unified window must subtract its chrome, scroll={}",
s.left_scroll
);
}
}