use crate::diff::{AlignedNode, DiffState, FileInfo};
use crate::ignore::IgnoreMatcher;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Clone, Debug, PartialEq)]
pub struct FlatRow {
pub depth: usize,
pub relative_path: PathBuf,
pub name: String,
pub state: DiffState,
pub left: Option<FileInfo>,
pub right: Option<FileInfo>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HelpTopic {
DirectoryTree,
FileDiff,
Config,
Mouse,
General,
About,
}
impl HelpTopic {
pub fn all() -> [HelpTopic; 6] {
use HelpTopic::*;
[DirectoryTree, FileDiff, Config, Mouse, General, About]
}
pub fn title(self) -> &'static str {
match self {
HelpTopic::DirectoryTree => "Directory Tree",
HelpTopic::FileDiff => "File Diff",
HelpTopic::Config => "Config",
HelpTopic::Mouse => "Mouse",
HelpTopic::General => "General",
HelpTopic::About => "About",
}
}
pub fn for_view(view: ViewMode) -> HelpTopic {
match view {
ViewMode::DirectoryTree => HelpTopic::DirectoryTree,
ViewMode::FileDiff => HelpTopic::FileDiff,
ViewMode::ConfigMenu => HelpTopic::Config,
ViewMode::Help => HelpTopic::General,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ViewMode {
DirectoryTree,
FileDiff,
ConfigMenu,
Help,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigRowKind {
Header(&'static str),
DiffTool(usize),
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfirmAction {
CopyLeftToRight,
CopyRightToLeft,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaletteMode {
Menu,
Command,
}
#[derive(Clone, Debug)]
pub struct PaletteAction {
pub key: String,
pub label: String,
pub action_id: &'static str,
pub enabled: bool,
}
#[derive(Clone, Debug, Default)]
pub struct PaletteState {
pub visible: bool,
pub mode: Option<PaletteMode>,
pub query: String,
pub items: Vec<PaletteAction>,
pub selected_idx: usize,
pub x: u16,
pub y: u16,
}
pub struct App {
pub left_path: PathBuf,
pub right_path: PathBuf,
pub precise_mode: bool,
pub root_node: Option<AlignedNode>,
pub scan_in_progress: bool,
pub progress_count: usize,
pub progress_path: String,
pub flat_rows: Vec<FlatRow>,
pub selected_idx: usize,
pub scroll_offset: usize,
pub active_side_left: bool,
pub view_mode: ViewMode,
pub diff_rows: Vec<crate::diff_view::DiffRow>,
pub diff_scroll: usize,
pub visible_height: usize,
pub diff_show_full: bool,
pub diff_wrap: bool,
pub diff_h_scroll: usize,
pub diff_physical_rows: usize,
pub diff_content_width: usize,
pub diff_max_line_width: usize,
pub diff_left_hash: Option<String>,
pub diff_right_hash: Option<String>,
pub diff_left_line_ending: Option<String>,
pub diff_right_line_ending: Option<String>,
pub last_click_idx: Option<usize>,
pub last_click_time: Option<std::time::Instant>,
pub settings: crate::settings::AppSettings,
pub detected_diff_tools: Vec<(crate::diff_tool::ExternalDiffTool, bool)>,
pub config_selected_idx: usize,
pub palette: PaletteState,
pub show_confirm_modal: bool,
pub confirm_modal_message: String,
pub confirm_modal_action: Option<ConfirmAction>,
pub status_message: Option<(String, bool, Instant)>,
pub filter_active: bool,
pub filter_input: String,
pub filter_pattern: String,
pub filter_diffs_only: bool,
pub filtered_rows: Vec<FlatRow>,
pub ignore_matcher: IgnoreMatcher,
pub update_check_enabled: bool,
pub update_available: Option<String>,
pub install_method: crate::upgrade::InstallMethod,
pub help_topic: HelpTopic,
pub help_return_view: ViewMode,
pub help_index_open: bool,
pub help_index_sel: usize,
pub help_scroll: u16,
pub should_quit: bool,
}
impl App {
pub fn new(left: PathBuf, right: PathBuf) -> Self {
Self::new_with_ignore(left, right, IgnoreMatcher::default())
}
pub fn new_with_ignore(left: PathBuf, right: PathBuf, ignore_matcher: IgnoreMatcher) -> Self {
let mut settings = crate::settings::AppSettings::load();
let detected_diff_tools = crate::diff_tool::detect_diff_tools();
if settings.external_diff_tool.is_none() {
if let Some((tool, _)) = detected_diff_tools.iter().find(|(_, avail)| *avail) {
settings.external_diff_tool = Some(tool.as_str().to_string());
let _ = settings.save();
}
}
let install_method = if let Ok(exe_path) = std::env::current_exe() {
crate::upgrade::detect_install_method(&exe_path)
} else {
crate::upgrade::InstallMethod::Standalone
};
Self {
left_path: left,
right_path: right,
precise_mode: false,
root_node: None,
scan_in_progress: false,
progress_count: 0,
progress_path: String::new(),
flat_rows: Vec::new(),
selected_idx: 0,
scroll_offset: 0,
active_side_left: true,
view_mode: ViewMode::DirectoryTree,
diff_rows: Vec::new(),
diff_scroll: 0,
visible_height: 0,
diff_show_full: false,
diff_wrap: false,
diff_h_scroll: 0,
diff_physical_rows: 0,
diff_content_width: 0,
diff_max_line_width: 0,
diff_left_hash: None,
diff_right_hash: None,
diff_left_line_ending: None,
diff_right_line_ending: None,
last_click_idx: None,
last_click_time: None,
settings,
detected_diff_tools,
config_selected_idx: 0,
palette: PaletteState::default(),
show_confirm_modal: false,
confirm_modal_message: String::new(),
confirm_modal_action: None,
status_message: None,
filter_active: false,
filter_input: String::new(),
filter_pattern: String::new(),
filter_diffs_only: false,
filtered_rows: Vec::new(),
ignore_matcher,
update_check_enabled: true,
update_available: None,
install_method,
help_topic: HelpTopic::General,
help_return_view: ViewMode::DirectoryTree,
help_index_open: false,
help_index_sel: 0,
help_scroll: 0,
should_quit: false,
}
}
pub fn set_status(&mut self, msg: impl Into<String>, is_error: bool) {
self.status_message = Some((msg.into(), is_error, Instant::now()));
}
pub fn config_rows(&self) -> Vec<ConfigRowKind> {
let mut rows = vec![ConfigRowKind::Header("External Diff Tool")];
rows.extend(
self.detected_diff_tools
.iter()
.enumerate()
.map(|(i, _)| ConfigRowKind::DiffTool(i)),
);
rows
}
pub fn open_config(&mut self) {
self.view_mode = ViewMode::ConfigMenu;
self.ensure_config_selection();
}
pub fn ensure_config_selection(&mut self) {
let rows = self.config_rows();
if rows.is_empty() {
self.config_selected_idx = 0;
return;
}
if self.config_selected_idx >= rows.len()
|| matches!(rows[self.config_selected_idx], ConfigRowKind::Header(_))
{
self.config_selected_idx = rows
.iter()
.position(|r| matches!(r, ConfigRowKind::DiffTool(_)))
.unwrap_or(0);
}
}
pub fn config_select_next(&mut self) {
let rows = self.config_rows();
if rows.is_empty() {
return;
}
let mut next = self.config_selected_idx;
for _ in 0..rows.len() {
next = (next + 1) % rows.len();
if matches!(rows[next], ConfigRowKind::DiffTool(_)) {
self.config_selected_idx = next;
return;
}
}
}
pub fn config_select_prev(&mut self) {
let rows = self.config_rows();
if rows.is_empty() {
return;
}
let mut prev = self.config_selected_idx;
for _ in 0..rows.len() {
prev = prev.checked_sub(1).unwrap_or(rows.len() - 1);
if matches!(rows[prev], ConfigRowKind::DiffTool(_)) {
self.config_selected_idx = prev;
return;
}
}
}
pub fn apply_config_selection(&mut self) {
let rows = self.config_rows();
if let Some(ConfigRowKind::DiffTool(idx)) = rows.get(self.config_selected_idx) {
if let Some((tool, _)) = self.detected_diff_tools.get(*idx) {
self.settings.external_diff_tool = Some(tool.as_str().to_string());
let _ = self.settings.save();
}
}
}
pub fn focus_left_pane(&mut self) {
self.active_side_left = true;
}
pub fn focus_right_pane(&mut self) {
self.active_side_left = false;
}
pub fn swap_paths(&mut self) {
std::mem::swap(&mut self.left_path, &mut self.right_path);
self.selected_idx = 0;
self.scroll_offset = 0;
self.diff_scroll = 0;
self.diff_left_hash = None;
self.diff_right_hash = None;
}
pub fn clear_expired_status(&mut self, duration: std::time::Duration) {
if let Some((_, _, created)) = &self.status_message {
if created.elapsed() >= duration {
self.status_message = None;
}
}
}
pub fn jump_to_next_change(&mut self) {
let width = self.diff_content_width.max(1);
if let Some(scroll) = crate::diff_view::jump_to_change_scroll(
&self.diff_rows,
self.diff_scroll,
width,
self.diff_wrap,
true,
) {
self.diff_scroll = scroll;
}
}
pub fn jump_to_prev_change(&mut self) {
let width = self.diff_content_width.max(1);
if let Some(scroll) = crate::diff_view::jump_to_change_scroll(
&self.diff_rows,
self.diff_scroll,
width,
self.diff_wrap,
false,
) {
self.diff_scroll = scroll;
}
}
pub fn refresh_file_diff(&mut self) {
if self.selected_idx >= self.filtered_rows.len() {
return;
}
let row = &self.filtered_rows[self.selected_idx];
let left_file = self.left_path.join(&row.relative_path);
let right_file = self.right_path.join(&row.relative_path);
self.diff_rows =
crate::diff_view::compare_files(&left_file, &right_file, self.diff_show_full)
.unwrap_or_default();
self.diff_left_hash = crate::diff::compute_file_md5(&left_file).ok();
self.diff_right_hash = crate::diff::compute_file_md5(&right_file).ok();
self.diff_left_line_ending = crate::diff_view::detect_file_line_ending(&left_file);
self.diff_right_line_ending = crate::diff_view::detect_file_line_ending(&right_file);
}
pub fn copy_hunk_at_cursor(
&mut self,
direction: crate::diff_view::HunkCopyDirection,
) -> Result<(), std::io::Error> {
if self.selected_idx >= self.filtered_rows.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no file selected",
));
}
let width = self.diff_content_width.max(1);
let hunk_index = crate::diff_view::hunk_index_at_scroll(
&self.diff_rows,
self.diff_scroll,
width,
self.diff_wrap,
)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no change block at cursor",
)
})?;
let row = &self.filtered_rows[self.selected_idx];
let left_file = self.left_path.join(&row.relative_path);
let right_file = self.right_path.join(&row.relative_path);
let prev_scroll = self.diff_scroll;
crate::diff_view::apply_hunk_copy(
&left_file,
&right_file,
&self.diff_rows,
hunk_index,
direction,
)?;
self.refresh_file_diff();
let max_scroll = self.diff_physical_rows.saturating_sub(self.visible_height);
self.diff_scroll = prev_scroll.min(max_scroll);
Ok(())
}
pub fn flatten_tree(&mut self) {
self.flat_rows.clear();
if let Some(root) = self.root_node.take() {
self.flatten_node(&root, 0);
self.root_node = Some(root);
}
if self.selected_idx >= self.flat_rows.len() && !self.flat_rows.is_empty() {
self.selected_idx = self.flat_rows.len() - 1;
}
self.apply_filter();
}
fn flatten_node(&mut self, node: &AlignedNode, depth: usize) {
self.flat_rows.push(FlatRow {
depth,
relative_path: node.relative_path.clone(),
name: node.name.clone(),
state: node.state,
left: node.left.clone(),
right: node.right.clone(),
});
if node.is_expanded {
for child in &node.children {
self.flatten_node(child, depth + 1);
}
}
}
pub fn apply_filter(&mut self) {
let pattern = self.filter_pattern.to_lowercase();
let diffs_only = self.filter_diffs_only;
if pattern.is_empty() && !diffs_only {
self.filtered_rows = self.flat_rows.clone();
} else {
self.filtered_rows = self
.flat_rows
.iter()
.filter(|row| {
if diffs_only && row.state == DiffState::Identical {
return false;
}
if pattern.is_empty() {
return true;
}
row.name.to_lowercase().contains(&pattern)
|| row
.relative_path
.to_string_lossy()
.to_lowercase()
.contains(&pattern)
})
.cloned()
.collect();
}
self.selected_idx = 0;
self.scroll_offset = 0;
}
pub fn open_help(&mut self) {
self.help_return_view = self.view_mode;
self.help_topic = HelpTopic::for_view(self.view_mode);
self.help_index_sel = HelpTopic::all()
.iter()
.position(|&t| t == self.help_topic)
.unwrap_or(0);
self.help_index_open = true;
self.help_scroll = 0;
self.view_mode = ViewMode::Help;
}
pub fn open_filter(&mut self) {
self.filter_active = true;
self.filter_input = self.filter_pattern.clone();
}
pub fn commit_filter(&mut self) {
self.filter_active = false;
self.filter_pattern = self.filter_input.clone();
self.apply_filter();
}
pub fn cancel_filter(&mut self) {
self.filter_active = false;
self.filter_input = self.filter_pattern.clone();
}
pub fn clear_filter(&mut self) {
self.filter_pattern.clear();
self.filter_input.clear();
self.filter_diffs_only = false;
self.apply_filter();
}
pub fn toggle_expand(&mut self) {
if self.filtered_rows.is_empty() || self.selected_idx >= self.filtered_rows.len() {
return;
}
let row = &self.filtered_rows[self.selected_idx];
let is_dir = row.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| row.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
if !is_dir {
return;
}
let rel_path = row.relative_path.clone();
if let Some(ref mut root) = self.root_node {
Self::toggle_expand_node(root, &rel_path);
}
self.flatten_tree();
}
fn toggle_expand_node(node: &mut AlignedNode, target_path: &std::path::Path) {
if node.relative_path == target_path {
node.is_expanded = !node.is_expanded;
return;
}
for child in &mut node.children {
Self::toggle_expand_node(child, target_path);
}
}
pub fn select_next(&mut self) {
if !self.filtered_rows.is_empty() && self.selected_idx < self.filtered_rows.len() - 1 {
self.selected_idx += 1;
}
}
pub fn select_prev(&mut self) {
if self.selected_idx > 0 {
self.selected_idx -= 1;
}
}
pub fn expand_selected(&mut self) {
if self.filtered_rows.is_empty() || self.selected_idx >= self.filtered_rows.len() {
return;
}
let row = &self.filtered_rows[self.selected_idx];
let is_dir = row.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| row.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
if !is_dir {
return;
}
let rel_path = row.relative_path.clone();
if let Some(ref mut root) = self.root_node {
Self::set_expand_node(root, &rel_path, true);
}
self.flatten_tree();
}
pub fn collapse_selected(&mut self) {
if self.filtered_rows.is_empty() || self.selected_idx >= self.filtered_rows.len() {
return;
}
let row = &self.filtered_rows[self.selected_idx];
let is_dir = row.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| row.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
if !is_dir {
return;
}
let rel_path = row.relative_path.clone();
if let Some(ref mut root) = self.root_node {
Self::set_expand_node(root, &rel_path, false);
}
self.flatten_tree();
}
fn set_expand_node(node: &mut AlignedNode, target_path: &std::path::Path, expand: bool) {
if node.relative_path == target_path {
node.is_expanded = expand;
return;
}
for child in &mut node.children {
Self::set_expand_node(child, target_path, expand);
}
}
pub fn adjust_scroll(&mut self, visible_height: usize) {
if visible_height == 0 {
return;
}
if self.selected_idx < self.scroll_offset {
self.scroll_offset = self.selected_idx;
} else if self.selected_idx >= self.scroll_offset + visible_height {
self.scroll_offset = self.selected_idx - visible_height + 1;
}
}
pub fn build_palette_actions(&self) -> Vec<PaletteAction> {
let mut actions = Vec::new();
match self.view_mode {
ViewMode::DirectoryTree => {
let row = self.filtered_rows.get(self.selected_idx);
let is_file_pair = row.is_some_and(|r| {
let is_dir = r.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| r.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
!is_dir && r.left.is_some() && r.right.is_some()
});
let is_file_active = row.is_some_and(|r| {
if self.active_side_left {
r.left.as_ref().map(|f| !f.is_dir).unwrap_or(false)
} else {
r.right.as_ref().map(|f| !f.is_dir).unwrap_or(false)
}
});
actions.push(PaletteAction {
key: "D".to_string(),
label: "Compare via External Diff Tool".to_string(),
action_id: "ext_diff",
enabled: is_file_pair && self.settings.external_diff_tool.is_some(),
});
actions.push(PaletteAction {
key: "E".to_string(),
label: "Edit via External Editor".to_string(),
action_id: "ext_edit",
enabled: is_file_active,
});
actions.push(PaletteAction {
key: "R".to_string(),
label: "Copy Left to Right".to_string(),
action_id: "copy_l2r",
enabled: row.is_some_and(|r| r.left.is_some()),
});
actions.push(PaletteAction {
key: "L".to_string(),
label: "Copy Right to Left".to_string(),
action_id: "copy_r2l",
enabled: row.is_some_and(|r| r.right.is_some()),
});
actions.push(PaletteAction {
key: "Enter".to_string(),
label: "Open built-in Diff view".to_string(),
action_id: "builtin_diff",
enabled: row.is_some_and(|r| {
let is_dir = r.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| r.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
!is_dir
}),
});
actions.push(PaletteAction {
key: "s".to_string(),
label: "Swap Left/Right Paths".to_string(),
action_id: "swap_paths",
enabled: true,
});
actions.push(PaletteAction {
key: "c".to_string(),
label: "Toggle Scan Mode (Fast/Precise)".to_string(),
action_id: "toggle_scan",
enabled: true,
});
actions.push(PaletteAction {
key: "r".to_string(),
label: "Manual Re-scan / Refresh".to_string(),
action_id: "refresh",
enabled: true,
});
actions.push(PaletteAction {
key: "C".to_string(),
label: "Edit Configuration".to_string(),
action_id: "config",
enabled: true,
});
actions.push(PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: "help",
enabled: true,
});
actions.push(PaletteAction {
key: "/".to_string(),
label: "Open Filter Input".to_string(),
action_id: "filter",
enabled: true,
});
actions.push(PaletteAction {
key: "q".to_string(),
label: "Quit duodiff".to_string(),
action_id: "quit",
enabled: true,
});
}
ViewMode::FileDiff => {
actions.push(PaletteAction {
key: "w".to_string(),
label: "Toggle Wrap Mode".to_string(),
action_id: "toggle_wrap",
enabled: true,
});
actions.push(PaletteAction {
key: "f".to_string(),
label: "Toggle Full Content".to_string(),
action_id: "toggle_full",
enabled: true,
});
actions.push(PaletteAction {
key: "N".to_string(),
label: "Next Change".to_string(),
action_id: "next_change",
enabled: self
.diff_rows
.iter()
.any(crate::diff_view::diff_row_is_change),
});
actions.push(PaletteAction {
key: "P".to_string(),
label: "Previous Change".to_string(),
action_id: "prev_change",
enabled: self
.diff_rows
.iter()
.any(crate::diff_view::diff_row_is_change),
});
actions.push(PaletteAction {
key: "]".to_string(),
label: "Copy Change Block to Right".to_string(),
action_id: "copy_hunk_l2r",
enabled: self
.diff_rows
.iter()
.any(crate::diff_view::diff_row_is_change),
});
actions.push(PaletteAction {
key: "[".to_string(),
label: "Copy Change Block to Left".to_string(),
action_id: "copy_hunk_r2l",
enabled: self
.diff_rows
.iter()
.any(crate::diff_view::diff_row_is_change),
});
actions.push(PaletteAction {
key: "R".to_string(),
label: "Copy Whole File Left to Right".to_string(),
action_id: "copy_l2r",
enabled: self.selected_idx < self.filtered_rows.len()
&& self.filtered_rows[self.selected_idx].left.is_some(),
});
actions.push(PaletteAction {
key: "L".to_string(),
label: "Copy Whole File Right to Left".to_string(),
action_id: "copy_r2l",
enabled: self.selected_idx < self.filtered_rows.len()
&& self.filtered_rows[self.selected_idx].right.is_some(),
});
actions.push(PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: "help",
enabled: true,
});
actions.push(PaletteAction {
key: "Esc".to_string(),
label: "Return to Tree View".to_string(),
action_id: "back",
enabled: true,
});
}
_ => {
actions.push(PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: "help",
enabled: true,
});
actions.push(PaletteAction {
key: "Esc".to_string(),
label: "Go Back".to_string(),
action_id: "back",
enabled: true,
});
}
}
actions
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::{DiffState, FileInfo};
use std::time::SystemTime;
#[test]
fn test_flatten_tree() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let node = AlignedNode {
name: "root".to_string(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "child".to_string(),
relative_path: PathBuf::from("child"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: true,
};
app.root_node = Some(node);
app.flatten_tree();
assert_eq!(app.flat_rows.len(), 2, "Expected 2 flattened rows");
assert_eq!(app.flat_rows[0].name, "root");
assert_eq!(app.flat_rows[1].name, "child");
assert_eq!(app.flat_rows[1].depth, 1, "Child depth should be 1");
}
#[test]
fn test_select_next_prev() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.flat_rows = vec![
FlatRow {
depth: 0,
relative_path: PathBuf::from(""),
name: "root".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
FlatRow {
depth: 1,
relative_path: PathBuf::from("child"),
name: "child".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
];
app.apply_filter();
assert_eq!(app.selected_idx, 0);
app.select_next();
assert_eq!(app.selected_idx, 1);
app.select_next();
assert_eq!(app.selected_idx, 1); app.select_prev();
assert_eq!(app.selected_idx, 0);
app.select_prev();
assert_eq!(app.selected_idx, 0); }
#[test]
fn test_toggle_expand() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let node = AlignedNode {
name: "root".to_string(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "child".to_string(),
relative_path: PathBuf::from("child"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: true,
};
app.root_node = Some(node);
app.flatten_tree();
assert_eq!(app.flat_rows.len(), 2);
app.selected_idx = 0;
app.toggle_expand();
assert_eq!(app.flat_rows.len(), 1);
assert_eq!(app.flat_rows[0].name, "root");
app.toggle_expand();
assert_eq!(app.flat_rows.len(), 2);
}
#[test]
fn test_expand_collapse_selected() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let node = AlignedNode {
name: "root".to_string(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "child".to_string(),
relative_path: PathBuf::from("child"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: true,
};
app.root_node = Some(node);
app.flatten_tree();
assert_eq!(app.flat_rows.len(), 2);
app.selected_idx = 0;
app.collapse_selected();
assert_eq!(app.flat_rows.len(), 1);
app.expand_selected();
assert_eq!(app.flat_rows.len(), 2);
}
#[test]
fn test_adjust_scroll() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.scroll_offset = 2;
app.selected_idx = 5;
app.adjust_scroll(0);
assert_eq!(app.scroll_offset, 2);
app.selected_idx = 1;
app.adjust_scroll(5);
assert_eq!(app.scroll_offset, 1);
app.selected_idx = 7;
app.adjust_scroll(5);
assert_eq!(app.scroll_offset, 3);
app.selected_idx = 5;
app.adjust_scroll(5);
assert_eq!(app.scroll_offset, 3);
}
#[test]
fn test_status_message_lifecycle() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert!(app.status_message.is_none());
app.set_status("Copy failed: permission denied", true);
assert!(app.status_message.is_some());
let (msg, is_error, _) = app.status_message.as_ref().unwrap();
assert!(is_error);
assert!(msg.contains("permission denied"));
app.clear_expired_status(std::time::Duration::from_secs(10));
assert!(app.status_message.is_some());
app.clear_expired_status(std::time::Duration::ZERO);
assert!(app.status_message.is_none());
app.set_status("Copied 'file.txt'", false);
let (_, is_error, _) = app.status_message.as_ref().unwrap();
assert!(!is_error);
}
#[test]
fn test_swap_paths() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.left_path, PathBuf::from("/left"));
assert_eq!(app.right_path, PathBuf::from("/right"));
app.swap_paths();
assert_eq!(app.left_path, PathBuf::from("/right"));
assert_eq!(app.right_path, PathBuf::from("/left"));
}
#[test]
fn test_swap_paths_resets_state() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.selected_idx = 5;
app.scroll_offset = 3;
app.diff_scroll = 2;
app.diff_left_hash = Some("abc".to_string());
app.diff_right_hash = Some("def".to_string());
app.swap_paths();
assert_eq!(app.selected_idx, 0);
assert_eq!(app.scroll_offset, 0);
assert_eq!(app.diff_scroll, 0);
assert!(app.diff_left_hash.is_none());
assert!(app.diff_right_hash.is_none());
}
#[test]
fn test_swap_paths_twice_restores() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.swap_paths();
app.swap_paths();
assert_eq!(app.left_path, PathBuf::from("/left"));
assert_eq!(app.right_path, PathBuf::from("/right"));
}
#[test]
fn test_filter_by_pattern() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = vec![
FlatRow {
depth: 0,
relative_path: PathBuf::from("alpha.txt"),
name: "alpha.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("beta.txt"),
name: "beta.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("gamma.txt"),
name: "gamma.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
];
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 3);
app.filter_pattern = "alpha".to_string();
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 1);
assert_eq!(app.filtered_rows[0].name, "alpha.txt");
app.filter_pattern.clear();
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 3);
}
#[test]
fn test_filter_diffs_only() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = vec![
FlatRow {
depth: 0,
relative_path: PathBuf::from("same.txt"),
name: "same.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff.txt"),
name: "diff.txt".to_string(),
state: DiffState::DifferentNewerLeft,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("only.txt"),
name: "only.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
},
];
app.filter_diffs_only = true;
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 2);
assert!(app
.filtered_rows
.iter()
.all(|r| r.state != DiffState::Identical));
}
#[test]
fn test_filter_pattern_and_diffs_only_combined() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = vec![
FlatRow {
depth: 0,
relative_path: PathBuf::from("same.txt"),
name: "same.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff_a.txt"),
name: "diff_a.txt".to_string(),
state: DiffState::DifferentNewerLeft,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff_b.txt"),
name: "diff_b.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
},
];
app.filter_pattern = "a".to_string();
app.filter_diffs_only = true;
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 1);
assert_eq!(app.filtered_rows[0].name, "diff_a.txt");
}
#[test]
fn test_filter_case_insensitive() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = vec![FlatRow {
depth: 0,
relative_path: PathBuf::from("README.md"),
name: "README.md".to_string(),
state: DiffState::Identical,
left: None,
right: None,
}];
app.filter_pattern = "readme".to_string();
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 1);
}
#[test]
fn test_open_commit_cancel_filter() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.filter_pattern = "abc".to_string();
app.open_filter();
assert!(app.filter_active);
assert_eq!(app.filter_input, "abc");
app.filter_input.push_str("def");
assert_eq!(app.filter_input, "abcdef");
app.cancel_filter();
assert!(!app.filter_active);
assert_eq!(app.filter_input, "abc");
assert_eq!(app.filter_pattern, "abc");
app.open_filter();
app.filter_input = "xyz".to_string();
app.commit_filter();
assert!(!app.filter_active);
assert_eq!(app.filter_pattern, "xyz");
}
#[test]
fn test_clear_filter() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = vec![
FlatRow {
depth: 0,
relative_path: PathBuf::from("a.txt"),
name: "a.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("b.txt"),
name: "b.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
},
];
app.filter_pattern = "a".to_string();
app.filter_diffs_only = true;
app.apply_filter();
assert_eq!(app.filtered_rows.len(), 0);
app.clear_filter();
assert!(app.filter_pattern.is_empty());
assert!(!app.filter_diffs_only);
assert_eq!(app.filtered_rows.len(), 2);
}
#[test]
fn test_help_topic_all_returns_six_topics_in_order() {
use HelpTopic::*;
assert_eq!(
HelpTopic::all(),
[DirectoryTree, FileDiff, Config, Mouse, General, About]
);
}
#[test]
fn test_help_topic_titles_are_distinct_non_empty() {
let titles: Vec<&str> = HelpTopic::all().iter().map(|t| t.title()).collect();
for title in &titles {
assert!(!title.is_empty());
}
let mut unique = titles.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), titles.len(), "topic titles must be distinct");
}
#[test]
fn test_help_topic_for_view_maps_each_view_correctly() {
assert_eq!(
HelpTopic::for_view(ViewMode::DirectoryTree),
HelpTopic::DirectoryTree
);
assert_eq!(HelpTopic::for_view(ViewMode::FileDiff), HelpTopic::FileDiff);
assert_eq!(HelpTopic::for_view(ViewMode::ConfigMenu), HelpTopic::Config);
}
#[test]
fn test_app_help_fields_have_expected_defaults() {
let app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert_eq!(app.help_topic, HelpTopic::General);
assert_eq!(app.help_return_view, ViewMode::DirectoryTree);
assert!(!app.help_index_open);
assert_eq!(app.help_index_sel, 0);
assert_eq!(app.help_scroll, 0);
}
#[test]
fn test_open_help_sets_contextual_topic_and_return_view() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.view_mode = ViewMode::FileDiff;
app.help_index_open = false; app.help_scroll = 7;
app.open_help();
assert_eq!(app.help_return_view, ViewMode::FileDiff);
assert_eq!(app.help_topic, HelpTopic::FileDiff);
assert!(app.help_index_open);
assert_eq!(app.help_scroll, 0);
assert_eq!(app.view_mode, ViewMode::Help);
}
#[test]
fn test_config_rows_and_navigation() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.detected_diff_tools = vec![
(crate::diff_tool::ExternalDiffTool::Vim, true),
(crate::diff_tool::ExternalDiffTool::Code, false),
];
let rows = app.config_rows();
assert_eq!(rows.len(), 3);
assert!(matches!(
rows[0],
ConfigRowKind::Header("External Diff Tool")
));
assert!(matches!(rows[1], ConfigRowKind::DiffTool(0)));
assert!(matches!(rows[2], ConfigRowKind::DiffTool(1)));
app.config_selected_idx = 0;
app.ensure_config_selection();
assert_eq!(app.config_selected_idx, 1);
app.config_select_next();
assert_eq!(app.config_selected_idx, 2);
app.config_select_next();
assert_eq!(app.config_selected_idx, 1);
app.config_select_prev();
assert_eq!(app.config_selected_idx, 2);
}
#[test]
fn test_focus_pane_shortcuts() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.active_side_left);
app.focus_right_pane();
assert!(!app.active_side_left);
app.focus_left_pane();
assert!(app.active_side_left);
}
#[test]
fn test_build_palette_actions_directory_tree() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.view_mode = ViewMode::DirectoryTree;
let actions = app.build_palette_actions();
assert!(actions.iter().any(|a| a.action_id == "quit"));
assert!(actions.iter().any(|a| a.action_id == "help"));
assert!(actions.iter().any(|a| a.action_id == "refresh"));
}
#[test]
fn test_build_palette_actions_file_diff() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.view_mode = ViewMode::FileDiff;
let actions = app.build_palette_actions();
assert!(actions.iter().any(|a| a.action_id == "toggle_wrap"));
assert!(actions.iter().any(|a| a.action_id == "toggle_full"));
assert!(actions.iter().any(|a| a.action_id == "next_change"));
assert!(actions.iter().any(|a| a.action_id == "prev_change"));
assert!(actions.iter().any(|a| a.action_id == "copy_hunk_l2r"));
assert!(actions.iter().any(|a| a.action_id == "copy_hunk_r2l"));
}
#[test]
fn test_copy_hunk_at_cursor_updates_target_file() {
use crate::diff::FileInfo;
use crate::diff_view::HunkCopyDirection;
use std::fs::{read_to_string, write};
use std::time::SystemTime;
use tempfile::tempdir;
let left_dir = tempdir().unwrap();
let right_dir = tempdir().unwrap();
write(left_dir.path().join("merge.txt"), "keep\nleft-line\n").unwrap();
write(right_dir.path().join("merge.txt"), "keep\nright-line\n").unwrap();
let mut app = App::new(
left_dir.path().to_path_buf(),
right_dir.path().to_path_buf(),
);
app.flat_rows = vec![FlatRow {
depth: 0,
relative_path: PathBuf::from("merge.txt"),
name: "merge.txt".to_string(),
state: crate::diff::DiffState::DifferentNewerLeft,
left: Some(FileInfo {
is_dir: false,
size: 1,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 1,
modified: SystemTime::UNIX_EPOCH,
}),
}];
app.apply_filter();
app.view_mode = ViewMode::FileDiff;
app.diff_show_full = true;
app.refresh_file_diff();
app.diff_scroll = 1;
app.copy_hunk_at_cursor(HunkCopyDirection::LeftToRight)
.expect("hunk copy should succeed");
let right_text = read_to_string(right_dir.path().join("merge.txt")).unwrap();
assert!(right_text.contains("left-line"));
assert!(!right_text.contains("right-line"));
}
#[test]
fn test_jump_to_next_and_prev_change() {
use crate::diff_view::{DiffLine, DiffRow};
use similar::ChangeTag;
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.diff_content_width = 40;
app.diff_rows = vec![
DiffRow::from((
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "ctx".to_string(),
}),
)),
DiffRow::from((
Some(DiffLine {
tag: ChangeTag::Delete,
text: "old".to_string(),
}),
Some(DiffLine {
tag: ChangeTag::Insert,
text: "new".to_string(),
}),
)),
DiffRow::from((
Some(DiffLine {
tag: ChangeTag::Delete,
text: "bye".to_string(),
}),
None,
)),
];
app.jump_to_next_change();
assert_eq!(app.diff_scroll, 1);
app.jump_to_next_change();
assert_eq!(app.diff_scroll, 2);
app.jump_to_prev_change();
assert_eq!(app.diff_scroll, 1);
}
}