use std::collections::{HashMap, HashSet};
use std::time::SystemTime;
use crate::command::diff::diff_algo::{compute_side_by_side, find_hunk_starts};
use crate::command::diff::highlight::FileHighlighter;
use crate::command::diff::search::SearchState;
use crate::command::diff::types::{
build_file_tree, CursorPosition, DiffFullscreen, DiffLine, DiffPanelFocus,
DiffViewSettings, FileDiff, FocusedPanel, Selection, SelectionMode, SidebarItem,
};
use crate::vcs::StackedCommitInfo;
#[derive(Default, Clone, Copy, PartialEq)]
pub enum PendingKey {
#[default]
None,
G,
}
fn sidebar_item_path(item: &SidebarItem) -> &str {
match item {
SidebarItem::Directory { path, .. } => path,
SidebarItem::File { path, .. } => path,
}
}
fn is_child_path(path: &str, parent: &str) -> bool {
if parent.is_empty() {
return false;
}
path.starts_with(&format!("{}/", parent))
}
fn build_sidebar_visible_indices(
items: &[SidebarItem],
collapsed_dirs: &HashSet<String>,
) -> Vec<usize> {
let mut visible = Vec::new();
let mut collapsed_stack: Vec<String> = Vec::new();
for (idx, item) in items.iter().enumerate() {
let path = sidebar_item_path(item);
while let Some(last) = collapsed_stack.last() {
if is_child_path(path, last) {
break;
}
collapsed_stack.pop();
}
if let Some(last) = collapsed_stack.last() {
if is_child_path(path, last) {
continue;
}
}
visible.push(idx);
if let SidebarItem::Directory { path, .. } = item {
if collapsed_dirs.contains(path) {
collapsed_stack.push(path.clone());
}
}
}
visible
}
#[derive(Clone)]
pub enum AnnotationTarget {
File,
LineRange {
panel: DiffPanelFocus,
start_line: usize,
end_line: usize,
},
}
#[derive(Clone)]
pub struct Annotation {
pub id: u64,
pub filename: String,
pub target: AnnotationTarget,
pub content: String,
pub created_at: SystemTime,
}
impl Annotation {
#[cfg(feature = "jj")]
pub fn format_time(&self) -> String {
use chrono::{DateTime, Local};
let datetime: DateTime<Local> = self.created_at.into();
datetime.format("%H:%M").to_string()
}
#[cfg(not(feature = "jj"))]
pub fn format_time(&self) -> String {
use std::time::UNIX_EPOCH;
let duration = self.created_at.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = duration.as_secs();
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
format!("{:02}:{:02}", hours, minutes)
}
pub fn target_label(&self) -> &'static str {
match &self.target {
AnnotationTarget::File => "file",
AnnotationTarget::LineRange { panel, .. } => match panel {
DiffPanelFocus::Old => "old",
DiffPanelFocus::New => "new",
DiffPanelFocus::None => "new",
},
}
}
pub fn line_range_display(&self) -> String {
match &self.target {
AnnotationTarget::File => String::new(),
AnnotationTarget::LineRange { start_line, end_line, .. } => {
if start_line == end_line {
format!("L{}", start_line)
} else {
format!("L{}-{}", start_line, end_line)
}
}
}
}
}
pub struct AppState {
pub file_diffs: Vec<FileDiff>,
pub sidebar_items: Vec<SidebarItem>,
pub sidebar_visible: Vec<usize>,
pub collapsed_dirs: HashSet<String>,
pub current_file: usize,
pub sidebar_selected: usize,
pub sidebar_scroll: usize,
pub sidebar_h_scroll: u16,
pub scroll: u16,
pub h_scroll: u16,
pub focused_panel: FocusedPanel,
pub viewed_files: HashSet<usize>,
pub show_sidebar: bool,
pub settings: DiffViewSettings,
pub diff_fullscreen: DiffFullscreen,
pub search_state: SearchState,
pub pending_key: PendingKey,
pub needs_reload: bool,
pub focused_hunk: Option<usize>,
pub annotations: Vec<Annotation>,
annotation_next_id: u64,
pub stacked_mode: bool,
pub stacked_commits: Vec<StackedCommitInfo>,
pub current_commit_index: usize,
stacked_viewed_files: HashMap<String, HashSet<String>>,
pub vcs_name: &'static str,
pub diff_reference: Option<String>,
pub diff_panel_focus: DiffPanelFocus,
pub selection: Selection,
pub is_dragging: bool,
pub show_selection_tooltip: bool,
cached_side_by_side: Option<(usize, Vec<DiffLine>)>,
cached_hunks: Option<(usize, Vec<usize>)>,
cached_total_lines: Option<(usize, usize)>,
cached_highlighters: Option<(usize, FileHighlighter, FileHighlighter)>,
search_dirty: bool,
pub content_row_offset: usize,
pub annotation_overlay_gaps: Vec<(usize, usize)>,
}
impl AppState {
pub fn new(file_diffs: Vec<FileDiff>, focus_file: Option<&str>) -> Self {
let sidebar_items = build_file_tree(&file_diffs);
let collapsed_dirs = HashSet::new();
let sidebar_visible = build_sidebar_visible_indices(&sidebar_items, &collapsed_dirs);
let (sidebar_selected, current_file) = if let Some(focus_path) = focus_file {
if let Some(file_idx) = file_diffs.iter().position(|f| f.filename == focus_path) {
let sidebar_idx = sidebar_visible
.iter()
.position(|&idx| {
matches!(sidebar_items[idx], SidebarItem::File { file_index, .. } if file_index == file_idx)
})
.unwrap_or(0);
(sidebar_idx, file_idx)
} else {
eprintln!(
"\x1b[93mwarning:\x1b[0m --focus file '{}' not found in diff, using first file",
focus_path
);
Self::find_first_file(&sidebar_items, &sidebar_visible)
}
} else {
Self::find_first_file(&sidebar_items, &sidebar_visible)
};
let settings = DiffViewSettings::default();
let (scroll, focused_hunk) = if !file_diffs.is_empty() && current_file < file_diffs.len() {
let diff = &file_diffs[current_file];
let side_by_side =
compute_side_by_side(&diff.old_content, &diff.new_content, settings.tab_width);
let hunks = find_hunk_starts(&side_by_side);
let scroll = hunks
.first()
.map(|&h| (h as u16).saturating_sub(5))
.unwrap_or(0);
let focused = if hunks.is_empty() { None } else { Some(0) };
(scroll, focused)
} else {
(0, None)
};
Self {
file_diffs,
sidebar_items,
sidebar_visible,
collapsed_dirs,
current_file,
sidebar_selected,
sidebar_scroll: 0,
sidebar_h_scroll: 0,
scroll,
h_scroll: 0,
focused_panel: FocusedPanel::default(),
viewed_files: HashSet::new(),
show_sidebar: true,
settings,
diff_fullscreen: DiffFullscreen::default(),
search_state: SearchState::default(),
pending_key: PendingKey::default(),
needs_reload: false,
focused_hunk,
annotations: Vec::new(),
annotation_next_id: 0,
stacked_mode: false,
stacked_commits: Vec::new(),
current_commit_index: 0,
stacked_viewed_files: HashMap::new(),
vcs_name: "git", diff_reference: None,
diff_panel_focus: DiffPanelFocus::default(),
selection: Selection::default(),
is_dragging: false,
show_selection_tooltip: false,
cached_side_by_side: None,
cached_hunks: None,
cached_total_lines: None,
cached_highlighters: None,
search_dirty: true,
content_row_offset: 0,
annotation_overlay_gaps: Vec::new(),
}
}
fn find_first_file(sidebar_items: &[SidebarItem], sidebar_visible: &[usize]) -> (usize, usize) {
for (visible_idx, &item_idx) in sidebar_visible.iter().enumerate() {
if let SidebarItem::File { file_index, .. } = &sidebar_items[item_idx] {
return (visible_idx, *file_index);
}
}
(0, 0)
}
pub fn get_side_by_side(&mut self) -> &[DiffLine] {
if self.file_diffs.is_empty() {
return &[];
}
let current = self.current_file;
let needs_recompute = match &self.cached_side_by_side {
Some((cached_file, _)) => *cached_file != current,
None => true,
};
if needs_recompute {
let diff = &self.file_diffs[current];
let side_by_side = compute_side_by_side(
&diff.old_content,
&diff.new_content,
self.settings.tab_width,
);
let hunks = find_hunk_starts(&side_by_side);
let total = side_by_side.len();
self.cached_side_by_side = Some((current, side_by_side));
self.cached_hunks = Some((current, hunks));
self.cached_total_lines = Some((current, total));
}
&self.cached_side_by_side.as_ref().unwrap().1
}
pub fn get_hunks(&mut self) -> &[usize] {
let _ = self.get_side_by_side();
&self.cached_hunks.as_ref().unwrap().1
}
pub fn ensure_cache(&mut self) {
if self.file_diffs.is_empty() {
return;
}
let current = self.current_file;
let needs_recompute = match &self.cached_side_by_side {
Some((cached_file, _)) => *cached_file != current,
None => true,
};
if needs_recompute {
let diff = &self.file_diffs[current];
let sbs = compute_side_by_side(
&diff.old_content,
&diff.new_content,
self.settings.tab_width,
);
let hnks = find_hunk_starts(&sbs);
let total = sbs.len();
self.cached_side_by_side = Some((current, sbs));
self.cached_hunks = Some((current, hnks));
self.cached_total_lines = Some((current, total));
}
}
pub fn side_by_side_ref(&self) -> &[DiffLine] {
match &self.cached_side_by_side {
Some((_, ref data)) => data,
None => &[],
}
}
pub fn hunks_ref(&self) -> &[usize] {
match &self.cached_hunks {
Some((_, ref data)) => data,
None => &[],
}
}
pub fn update_search_matches(&mut self) {
self.ensure_cache();
if self.search_dirty {
let sbs = match &self.cached_side_by_side {
Some((_, data)) => data.as_slice(),
None => &[],
};
self.search_state.update_matches(sbs, self.diff_fullscreen);
self.search_dirty = false;
}
}
pub fn mark_search_dirty(&mut self) {
self.search_dirty = true;
}
pub fn total_lines(&mut self) -> usize {
self.ensure_cache();
self.cached_total_lines
.as_ref()
.map(|(_, n)| *n)
.unwrap_or(0)
}
pub fn get_highlighters(&mut self) -> (&FileHighlighter, &FileHighlighter) {
let current = self.current_file;
let needs_recompute = match &self.cached_highlighters {
Some((cached_file, _, _)) => *cached_file != current,
None => true,
};
if needs_recompute {
let diff = &self.file_diffs[current];
let old_hl = FileHighlighter::new(&diff.old_content, &diff.filename);
let new_hl = FileHighlighter::new(&diff.new_content, &diff.filename);
self.cached_highlighters = Some((current, old_hl, new_hl));
}
let (_, old_hl, new_hl) = self.cached_highlighters.as_ref().unwrap();
(old_hl, new_hl)
}
pub fn highlighters_ref(&self) -> Option<(&FileHighlighter, &FileHighlighter)> {
self.cached_highlighters
.as_ref()
.map(|(_, old_hl, new_hl)| (old_hl, new_hl))
}
pub fn invalidate_cache(&mut self) {
self.cached_side_by_side = None;
self.cached_hunks = None;
self.cached_total_lines = None;
self.cached_highlighters = None;
self.search_dirty = true;
self.content_row_offset = 0;
self.annotation_overlay_gaps.clear();
}
pub fn adjust_for_overlay_gaps(&self, content_y: usize) -> Option<usize> {
let mut cumulative = 0;
for &(after_line, gap_height) in &self.annotation_overlay_gaps {
let gap_screen_start = after_line + 1 + cumulative;
let gap_screen_end = gap_screen_start + gap_height;
if content_y < gap_screen_start {
break; }
if content_y < gap_screen_end {
return None; }
cumulative += gap_height;
}
Some(content_y - cumulative)
}
pub fn adjust_for_overlay_gaps_clamped(&self, content_y: usize) -> usize {
let mut cumulative = 0;
for &(after_line, gap_height) in &self.annotation_overlay_gaps {
let gap_screen_start = after_line + 1 + cumulative;
let gap_screen_end = gap_screen_start + gap_height;
if content_y < gap_screen_start {
break;
}
if content_y < gap_screen_end {
return after_line; }
cumulative += gap_height;
}
content_y - cumulative
}
pub fn clear_selection(&mut self) {
self.diff_panel_focus = DiffPanelFocus::None;
self.selection = Selection::default();
self.is_dragging = false;
self.show_selection_tooltip = false;
}
pub fn start_selection(&mut self, panel: DiffPanelFocus, pos: CursorPosition, mode: SelectionMode) {
self.diff_panel_focus = panel;
self.selection = Selection {
panel,
anchor: pos,
head: pos,
mode,
};
self.is_dragging = true;
}
pub fn extend_selection(&mut self, pos: CursorPosition) {
if self.is_dragging {
self.selection.head = pos;
}
}
pub fn end_drag(&mut self) {
self.is_dragging = false;
if self.selection.is_active() && self.selection.anchor != self.selection.head {
self.show_selection_tooltip = true;
}
}
pub fn set_vcs_name(&mut self, name: &'static str) {
self.vcs_name = name;
}
pub fn sidebar_visible_len(&self) -> usize {
self.sidebar_visible.len()
}
pub fn sidebar_item_at_visible(&self, visible_index: usize) -> Option<&SidebarItem> {
self.sidebar_visible
.get(visible_index)
.and_then(|idx| self.sidebar_items.get(*idx))
}
pub fn sidebar_visible_index_for_file(&self, file_index: usize) -> Option<usize> {
self.sidebar_visible.iter().position(|idx| {
matches!(self.sidebar_items[*idx], SidebarItem::File { file_index: fi, .. } if fi == file_index)
})
}
pub fn sidebar_visible_index_for_dir(&self, dir_path: &str) -> Option<usize> {
self.sidebar_visible.iter().position(|idx| {
matches!(&self.sidebar_items[*idx], SidebarItem::Directory { path, .. } if path == dir_path)
})
}
pub fn rebuild_sidebar_visible(&mut self) {
let existing_dirs: HashSet<String> = self
.sidebar_items
.iter()
.filter_map(|item| match item {
SidebarItem::Directory { path, .. } => Some(path.clone()),
_ => None,
})
.collect();
self.collapsed_dirs
.retain(|path| existing_dirs.contains(path));
self.sidebar_visible =
build_sidebar_visible_indices(&self.sidebar_items, &self.collapsed_dirs);
if self.sidebar_visible.is_empty() {
self.sidebar_selected = 0;
self.sidebar_scroll = 0;
return;
}
if let Some(idx) = self.sidebar_visible_index_for_file(self.current_file) {
self.sidebar_selected = idx;
} else if self.sidebar_selected >= self.sidebar_visible.len() {
self.sidebar_selected = self.sidebar_visible.len() - 1;
}
if self.sidebar_scroll >= self.sidebar_visible.len() {
self.sidebar_scroll = self.sidebar_visible.len() - 1;
}
}
pub fn toggle_directory(&mut self, dir_path: &str) {
let selected_item = self.sidebar_item_at_visible(self.sidebar_selected).cloned();
let collapsing = !self.collapsed_dirs.contains(dir_path);
if collapsing {
self.collapsed_dirs.insert(dir_path.to_string());
} else {
self.collapsed_dirs.remove(dir_path);
}
self.rebuild_sidebar_visible();
if collapsing {
if let Some(item) = &selected_item {
let path = sidebar_item_path(item);
if is_child_path(path, dir_path) {
if let Some(idx) = self.sidebar_visible_index_for_dir(dir_path) {
self.sidebar_selected = idx;
return;
}
}
}
}
if let Some(item) = selected_item {
match item {
SidebarItem::Directory { path, .. } => {
if let Some(idx) = self.sidebar_visible_index_for_dir(&path) {
self.sidebar_selected = idx;
}
}
SidebarItem::File { file_index, .. } => {
if let Some(idx) = self.sidebar_visible_index_for_file(file_index) {
self.sidebar_selected = idx;
}
}
}
}
}
pub fn reveal_file(&mut self, file_index: usize) {
if file_index >= self.file_diffs.len() {
return;
}
let path = self.file_diffs[file_index].filename.clone();
let parts: Vec<&str> = path.split('/').collect();
if parts.len() > 1 {
for i in 0..parts.len() - 1 {
let dir_path = parts[..=i].join("/");
self.collapsed_dirs.remove(&dir_path);
}
}
self.rebuild_sidebar_visible();
if let Some(idx) = self.sidebar_visible_index_for_file(file_index) {
self.sidebar_selected = idx;
}
}
pub fn set_diff_reference(&mut self, reference: Option<String>) {
self.diff_reference = reference;
}
pub fn init_stacked_mode(&mut self, commits: Vec<StackedCommitInfo>) {
self.stacked_mode = true;
self.stacked_commits = commits;
self.current_commit_index = 0;
}
pub fn current_commit(&self) -> Option<&StackedCommitInfo> {
if self.stacked_mode {
self.stacked_commits.get(self.current_commit_index)
} else {
None
}
}
pub fn save_stacked_viewed_files(&mut self) {
if !self.stacked_mode {
return;
}
if let Some(commit) = self.stacked_commits.get(self.current_commit_index) {
let viewed_filenames: HashSet<String> = self
.viewed_files
.iter()
.filter_map(|&idx| self.file_diffs.get(idx).map(|f| f.filename.clone()))
.collect();
self.stacked_viewed_files
.insert(commit.commit_id.clone(), viewed_filenames);
}
}
pub fn load_stacked_viewed_files(&mut self) {
if !self.stacked_mode {
return;
}
if let Some(commit) = self.stacked_commits.get(self.current_commit_index) {
if let Some(viewed_filenames) = self.stacked_viewed_files.get(&commit.commit_id) {
self.viewed_files = self
.file_diffs
.iter()
.enumerate()
.filter(|(_, f)| viewed_filenames.contains(&f.filename))
.map(|(i, _)| i)
.collect();
} else {
self.viewed_files.clear();
}
}
}
pub fn reload(&mut self, file_diffs: Vec<FileDiff>, changed_files: Option<&HashSet<String>>) {
let old_filename = self
.file_diffs
.get(self.current_file)
.map(|f| f.filename.clone());
let old_scroll = self.scroll;
let old_h_scroll = self.h_scroll;
let mut viewed_filenames: HashSet<String> = self
.viewed_files
.iter()
.filter_map(|&idx| self.file_diffs.get(idx).map(|f| f.filename.clone()))
.collect();
if let Some(changed) = changed_files {
for filename in changed {
viewed_filenames.remove(filename);
}
}
self.file_diffs = file_diffs;
self.sidebar_items = build_file_tree(&self.file_diffs);
let filenames: HashSet<&str> = self.file_diffs.iter().map(|f| f.filename.as_str()).collect();
self.annotations.retain(|ann| filenames.contains(ann.filename.as_str()));
self.viewed_files = self
.file_diffs
.iter()
.enumerate()
.filter(|(_, f)| viewed_filenames.contains(&f.filename))
.map(|(i, _)| i)
.collect();
if let Some(name) = old_filename {
self.current_file = self
.file_diffs
.iter()
.position(|f| f.filename == name)
.unwrap_or(0);
}
if self.current_file >= self.file_diffs.len() && !self.file_diffs.is_empty() {
self.current_file = self.file_diffs.len() - 1;
}
self.rebuild_sidebar_visible();
self.needs_reload = false;
self.invalidate_cache();
if !self.file_diffs.is_empty() {
let total = self.total_lines();
let max_scroll = total.saturating_sub(10);
self.scroll = old_scroll.min(max_scroll as u16);
self.h_scroll = old_h_scroll;
}
}
pub fn select_file(&mut self, file_index: usize) {
self.current_file = file_index;
self.diff_fullscreen = DiffFullscreen::None;
self.clear_selection(); self.invalidate_cache();
let hunks = self.get_hunks().to_vec();
self.scroll = hunks
.first()
.map(|&h| (h as u16).saturating_sub(5))
.unwrap_or(0);
self.h_scroll = 0;
self.focused_hunk = if hunks.is_empty() { None } else { Some(0) };
}
pub fn get_annotation_by_id(&self, id: u64) -> Option<&Annotation> {
self.annotations.iter().find(|a| a.id == id)
}
#[allow(dead_code)]
pub fn get_annotations_for_file(&self, filename: &str) -> Vec<&Annotation> {
self.annotations
.iter()
.filter(|a| a.filename == filename)
.collect()
}
pub fn add_annotation(&mut self, filename: String, target: AnnotationTarget, content: String, created_at: SystemTime) -> u64 {
let id = self.annotation_next_id;
self.annotation_next_id += 1;
self.annotations.push(Annotation {
id,
filename,
target,
content,
created_at,
});
id
}
pub fn update_annotation(&mut self, id: u64, content: String) {
if let Some(ann) = self.annotations.iter_mut().find(|a| a.id == id) {
ann.content = content;
}
}
pub fn remove_annotation(&mut self, id: u64) {
self.annotations.retain(|a| a.id != id);
}
pub fn format_annotations_for_export(&self) -> String {
let mut result = String::new();
if let Some(ref reference) = self.diff_reference {
result.push_str(&format!("# {}\n\n", reference));
}
for (i, ann) in self.annotations.iter().enumerate() {
if i > 0 {
result.push_str("---\n\n");
}
match &ann.target {
AnnotationTarget::File => {
result.push_str(&format!("**{}**\n\n", ann.filename));
}
AnnotationTarget::LineRange { panel, start_line, end_line, .. } => {
let side = match panel {
DiffPanelFocus::Old => "LEFT",
_ => "RIGHT",
};
if start_line == end_line {
result.push_str(&format!(
"**{}** line {} ({})\n\n",
ann.filename, start_line, side,
));
} else {
result.push_str(&format!(
"**{}** lines {}-{} ({})\n\n",
ann.filename, start_line, end_line, side,
));
}
}
}
result.push_str(&ann.content);
result.push_str("\n\n");
}
result.trim_end().to_string()
}
}
pub fn adjust_scroll_to_line(
line: usize,
scroll: u16,
visible_height: usize,
max_scroll: usize,
) -> u16 {
let margin = 10usize;
let scroll_usize = scroll as usize;
let content_height = visible_height.saturating_sub(2);
let new_scroll = if line < scroll_usize + margin {
line.saturating_sub(margin) as u16
} else if line >= scroll_usize + content_height.saturating_sub(margin) {
(line.saturating_sub(content_height.saturating_sub(margin).saturating_sub(1))) as u16
} else {
scroll
};
new_scroll.min(max_scroll as u16)
}
pub fn adjust_scroll_for_hunk(
hunk_line: usize,
scroll: u16,
visible_height: usize,
max_scroll: usize,
) -> u16 {
let top_margin = 5usize;
let bottom_margin = 25usize;
let scroll_usize = scroll as usize;
let content_height = visible_height.saturating_sub(2);
if hunk_line < scroll_usize + top_margin {
return (hunk_line.saturating_sub(top_margin) as u16).min(max_scroll as u16);
}
if hunk_line >= scroll_usize + content_height.saturating_sub(bottom_margin) {
return (hunk_line.saturating_sub(
content_height
.saturating_sub(bottom_margin)
.saturating_sub(1),
) as u16)
.min(max_scroll as u16);
}
scroll
}
#[cfg(test)]
mod tests {
use super::*;
use crate::command::diff::types::FileStatus;
fn make_file_diff(filename: &str) -> FileDiff {
FileDiff {
filename: filename.to_string(),
old_content: String::new(),
new_content: "content\n".to_string(),
status: FileStatus::Added,
is_binary: false,
}
}
#[test]
fn test_focus_selects_matching_file() {
let diffs = vec![
make_file_diff("src/main.rs"),
make_file_diff("src/lib.rs"),
make_file_diff("README.md"),
];
let state = AppState::new(diffs, Some("src/lib.rs"));
assert_eq!(state.file_diffs[state.current_file].filename, "src/lib.rs");
}
#[test]
fn test_focus_none_selects_first_file_in_sidebar() {
let diffs = vec![make_file_diff("bbb.rs"), make_file_diff("aaa.rs")];
let state = AppState::new(diffs, None);
assert_eq!(state.file_diffs[state.current_file].filename, "aaa.rs");
}
#[test]
fn test_focus_not_found_falls_back_to_first_in_sidebar() {
let diffs = vec![make_file_diff("bbb.rs"), make_file_diff("aaa.rs")];
let state = AppState::new(diffs, Some("nonexistent.rs"));
assert_eq!(state.file_diffs[state.current_file].filename, "aaa.rs");
}
#[test]
fn test_focus_updates_sidebar_selection() {
let diffs = vec![
make_file_diff("aaa.rs"),
make_file_diff("bbb.rs"),
make_file_diff("ccc.rs"),
];
let state = AppState::new(diffs, Some("ccc.rs"));
if let Some(SidebarItem::File { file_index, .. }) = state.sidebar_item_at_visible(state.sidebar_selected) {
assert_eq!(*file_index, state.current_file);
} else {
panic!("sidebar_selected should point to a file");
}
}
#[test]
fn test_focus_empty_diffs() {
let diffs = vec![];
let state = AppState::new(diffs, Some("any.rs"));
assert_eq!(state.current_file, 0);
assert!(state.file_diffs.is_empty());
}
}