use crate::diff::{AlignedNode, DiffState, FileInfo};
use crate::ignore::IgnoreMatcher;
use ratatui::layout::Rect;
use std::path::{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>,
}
impl FlatRow {
pub(crate) fn is_dir(&self) -> bool {
self.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| self.right.as_ref().map(|f| f.is_dir).unwrap_or(false)
}
}
#[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),
CheckUpdates,
Mouse,
Theme,
DiffContext,
}
impl ConfigRowKind {
pub fn is_selectable(self) -> bool {
!matches!(self, ConfigRowKind::Header(_))
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfirmAction {
CopyLeftToRight,
CopyRightToLeft,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConfirmModal {
pub message: String,
pub action: ConfirmAction,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaletteMode {
Menu,
Command,
}
#[derive(Clone, Debug, Default)]
pub struct PaletteState {
pub visible: bool,
pub mode: Option<PaletteMode>,
pub query: String,
pub items: Vec<crate::ui::PaletteAction>,
pub selected_idx: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct HelpState {
topic: HelpTopic,
return_view: ViewMode,
index_open: bool,
index_sel: usize,
scroll: u16,
}
impl Default for HelpState {
fn default() -> Self {
Self {
topic: HelpTopic::General,
return_view: ViewMode::DirectoryTree,
index_open: false,
index_sel: 0,
scroll: 0,
}
}
}
impl HelpState {
pub(crate) fn set_return_view(&mut self, view: ViewMode) {
self.return_view = view;
}
fn enter(&mut self, topic: HelpTopic) {
self.topic = topic;
self.index_sel = HelpTopic::all()
.iter()
.position(|&t| t == topic)
.unwrap_or(0);
self.index_open = false;
self.scroll = 0;
}
fn leave(&mut self) {
self.index_open = false;
}
pub(crate) fn select_topic(&mut self, topic: HelpTopic) {
self.topic = topic;
self.index_open = false;
self.scroll = 0;
}
pub(crate) fn select_topic_by_index(&mut self, idx: usize) -> bool {
match HelpTopic::all().get(idx) {
Some(&topic) => {
self.select_topic(topic);
true
}
None => false,
}
}
pub(crate) fn open_index(&mut self) {
self.index_sel = HelpTopic::all()
.iter()
.position(|&t| t == self.topic)
.unwrap_or(0);
self.index_open = true;
}
#[allow(dead_code)]
pub(crate) fn close_index(&mut self) {
self.index_open = false;
}
pub(crate) fn index_select_next(&mut self) {
self.index_sel = (self.index_sel + 1) % HelpTopic::all().len();
}
pub(crate) fn index_select_prev(&mut self) {
self.index_sel = self
.index_sel
.checked_sub(1)
.unwrap_or(HelpTopic::all().len() - 1);
}
pub(crate) fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_add(1);
}
pub(crate) fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub(crate) fn move_down(&mut self) {
if self.index_open {
self.index_select_next();
} else {
self.scroll_down();
}
}
pub(crate) fn move_up(&mut self) {
if self.index_open {
self.index_select_prev();
} else {
self.scroll_up();
}
}
pub(crate) fn topic(&self) -> HelpTopic {
self.topic
}
pub(crate) fn return_view(&self) -> ViewMode {
self.return_view
}
pub(crate) fn index_open(&self) -> bool {
self.index_open
}
pub(crate) fn index_sel(&self) -> usize {
self.index_sel
}
pub(crate) fn scroll(&self) -> u16 {
self.scroll
}
#[allow(dead_code)]
pub(crate) fn set_topic(&mut self, topic: HelpTopic) {
self.topic = topic;
}
#[allow(dead_code)]
pub(crate) fn set_index_open(&mut self, open: bool) {
self.index_open = open;
}
#[allow(dead_code)]
pub(crate) fn set_index_sel(&mut self, idx: usize) {
self.index_sel = idx;
}
#[allow(dead_code)]
pub(crate) fn set_scroll(&mut self, scroll: u16) {
self.scroll = scroll;
}
}
#[derive(Clone, Debug, Default)]
pub struct FilterState {
active: bool,
input: crate::text_input::TextInput,
pattern: String,
diffs_only: bool,
rows: Vec<FlatRow>,
}
impl FilterState {
pub(crate) fn active(&self) -> bool {
self.active
}
pub(crate) fn pattern(&self) -> &str {
&self.pattern
}
pub(crate) fn diffs_only(&self) -> bool {
self.diffs_only
}
pub(crate) fn toggle_diffs_only(&mut self) {
self.diffs_only = !self.diffs_only;
}
pub(crate) fn input(&self) -> &crate::text_input::TextInput {
&self.input
}
pub(crate) fn input_mut(&mut self) -> &mut crate::text_input::TextInput {
&mut self.input
}
pub(crate) fn rows(&self) -> &[FlatRow] {
&self.rows
}
pub(crate) fn open(&mut self) {
self.active = true;
self.input.set(self.pattern.clone());
}
pub(crate) fn commit(&mut self) {
self.active = false;
self.pattern = self.input.to_string();
}
pub(crate) fn cancel(&mut self) {
self.active = false;
self.input.set(self.pattern.clone());
}
pub(crate) fn clear(&mut self) {
self.pattern.clear();
self.input.clear();
self.diffs_only = false;
}
pub(crate) fn recompute(&mut self, source: &[FlatRow]) {
let pattern = self.pattern.to_lowercase();
let diffs_only = self.diffs_only;
if pattern.is_empty() && !diffs_only {
self.rows = source.to_vec();
} else {
self.rows = source
.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();
}
}
#[allow(dead_code)]
pub(crate) fn set_rows(&mut self, rows: Vec<FlatRow>) {
self.rows = rows;
}
#[allow(dead_code)]
pub(crate) fn set_pattern(&mut self, pattern: impl Into<String>) {
self.pattern = pattern.into();
}
}
#[derive(Clone, Copy, Debug)]
pub struct ConfigState {
selected_idx: usize,
return_view: ViewMode,
}
impl Default for ConfigState {
fn default() -> Self {
Self {
selected_idx: 0,
return_view: ViewMode::DirectoryTree,
}
}
}
impl ConfigState {
pub(crate) fn selected_idx(&self) -> usize {
self.selected_idx
}
pub(crate) fn return_view(&self) -> ViewMode {
self.return_view
}
pub(crate) fn set_return_view(&mut self, view: ViewMode) {
self.return_view = view;
}
pub(crate) fn ensure_selection(&mut self, rows: &[ConfigRowKind]) {
if rows.is_empty() {
self.selected_idx = 0;
return;
}
if self.selected_idx >= rows.len() || !rows[self.selected_idx].is_selectable() {
self.selected_idx = rows.iter().position(|r| r.is_selectable()).unwrap_or(0);
}
}
pub(crate) fn select_next(&mut self, rows: &[ConfigRowKind]) {
if rows.is_empty() {
return;
}
let mut next = self.selected_idx;
for _ in 0..rows.len() {
next = (next + 1) % rows.len();
if rows[next].is_selectable() {
self.selected_idx = next;
return;
}
}
}
pub(crate) fn select_prev(&mut self, rows: &[ConfigRowKind]) {
if rows.is_empty() {
return;
}
let mut prev = self.selected_idx;
for _ in 0..rows.len() {
prev = prev.checked_sub(1).unwrap_or(rows.len() - 1);
if rows[prev].is_selectable() {
self.selected_idx = prev;
return;
}
}
}
pub(crate) fn select_at(&mut self, idx: usize, rows: &[ConfigRowKind]) -> bool {
if idx < rows.len() && rows[idx].is_selectable() {
self.selected_idx = idx;
true
} else {
false
}
}
#[allow(dead_code)]
pub(crate) fn set_selected_idx(&mut self, idx: usize) {
self.selected_idx = idx;
}
}
#[derive(Clone, Debug, Default)]
pub struct FileDiffState {
rows: Vec<crate::diff_view::DiffRow>,
scroll: usize,
h_scroll: usize,
wrap: bool,
show_full: bool,
left_hash: Option<String>,
right_hash: Option<String>,
left_line_ending: Option<String>,
right_line_ending: Option<String>,
}
impl FileDiffState {
pub(crate) fn rows(&self) -> &[crate::diff_view::DiffRow] {
&self.rows
}
pub(crate) fn has_changes(&self) -> bool {
self.rows.iter().any(crate::diff_view::diff_row_is_change)
}
pub(crate) fn scroll(&self) -> usize {
self.scroll
}
pub(crate) fn h_scroll(&self) -> usize {
self.h_scroll
}
pub(crate) fn wrap(&self) -> bool {
self.wrap
}
pub(crate) fn show_full(&self) -> bool {
self.show_full
}
pub(crate) fn left_hash(&self) -> Option<&str> {
self.left_hash.as_deref()
}
pub(crate) fn right_hash(&self) -> Option<&str> {
self.right_hash.as_deref()
}
pub(crate) fn left_line_ending(&self) -> Option<&str> {
self.left_line_ending.as_deref()
}
pub(crate) fn right_line_ending(&self) -> Option<&str> {
self.right_line_ending.as_deref()
}
pub(crate) fn load(
&mut self,
left_file: &Path,
right_file: &Path,
diff_context: usize,
) -> Result<(), String> {
self.rows =
crate::diff_view::compare_files(left_file, right_file, self.show_full, diff_context)
.map_err(|e| e.to_string())?;
self.left_hash = crate::diff::compute_file_sha256(left_file).ok();
self.right_hash = crate::diff::compute_file_sha256(right_file).ok();
self.left_line_ending = crate::diff_view::detect_file_line_ending(left_file);
self.right_line_ending = crate::diff_view::detect_file_line_ending(right_file);
Ok(())
}
pub(crate) fn toggle_wrap(&mut self) {
self.wrap = !self.wrap;
self.reset_scroll();
}
pub(crate) fn toggle_show_full(&mut self) {
self.show_full = !self.show_full;
}
pub(crate) fn set_show_full(&mut self, on: bool) {
self.show_full = on;
}
pub(crate) fn scroll_down(&mut self, max: usize) {
if self.scroll < max {
self.scroll += 1;
}
}
pub(crate) fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub(crate) fn page_down(&mut self, step: usize, max: usize) {
self.scroll = (self.scroll + step).min(max);
}
pub(crate) fn page_up(&mut self, step: usize) {
self.scroll = self.scroll.saturating_sub(step);
}
pub(crate) fn h_scroll_left(&mut self) {
if !self.wrap && self.h_scroll > 0 {
self.h_scroll -= 1;
}
}
pub(crate) fn h_scroll_right(&mut self, max: usize) {
if !self.wrap && self.h_scroll < max {
self.h_scroll += 1;
}
}
pub(crate) fn reset_scroll(&mut self) {
self.scroll = 0;
self.h_scroll = 0;
}
pub(crate) fn clamp_scroll(&mut self, max_scroll: usize, max_h_scroll: usize) {
self.scroll = self.scroll.min(max_scroll);
self.h_scroll = self.h_scroll.min(max_h_scroll);
}
pub(crate) fn reset_for_swap(&mut self) {
self.scroll = 0;
self.left_hash = None;
self.right_hash = None;
}
pub(crate) fn jump_to_change(&mut self, width: usize, forward: bool) {
if let Some(scroll) = crate::diff_view::jump_to_change_scroll(
&self.rows,
self.scroll,
width,
self.wrap,
forward,
) {
self.scroll = scroll;
}
}
pub(crate) fn set_scroll(&mut self, scroll: usize) {
self.scroll = scroll;
}
#[allow(dead_code)]
pub(crate) fn set_rows(&mut self, rows: Vec<crate::diff_view::DiffRow>) {
self.rows = rows;
}
#[allow(dead_code)]
pub(crate) fn set_h_scroll(&mut self, scroll: usize) {
self.h_scroll = scroll;
}
#[allow(dead_code)]
pub(crate) fn set_wrap(&mut self, on: bool) {
self.wrap = on;
}
#[allow(dead_code)]
pub(crate) fn set_hashes(&mut self, left: Option<String>, right: Option<String>) {
self.left_hash = left;
self.right_hash = right;
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Viewport {
pub visible_height: usize,
pub diff_content_width: usize,
pub diff_max_line_width: usize,
pub diff_physical_rows: usize,
}
impl Viewport {
pub fn max_diff_scroll(self) -> usize {
self.diff_physical_rows.saturating_sub(self.visible_height)
}
pub fn max_diff_h_scroll(self) -> usize {
self.diff_max_line_width
.saturating_sub(self.diff_content_width)
}
}
pub struct App {
left_path: PathBuf,
right_path: PathBuf,
precise_mode: bool,
root_node: Option<AlignedNode>,
scan_in_progress: bool,
scan_generation: u64,
flat_rows: Vec<FlatRow>,
selected_idx: usize,
scroll_offset: usize,
active_side_left: bool,
view_mode: ViewMode,
diff: FileDiffState,
viewport: Viewport,
last_click_idx: Option<usize>,
last_click_time: Option<std::time::Instant>,
settings: crate::settings::AppSettings,
detected_diff_tools: Vec<(crate::diff_tool::ExternalDiffTool, bool)>,
config: ConfigState,
palette: PaletteState,
confirm_modal: Option<ConfirmModal>,
status_message: Option<(String, bool, Instant)>,
filter: FilterState,
ignore_matcher: IgnoreMatcher,
update_check_enabled: bool,
mouse_enabled: bool,
update_available: Option<String>,
install_method: crate::upgrade::InstallMethod,
help: HelpState,
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 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,
scan_generation: 0,
flat_rows: Vec::new(),
selected_idx: 0,
scroll_offset: 0,
active_side_left: true,
view_mode: ViewMode::DirectoryTree,
diff: FileDiffState::default(),
viewport: Viewport::default(),
last_click_idx: None,
last_click_time: None,
settings,
detected_diff_tools,
config: ConfigState::default(),
palette: PaletteState::default(),
confirm_modal: None,
status_message: None,
filter: FilterState::default(),
ignore_matcher,
update_check_enabled: true,
mouse_enabled: true,
update_available: None,
install_method,
help: HelpState::default(),
should_quit: false,
}
}
pub fn begin_scan(&mut self) -> u64 {
self.scan_generation = self.scan_generation.wrapping_add(1);
self.scan_in_progress = true;
self.scan_generation
}
pub fn scan_in_progress(&self) -> bool {
self.scan_in_progress
}
pub fn apply_scan_result(&mut self, generation: u64, node: AlignedNode) -> bool {
if generation != self.scan_generation {
return false;
}
let expanded_paths = self.collect_expanded_paths();
self.root_node = Some(node);
self.restore_expanded_paths(&expanded_paths);
self.scan_in_progress = false;
self.flatten_tree();
true
}
pub fn fail_scan(&mut self, generation: u64) -> bool {
if generation != self.scan_generation {
return false;
}
self.scan_in_progress = false;
true
}
pub fn apply_update_check_outcome(&mut self, outcome: crate::upgrade::UpdateCheckOutcome) {
let now = crate::upgrade::now_secs();
match outcome {
crate::upgrade::UpdateCheckOutcome::Newer(version) => {
if let Ok(path) = crate::upgrade::state_path() {
crate::upgrade::save_state(
&path,
&crate::upgrade::UpdateCheckState {
last_check: now,
latest_seen: version.clone(),
},
);
}
self.update_available = Some(version);
}
crate::upgrade::UpdateCheckOutcome::UpToDate => {
if let Ok(path) = crate::upgrade::state_path() {
crate::upgrade::save_state(
&path,
&crate::upgrade::UpdateCheckState {
last_check: now,
latest_seen: String::new(),
},
);
}
self.update_available = None;
}
crate::upgrade::UpdateCheckOutcome::Failed => {}
}
}
pub fn viewport(&self) -> Viewport {
self.viewport
}
pub fn sync_viewport(&mut self, area: Rect) {
match self.view_mode {
ViewMode::DirectoryTree => {
let inputs = self.tree_layout_inputs();
let layout = crate::ui::tree_layout(&inputs, area);
self.viewport.visible_height = layout.left.height.saturating_sub(2) as usize;
self.adjust_scroll(self.viewport.visible_height);
}
ViewMode::FileDiff => {
let inputs = self.diff_layout_inputs();
let layout = crate::ui::diff_layout(&inputs, area);
self.viewport.visible_height = layout.left.height.saturating_sub(2) as usize;
self.viewport.diff_content_width = layout.left.width.saturating_sub(2) as usize;
self.resync_diff_geometry();
self.clamp_diff_scroll();
}
ViewMode::ConfigMenu | ViewMode::Help => {}
}
}
pub fn set_status(&mut self, msg: impl Into<String>, is_error: bool) {
self.status_message = Some((msg.into(), is_error, Instant::now()));
}
pub(crate) fn status_toast(&self) -> Option<(&str, bool)> {
self.status_message
.as_ref()
.map(|(msg, is_error, _)| (msg.as_str(), *is_error))
}
pub fn request_quit(&mut self) {
self.should_quit = true;
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn toggle_precise_mode(&mut self) {
self.precise_mode = !self.precise_mode;
}
pub fn precise_mode(&self) -> bool {
self.precise_mode
}
pub fn ignore_matcher(&self) -> &IgnoreMatcher {
&self.ignore_matcher
}
pub fn mouse_enabled(&self) -> bool {
self.mouse_enabled
}
pub(crate) fn set_mouse_enabled(&mut self, enabled: bool) {
self.mouse_enabled = enabled;
}
pub fn update_check_enabled(&self) -> bool {
self.update_check_enabled
}
pub(crate) fn set_update_check_enabled(&mut self, enabled: bool) {
self.update_check_enabled = enabled;
}
pub fn update_available(&self) -> Option<&str> {
self.update_available.as_deref()
}
pub(crate) fn set_update_available(&mut self, version: Option<String>) {
self.update_available = version;
}
pub fn install_method(&self) -> &crate::upgrade::InstallMethod {
&self.install_method
}
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.push(ConfigRowKind::Header("Updates"));
rows.push(ConfigRowKind::CheckUpdates);
rows.push(ConfigRowKind::Header("Mouse"));
rows.push(ConfigRowKind::Mouse);
rows.push(ConfigRowKind::Header("Theme"));
rows.push(ConfigRowKind::Theme);
rows.push(ConfigRowKind::Header("Diff View"));
rows.push(ConfigRowKind::DiffContext);
rows
}
pub fn settings(&self) -> &crate::settings::AppSettings {
&self.settings
}
pub fn theme(&self) -> crate::theme::Theme {
crate::theme::Theme::for_choice(self.settings.theme)
}
pub fn toggle_theme(&mut self) {
self.settings.theme = self.settings.theme.toggled();
let _ = self.settings.save();
self.set_status(format!("Theme: {}", self.settings.theme.label()), false);
}
pub fn view_mode(&self) -> ViewMode {
self.view_mode
}
fn open_overlay(&mut self, target: ViewMode) -> bool {
if self.view_mode == target {
return false;
}
match target {
ViewMode::ConfigMenu => self.config.set_return_view(self.view_mode),
ViewMode::Help => self.help.set_return_view(self.view_mode),
_ => unreachable!("open_overlay is only used for the ConfigMenu/Help targets"),
}
self.view_mode = target;
true
}
pub fn open_config(&mut self) {
if self.open_overlay(ViewMode::ConfigMenu) {
self.ensure_config_selection();
}
}
pub(crate) fn close_config(&mut self) {
self.view_mode = self.config.return_view();
}
#[allow(dead_code)]
pub(crate) fn config(&self) -> &ConfigState {
&self.config
}
#[allow(dead_code)]
pub(crate) fn config_mut(&mut self) -> &mut ConfigState {
&mut self.config
}
pub fn ensure_config_selection(&mut self) {
let rows = self.config_rows();
self.config.ensure_selection(&rows);
}
pub fn config_select_next(&mut self) {
let rows = self.config_rows();
self.config.select_next(&rows);
}
pub fn config_select_prev(&mut self) {
let rows = self.config_rows();
self.config.select_prev(&rows);
}
pub(crate) fn config_select_at(&mut self, idx: usize) -> bool {
let rows = self.config_rows();
self.config.select_at(idx, &rows)
}
pub fn apply_config_selection(&mut self) {
let rows = self.config_rows();
match rows.get(self.config.selected_idx()) {
Some(ConfigRowKind::DiffTool(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();
}
}
Some(ConfigRowKind::CheckUpdates) => {
self.settings.check_updates = !self.settings.check_updates;
self.update_check_enabled = self.settings.check_updates;
let _ = self.settings.save();
}
Some(ConfigRowKind::Mouse) => {
self.settings.mouse = !self.settings.mouse;
self.mouse_enabled = self.settings.mouse;
let _ = self.settings.save();
}
Some(ConfigRowKind::Theme) => {
self.toggle_theme();
}
_ => {}
}
}
pub fn adjust_config_selection(&mut self, forward: bool) {
let rows = self.config_rows();
if let Some(ConfigRowKind::DiffContext) = rows.get(self.config.selected_idx()) {
self.settings.diff_context = if forward {
self.settings.diff_context.saturating_add(1).min(50)
} else {
self.settings.diff_context.saturating_sub(1)
};
let _ = self.settings.save();
}
}
pub(crate) fn config_scroll(&mut self, scroll_down: bool) {
let rows = self.config_rows();
if matches!(
rows.get(self.config.selected_idx()),
Some(ConfigRowKind::DiffContext)
) {
self.adjust_config_selection(!scroll_down);
} else if scroll_down {
self.config_select_next();
} else {
self.config_select_prev();
}
}
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(crate) fn toggle_active_side(&mut self) {
self.active_side_left = !self.active_side_left;
}
pub(crate) fn active_side_left(&self) -> bool {
self.active_side_left
}
pub fn left_path(&self) -> &Path {
&self.left_path
}
pub fn right_path(&self) -> &Path {
&self.right_path
}
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.reset_for_swap();
}
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;
}
}
}
#[allow(dead_code)]
pub(crate) fn diff(&self) -> &FileDiffState {
&self.diff
}
pub(crate) fn diff_mut(&mut self) -> &mut FileDiffState {
&mut self.diff
}
pub(crate) fn diff_view(&self) -> crate::ui::DiffView<'_> {
let viewport = self.viewport();
crate::ui::DiffView {
rows: self.diff.rows(),
wrap: self.diff.wrap(),
scroll: self.diff.scroll(),
h_scroll: self.diff.h_scroll(),
visible_height: viewport.visible_height,
content_width: viewport.diff_content_width,
left_root: &self.left_path,
right_root: &self.right_path,
row: self.selected_row(),
left_hash: self.diff.left_hash(),
right_hash: self.diff.right_hash(),
left_line_ending: self.diff.left_line_ending(),
right_line_ending: self.diff.right_line_ending(),
theme: self.theme(),
status_toast: self.status_toast(),
has_changes: self.diff.has_changes(),
update_available: self.update_available(),
install_method: self.install_method(),
}
}
pub(crate) fn diff_layout_inputs(&self) -> crate::ui::DiffLayoutInputs {
let row = self.selected_row();
crate::ui::DiffLayoutInputs {
has_changes: self.diff.has_changes(),
row_has_content: row.is_some_and(|r| r.left.is_some() || r.right.is_some()),
has_status: self.status_toast().is_some(),
has_update: self.update_available().is_some(),
}
}
pub(crate) fn tree_view(&self) -> crate::ui::TreeView<'_> {
crate::ui::TreeView {
rows: self.filter.rows(),
scroll_offset: self.scroll_offset,
selected_idx: self.selected_idx,
visible_height: self.viewport().visible_height,
left_root: &self.left_path,
right_root: &self.right_path,
active_side_left: self.active_side_left,
theme: self.theme(),
}
}
pub(crate) fn tree_footer_view(&self) -> crate::ui::TreeFooterView<'_> {
crate::ui::TreeFooterView {
row: self.selected_row(),
status_toast: self.status_toast(),
filter_active: self.filter.active(),
filter_input: self.filter.input(),
filter_pattern: self.filter.pattern(),
filter_diffs_only: self.filter.diffs_only(),
scan_in_progress: self.scan_in_progress(),
update_available: self.update_available(),
install_method: self.install_method(),
theme: self.theme(),
}
}
pub(crate) fn tree_layout_inputs(&self) -> crate::ui::TreeLayoutInputs {
crate::ui::TreeLayoutInputs {
has_detail: crate::ui::selected_row_detail(self.selected_row()).is_some(),
has_status: self.status_toast().is_some(),
has_filter: self.filter.active(),
has_update: self.update_available().is_some(),
}
}
pub(crate) fn help_view(&self) -> crate::ui::HelpView<'_> {
crate::ui::HelpView {
topic: self.help.topic(),
index_open: self.help.index_open(),
index_sel: self.help.index_sel(),
scroll: self.help.scroll(),
theme: self.theme(),
update_available: self.update_available.as_deref(),
install_method: &self.install_method,
}
}
pub(crate) fn config_view(&self) -> crate::ui::ConfigView<'_> {
crate::ui::ConfigView {
rows: self.config_rows(),
selected_idx: self.config.selected_idx(),
detected_diff_tools: &self.detected_diff_tools,
external_diff_tool: self.settings.external_diff_tool.as_deref(),
check_updates: self.settings.check_updates,
mouse: self.settings.mouse,
theme_choice: self.settings.theme,
diff_context: self.settings.diff_context,
theme: self.theme(),
}
}
pub(crate) fn top_bar_view(&self) -> crate::ui::TopBarView {
crate::ui::TopBarView {
view_mode: self.view_mode,
precise_mode: self.precise_mode,
diff_show_full: self.diff.show_full(),
diff_wrap: self.diff.wrap(),
theme: self.theme(),
}
}
pub(crate) fn confirm_view(&self) -> crate::ui::ConfirmView<'_> {
crate::ui::ConfirmView {
message: self
.confirm_modal
.as_ref()
.map(|m| m.message.as_str())
.unwrap_or(""),
theme: self.theme(),
}
}
pub(crate) fn selected_row(&self) -> Option<&FlatRow> {
self.filter.rows().get(self.selected_idx)
}
pub fn jump_to_next_change(&mut self) {
let width = self.viewport.diff_content_width.max(1);
self.diff.jump_to_change(width, true);
}
pub fn jump_to_prev_change(&mut self) {
let width = self.viewport.diff_content_width.max(1);
self.diff.jump_to_change(width, false);
}
pub fn refresh_file_diff(&mut self) -> Result<(), String> {
let Some(row) = self.selected_row() else {
return Err("no file selected".to_string());
};
let left_file = self.left_path.join(&row.relative_path);
let right_file = self.right_path.join(&row.relative_path);
self.diff
.load(&left_file, &right_file, self.settings.diff_context)?;
self.resync_diff_geometry();
Ok(())
}
pub fn toggle_diff_show_full(&mut self) -> Result<(), String> {
self.diff.toggle_show_full();
if let Err(e) = self.refresh_file_diff() {
self.diff.toggle_show_full();
return Err(e);
}
self.diff.reset_scroll();
Ok(())
}
fn resync_diff_geometry(&mut self) {
self.viewport.diff_max_line_width = crate::diff_view::diff_max_line_width(self.diff.rows());
self.viewport.diff_physical_rows = crate::diff_view::diff_total_physical_rows(
self.diff.rows(),
self.viewport.diff_content_width,
self.diff.wrap(),
);
}
fn clamp_diff_scroll(&mut self) {
let max_scroll = self.viewport.max_diff_scroll();
let max_h_scroll = self.viewport.max_diff_h_scroll();
self.diff.clamp_scroll(max_scroll, max_h_scroll);
}
pub fn enter_file_diff(&mut self) -> bool {
let Some(row) = self.selected_row() else {
return false;
};
let is_dir = row.is_dir();
if is_dir {
return false;
}
self.diff.set_show_full(false);
match self.refresh_file_diff() {
Ok(()) => {
self.view_mode = ViewMode::FileDiff;
self.diff.reset_scroll();
true
}
Err(e) => {
self.set_status(format!("Cannot open diff: {e}"), true);
false
}
}
}
pub fn leave_file_diff(&mut self) {
self.view_mode = ViewMode::DirectoryTree;
}
pub fn copy_hunk_at_cursor(
&mut self,
direction: crate::diff_view::HunkCopyDirection,
) -> Result<(), std::io::Error> {
let Some(row) = self.selected_row() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no file selected",
));
};
let width = self.viewport.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 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().map_err(std::io::Error::other)?;
let max_scroll = self.viewport.max_diff_scroll();
self.diff.set_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);
}
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 selected_relative_path(&self) -> Option<PathBuf> {
self.selected_row().map(|r| r.relative_path.clone())
}
pub fn collect_expanded_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(root) = &self.root_node {
Self::collect_expanded_paths_node(root, &mut paths);
}
paths
}
fn collect_expanded_paths_node(node: &AlignedNode, paths: &mut Vec<PathBuf>) {
if node.is_expanded {
paths.push(node.relative_path.clone());
}
for child in &node.children {
Self::collect_expanded_paths_node(child, paths);
}
}
pub fn restore_expanded_paths(&mut self, paths: &[PathBuf]) {
if paths.is_empty() {
return;
}
if let Some(ref mut root) = self.root_node {
for path in paths {
Self::set_expand_node(root, path, true);
}
}
}
pub fn apply_incremental_rescan(
&mut self,
copied_rel: &std::path::Path,
copied_is_dir: bool,
) -> Result<(), std::io::Error> {
let scan_rel: PathBuf = if copied_is_dir {
copied_rel.to_path_buf()
} else {
copied_rel
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default()
};
if scan_rel.as_os_str().is_empty() {
return Err(std::io::Error::other(
"incremental rescan not used for root",
));
}
let expanded = self.collect_expanded_paths();
let new_node = crate::diff::align_directories(
&self.left_path,
&self.right_path,
&scan_rel,
self.precise_mode,
&self.ignore_matcher,
)?;
let Some(root) = self.root_node.as_mut() else {
self.root_node = Some(new_node);
self.restore_expanded_paths(&expanded);
self.flatten_tree();
return Ok(());
};
if !crate::diff::replace_subtree(root, &scan_rel, new_node) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"subtree path not found in tree",
));
}
self.restore_expanded_paths(&expanded);
self.flatten_tree();
Ok(())
}
pub(crate) fn filter(&self) -> &FilterState {
&self.filter
}
pub(crate) fn filter_mut(&mut self) -> &mut FilterState {
&mut self.filter
}
pub fn apply_filter(&mut self) {
let prev_path = self.selected_relative_path();
let prev_scroll = self.scroll_offset;
self.filter.recompute(&self.flat_rows);
if self.filter.rows().is_empty() {
self.selected_idx = 0;
self.scroll_offset = 0;
return;
}
if let Some(path) = prev_path {
if let Some(idx) = self
.filter
.rows()
.iter()
.position(|r| r.relative_path == path)
{
self.selected_idx = idx;
let max_scroll = self.filter.rows().len().saturating_sub(1);
self.scroll_offset = prev_scroll.min(max_scroll);
self.adjust_scroll(self.viewport.visible_height);
return;
}
}
self.selected_idx = 0;
self.scroll_offset = 0;
}
pub fn request_confirm(&mut self, message: impl Into<String>, action: ConfirmAction) {
self.confirm_modal = Some(ConfirmModal {
message: message.into(),
action,
});
}
pub fn request_copy(&mut self, direction: ConfirmAction) {
let Some(row) = self.selected_row() else {
return;
};
let source_present = match direction {
ConfirmAction::CopyLeftToRight => row.left.is_some(),
ConfirmAction::CopyRightToLeft => row.right.is_some(),
};
if !source_present {
return;
}
let name = row.name.clone();
let dest_label = match direction {
ConfirmAction::CopyLeftToRight => "right",
ConfirmAction::CopyRightToLeft => "left",
};
self.request_confirm(
format!("Copy '{}' to {} side?", name, dest_label),
direction,
);
}
pub fn take_confirmed_action(&mut self) -> Option<ConfirmAction> {
self.confirm_modal.take().map(|modal| modal.action)
}
pub fn dismiss_confirm(&mut self) {
self.confirm_modal = None;
}
pub fn confirm_modal(&self) -> Option<&ConfirmModal> {
self.confirm_modal.as_ref()
}
pub fn open_help(&mut self) {
if !self.open_overlay(ViewMode::Help) {
return;
}
let topic = HelpTopic::for_view(self.help.return_view());
self.help.enter(topic);
}
pub(crate) fn close_help(&mut self) {
self.view_mode = self.help.return_view();
self.help.leave();
}
pub(crate) fn help(&self) -> &HelpState {
&self.help
}
pub(crate) fn help_mut(&mut self) -> &mut HelpState {
&mut self.help
}
pub fn commit_filter(&mut self) {
self.filter.commit();
self.apply_filter();
}
pub fn clear_filter(&mut self) {
self.filter.clear();
self.apply_filter();
}
pub fn toggle_expand(&mut self) {
let Some(row) = self.selected_row() else {
return;
};
let is_dir = row.is_dir();
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.filter.rows().is_empty() && self.selected_idx < self.filter.rows().len() - 1 {
self.selected_idx += 1;
}
}
pub fn select_prev(&mut self) {
if self.selected_idx > 0 {
self.selected_idx -= 1;
}
}
fn page_step(&self) -> usize {
self.viewport.visible_height.saturating_sub(1).max(1)
}
pub fn page_down(&mut self) {
if self.filter.rows().is_empty() {
return;
}
let max_idx = self.filter.rows().len() - 1;
self.selected_idx = (self.selected_idx + self.page_step()).min(max_idx);
self.adjust_scroll(self.viewport.visible_height);
}
pub fn page_up(&mut self) {
if self.filter.rows().is_empty() {
return;
}
self.selected_idx = self.selected_idx.saturating_sub(self.page_step());
self.adjust_scroll(self.viewport.visible_height);
}
pub fn diff_page_down(&mut self) {
let step = self.page_step();
let max = self.viewport.max_diff_scroll();
self.diff.page_down(step, max);
}
pub fn diff_page_up(&mut self) {
let step = self.page_step();
self.diff.page_up(step);
}
pub(crate) fn diff_scroll_down(&mut self) {
let max = self.viewport.max_diff_scroll();
self.diff.scroll_down(max);
}
pub(crate) fn diff_h_scroll_right(&mut self) {
let max = self.viewport.max_diff_h_scroll();
self.diff.h_scroll_right(max);
}
pub fn expand_selected(&mut self) {
let Some(row) = self.selected_row() else {
return;
};
let is_dir = row.is_dir();
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) {
let Some(row) = self.selected_row() else {
return;
};
let is_dir = row.is_dir();
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(crate) fn select_row_at(&mut self, idx: usize) -> bool {
if idx >= self.filter.rows().len() {
return false;
}
self.selected_idx = idx;
true
}
pub(crate) fn note_tree_click(&mut self, idx: usize) -> bool {
let now = std::time::Instant::now();
let is_double_click = Some(idx) == self.last_click_idx
&& self
.last_click_time
.is_some_and(|t| now.duration_since(t) < std::time::Duration::from_millis(400));
if is_double_click {
self.last_click_idx = None;
self.last_click_time = None;
} else {
self.last_click_idx = Some(idx);
self.last_click_time = Some(now);
}
is_double_click
}
#[allow(dead_code)]
pub(crate) fn selected_idx(&self) -> usize {
self.selected_idx
}
pub(crate) fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub(crate) fn open_palette_menu(&mut self) {
self.palette.visible = true;
self.palette.mode = Some(PaletteMode::Menu);
self.palette.query.clear();
self.palette.selected_idx = 0;
}
pub(crate) fn open_palette_command(&mut self) {
self.palette.visible = true;
self.palette.mode = Some(PaletteMode::Command);
self.palette.query.clear();
self.palette.selected_idx = 0;
}
pub(crate) fn close_palette(&mut self) {
self.palette.visible = false;
self.palette.query.clear();
}
pub(crate) fn hide_palette(&mut self) {
self.palette.visible = false;
}
pub(crate) fn palette_select_next(&mut self) {
if self.palette.items.is_empty() {
return;
}
self.palette.selected_idx = (self.palette.selected_idx + 1) % self.palette.items.len();
}
pub(crate) fn palette_select_prev(&mut self) {
if self.palette.items.is_empty() {
return;
}
self.palette.selected_idx = self
.palette
.selected_idx
.checked_sub(1)
.unwrap_or(self.palette.items.len() - 1);
}
pub(crate) fn palette_type_char(&mut self, c: char) {
self.palette.query.push(c);
self.palette.selected_idx = 0;
}
pub(crate) fn palette_backspace(&mut self) {
self.palette.query.pop();
self.palette.selected_idx = 0;
}
pub(crate) fn refresh_palette_items(&mut self) {
let mode = self.palette.mode.unwrap_or(PaletteMode::Menu);
let actions = self.build_palette_actions();
self.palette.items = if mode == PaletteMode::Command {
let q = self.palette.query.to_lowercase();
actions
.into_iter()
.filter(|a| {
a.label.to_lowercase().contains(&q) || a.key.to_lowercase().contains(&q)
})
.collect()
} else {
actions
};
}
pub(crate) fn palette(&self) -> &PaletteState {
&self.palette
}
pub(crate) fn palette_view(&self) -> crate::ui::PaletteView<'_> {
crate::ui::PaletteView {
mode: self.palette.mode.unwrap_or(PaletteMode::Menu),
items: &self.palette.items,
selected_idx: self.palette.selected_idx,
query: &self.palette.query,
theme: self.theme(),
}
}
pub(crate) fn palette_visible(&self) -> bool {
self.palette.visible
}
pub fn build_palette_actions(&self) -> Vec<crate::ui::PaletteAction> {
let mut actions = Vec::new();
match self.view_mode {
ViewMode::DirectoryTree => {
let row = self.selected_row();
let is_file_pair =
row.is_some_and(|r| !r.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(crate::ui::PaletteAction {
key: "D".to_string(),
label: "Compare via External Diff Tool".to_string(),
action_id: crate::ui::PaletteActionId::ExternalDiff,
enabled: is_file_pair && self.settings.external_diff_tool.is_some(),
});
actions.push(crate::ui::PaletteAction {
key: "E".to_string(),
label: "Edit via External Editor".to_string(),
action_id: crate::ui::PaletteActionId::ExternalEdit,
enabled: is_file_active,
});
actions.push(crate::ui::PaletteAction {
key: "R".to_string(),
label: "Copy Left to Right".to_string(),
action_id: crate::ui::PaletteActionId::CopyLeftToRight,
enabled: row.is_some_and(|r| r.left.is_some()),
});
actions.push(crate::ui::PaletteAction {
key: "L".to_string(),
label: "Copy Right to Left".to_string(),
action_id: crate::ui::PaletteActionId::CopyRightToLeft,
enabled: row.is_some_and(|r| r.right.is_some()),
});
actions.push(crate::ui::PaletteAction {
key: "Enter".to_string(),
label: "Open built-in Diff view".to_string(),
action_id: crate::ui::PaletteActionId::BuiltinDiff,
enabled: row.is_some_and(|r| !r.is_dir()),
});
actions.push(crate::ui::PaletteAction {
key: "s".to_string(),
label: "Swap Left/Right Paths".to_string(),
action_id: crate::ui::PaletteActionId::SwapPaths,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "c".to_string(),
label: "Toggle Scan Mode (Fast/Precise)".to_string(),
action_id: crate::ui::PaletteActionId::ToggleScan,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "r".to_string(),
label: "Manual Re-scan / Refresh".to_string(),
action_id: crate::ui::PaletteActionId::Refresh,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "C".to_string(),
label: "Edit Configuration".to_string(),
action_id: crate::ui::PaletteActionId::Config,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: crate::ui::PaletteActionId::Help,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "/".to_string(),
label: "Open Filter Input".to_string(),
action_id: crate::ui::PaletteActionId::Filter,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "q".to_string(),
label: "Quit duodiff".to_string(),
action_id: crate::ui::PaletteActionId::Quit,
enabled: true,
});
}
ViewMode::FileDiff => {
actions.push(crate::ui::PaletteAction {
key: "w".to_string(),
label: "Toggle Wrap Mode".to_string(),
action_id: crate::ui::PaletteActionId::ToggleWrap,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "f".to_string(),
label: "Toggle Full Content".to_string(),
action_id: crate::ui::PaletteActionId::ToggleFullDiff,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "N".to_string(),
label: "Next Change".to_string(),
action_id: crate::ui::PaletteActionId::NextChange,
enabled: self.diff.has_changes(),
});
actions.push(crate::ui::PaletteAction {
key: "P".to_string(),
label: "Previous Change".to_string(),
action_id: crate::ui::PaletteActionId::PrevChange,
enabled: self.diff.has_changes(),
});
actions.push(crate::ui::PaletteAction {
key: "]".to_string(),
label: "Copy Change Block to Right".to_string(),
action_id: crate::ui::PaletteActionId::CopyHunkLeftToRight,
enabled: self.diff.has_changes(),
});
actions.push(crate::ui::PaletteAction {
key: "[".to_string(),
label: "Copy Change Block to Left".to_string(),
action_id: crate::ui::PaletteActionId::CopyHunkRightToLeft,
enabled: self.diff.has_changes(),
});
actions.push(crate::ui::PaletteAction {
key: "R".to_string(),
label: "Copy Whole File Left to Right".to_string(),
action_id: crate::ui::PaletteActionId::CopyLeftToRight,
enabled: self.selected_row().is_some_and(|r| r.left.is_some()),
});
actions.push(crate::ui::PaletteAction {
key: "L".to_string(),
label: "Copy Whole File Right to Left".to_string(),
action_id: crate::ui::PaletteActionId::CopyRightToLeft,
enabled: self.selected_row().is_some_and(|r| r.right.is_some()),
});
actions.push(crate::ui::PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: crate::ui::PaletteActionId::Help,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "Esc".to_string(),
label: "Return to Tree View".to_string(),
action_id: crate::ui::PaletteActionId::Back,
enabled: true,
});
}
_ => {
actions.push(crate::ui::PaletteAction {
key: "?".to_string(),
label: "Open Help Screen".to_string(),
action_id: crate::ui::PaletteActionId::Help,
enabled: true,
});
actions.push(crate::ui::PaletteAction {
key: "Esc".to_string(),
label: "Go Back".to_string(),
action_id: crate::ui::PaletteActionId::Back,
enabled: true,
});
}
}
actions
}
}
#[cfg(test)]
impl App {
pub(crate) fn flat_rows(&self) -> &[FlatRow] {
&self.flat_rows
}
pub(crate) fn push_flat_row(&mut self, row: FlatRow) {
self.flat_rows.push(row);
}
pub(crate) fn set_flat_rows(&mut self, rows: Vec<FlatRow>) {
self.flat_rows = rows;
}
pub(crate) fn set_palette_items(&mut self, items: Vec<crate::ui::PaletteAction>) {
self.palette.items = items;
}
pub(crate) fn set_palette_selected_idx(&mut self, idx: usize) {
self.palette.selected_idx = idx;
}
pub(crate) fn set_root_node(&mut self, node: AlignedNode) {
self.root_node = Some(node);
self.flatten_tree();
}
pub(crate) fn set_view_mode(&mut self, view_mode: ViewMode) {
self.view_mode = view_mode;
}
pub(crate) fn set_theme(&mut self, theme: crate::theme::ThemeChoice) {
self.settings.theme = theme;
}
pub(crate) fn set_external_diff_tool(&mut self, tool: Option<String>) {
self.settings.external_diff_tool = tool;
}
pub(crate) fn set_detected_diff_tools(
&mut self,
tools: Vec<(crate::diff_tool::ExternalDiffTool, bool)>,
) {
self.detected_diff_tools = tools;
}
pub(crate) fn set_selected_idx(&mut self, idx: usize) {
self.selected_idx = idx;
}
pub(crate) fn set_scroll_offset(&mut self, offset: usize) {
self.scroll_offset = offset;
}
pub(crate) fn set_active_side_left(&mut self, left: bool) {
self.active_side_left = left;
}
pub(crate) fn set_precise_mode(&mut self, on: bool) {
self.precise_mode = on;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::{DiffState, FileInfo};
use crate::test_support::{lock_env_tests, ConfigEnvGuard, RedirectedConfigDir};
use std::time::SystemTime;
fn file_info(is_dir: bool) -> FileInfo {
FileInfo {
is_dir,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}
}
fn flat_row_with_sides(left: Option<FileInfo>, right: Option<FileInfo>) -> FlatRow {
FlatRow {
depth: 0,
relative_path: PathBuf::from("entry"),
name: "entry".to_string(),
state: DiffState::Identical,
left,
right,
}
}
#[test]
fn test_flat_row_is_dir_true_when_either_side_is_a_directory() {
assert!(flat_row_with_sides(Some(file_info(true)), Some(file_info(true))).is_dir());
assert!(flat_row_with_sides(Some(file_info(true)), Some(file_info(false))).is_dir());
assert!(flat_row_with_sides(None, Some(file_info(true))).is_dir());
assert!(flat_row_with_sides(Some(file_info(true)), None).is_dir());
}
#[test]
fn test_flat_row_is_dir_false_when_both_sides_are_files_or_missing() {
assert!(!flat_row_with_sides(Some(file_info(false)), Some(file_info(false))).is_dir());
assert!(!flat_row_with_sides(None, Some(file_info(false))).is_dir());
assert!(!flat_row_with_sides(Some(file_info(false)), None).is_dir());
assert!(!flat_row_with_sides(None, None).is_dir());
}
#[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_page_down_up_moves_by_visible_height() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.flat_rows = (0..20)
.map(|i| FlatRow {
depth: 0,
relative_path: PathBuf::from(format!("f{i}.txt")),
name: format!("f{i}.txt"),
state: DiffState::Identical,
left: None,
right: None,
})
.collect();
app.apply_filter();
app.viewport.visible_height = 5;
app.page_down();
assert_eq!(app.selected_idx(), 4);
assert_eq!(app.scroll_offset(), 0);
app.page_down();
assert_eq!(app.selected_idx(), 8);
assert_eq!(app.scroll_offset(), 4);
app.page_up();
assert_eq!(app.selected_idx(), 4);
app.set_selected_idx(18);
app.page_down();
assert_eq!(app.selected_idx(), 19);
app.page_up();
assert_eq!(app.selected_idx(), 15);
app.filter_mut().set_rows(Vec::new());
app.set_selected_idx(0);
app.page_down();
app.page_up();
assert_eq!(app.selected_idx(), 0);
}
#[test]
fn test_diff_page_down_up() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.viewport.diff_physical_rows = 30;
app.viewport.visible_height = 10; app.diff_mut().set_scroll(0);
app.diff_page_down();
assert_eq!(app.diff().scroll(), 9);
app.diff_page_down();
assert_eq!(app.diff().scroll(), 18);
app.diff_page_down();
assert_eq!(app.diff().scroll(), 20);
app.diff_page_up();
assert_eq!(app.diff().scroll(), 11);
app.diff_mut().set_scroll(3);
app.diff_page_up();
assert_eq!(app.diff().scroll(), 0);
}
#[test]
fn test_begin_scan_bumps_generation() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert_eq!(app.scan_generation, 0);
assert!(!app.scan_in_progress);
let g1 = app.begin_scan();
assert_eq!(g1, 1);
assert_eq!(app.scan_generation, 1);
assert!(app.scan_in_progress);
let g2 = app.begin_scan();
assert_eq!(g2, 2);
assert_eq!(app.scan_generation, 2);
}
#[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.set_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.set_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.set_scroll_offset(2);
app.set_selected_idx(5);
app.adjust_scroll(0);
assert_eq!(app.scroll_offset(), 2);
app.set_selected_idx(1);
app.adjust_scroll(5);
assert_eq!(app.scroll_offset(), 1);
app.set_selected_idx(7);
app.adjust_scroll(5);
assert_eq!(app.scroll_offset(), 3);
app.set_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_toast().is_none());
app.set_status("Copy failed: permission denied", true);
assert!(app.status_toast().is_some());
let (msg, is_error) = app.status_toast().unwrap();
assert!(is_error);
assert!(msg.contains("permission denied"));
app.clear_expired_status(std::time::Duration::from_secs(10));
assert!(app.status_toast().is_some());
app.clear_expired_status(std::time::Duration::ZERO);
assert!(app.status_toast().is_none());
app.set_status("Copied 'file.txt'", false);
let (_, is_error) = app.status_toast().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.set_selected_idx(5);
app.set_scroll_offset(3);
app.diff_mut().set_scroll(2);
app.diff_mut()
.set_hashes(Some("abc".to_string()), 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.filter().rows().len(), 3);
app.filter_mut().set_pattern("alpha");
app.apply_filter();
assert_eq!(app.filter().rows().len(), 1);
assert_eq!(app.filter().rows()[0].name, "alpha.txt");
app.filter_mut().set_pattern("");
app.apply_filter();
assert_eq!(app.filter().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_mut().toggle_diffs_only();
app.apply_filter();
assert_eq!(app.filter().rows().len(), 2);
assert!(app
.filter()
.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_mut().set_pattern("a");
app.filter_mut().toggle_diffs_only();
app.apply_filter();
assert_eq!(app.filter().rows().len(), 1);
assert_eq!(app.filter().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_mut().set_pattern("readme");
app.apply_filter();
assert_eq!(app.filter().rows().len(), 1);
}
#[test]
fn test_apply_filter_preserves_selection_and_scroll() {
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::LeftOnly,
left: None,
right: None,
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("c.txt"),
name: "c.txt".to_string(),
state: DiffState::RightOnly,
left: None,
right: None,
},
];
app.apply_filter();
app.set_selected_idx(2);
app.set_scroll_offset(1);
app.viewport.visible_height = 10;
app.apply_filter();
assert_eq!(app.selected_idx(), 2);
assert_eq!(
app.filter().rows()[app.selected_idx()].relative_path,
PathBuf::from("c.txt")
);
assert_eq!(app.scroll_offset(), 1);
}
#[test]
fn test_apply_filter_resets_when_selection_filtered_out() {
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,
},
];
app.apply_filter();
app.set_selected_idx(0); app.set_scroll_offset(0);
app.filter_mut().toggle_diffs_only();
app.apply_filter();
assert_eq!(app.selected_idx(), 0);
assert_eq!(app.filter().rows()[0].name, "diff.txt");
assert_eq!(app.scroll_offset(), 0);
}
#[test]
fn test_flatten_tree_preserves_selection() {
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_a".to_string(),
relative_path: PathBuf::from("child_a"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
},
AlignedNode {
name: "child_b".to_string(),
relative_path: PathBuf::from("child_b"),
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();
app.set_selected_idx(2); app.set_scroll_offset(1);
app.viewport.visible_height = 10;
app.flatten_tree();
assert_eq!(app.selected_idx(), 2);
assert_eq!(app.flat_rows[app.selected_idx()].name, "child_b");
assert_eq!(app.scroll_offset(), 1);
}
#[test]
fn test_restore_expanded_paths_after_rescan() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let old_tree = 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: "subdir".to_string(),
relative_path: PathBuf::from("subdir"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "file.txt".to_string(),
relative_path: PathBuf::from("subdir/file.txt"),
left: Some(FileInfo {
is_dir: false,
size: 5,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: true,
}],
is_expanded: true,
};
app.root_node = Some(old_tree);
app.flatten_tree();
app.set_selected_idx(
app.filter()
.rows()
.iter()
.position(|r| r.relative_path == *"subdir/file.txt")
.unwrap(),
);
let expanded = app.collect_expanded_paths();
assert!(expanded.contains(&PathBuf::from("")));
assert!(expanded.contains(&PathBuf::from("subdir")));
let new_tree = 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: "subdir".to_string(),
relative_path: PathBuf::from("subdir"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "file.txt".to_string(),
relative_path: PathBuf::from("subdir/file.txt"),
left: Some(FileInfo {
is_dir: false,
size: 5,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: false,
}],
is_expanded: true,
};
app.root_node = Some(new_tree);
app.restore_expanded_paths(&expanded);
app.flatten_tree();
assert!(app
.filter()
.rows()
.iter()
.any(|r| r.relative_path == *"subdir/file.txt"));
assert_eq!(
app.filter().rows()[app.selected_idx()].relative_path,
PathBuf::from("subdir/file.txt")
);
}
#[test]
fn test_open_commit_cancel_filter() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.filter_mut().set_pattern("abc");
app.filter_mut().open();
assert!(app.filter().active());
assert_eq!(app.filter().input(), "abc");
for c in "def".chars() {
app.filter_mut().input_mut().insert(c);
}
assert_eq!(app.filter().input(), "abcdef");
app.filter_mut().cancel();
assert!(!app.filter().active());
assert_eq!(app.filter().input(), "abc");
assert_eq!(app.filter().pattern(), "abc");
app.filter_mut().open();
app.filter_mut().input_mut().set("xyz");
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_mut().set_pattern("a");
app.filter_mut().toggle_diffs_only();
app.apply_filter();
assert_eq!(app.filter().rows().len(), 0);
app.clear_filter();
assert!(app.filter().pattern().is_empty());
assert!(!app.filter().diffs_only());
assert_eq!(app.filter().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.set_view_mode(ViewMode::FileDiff);
app.help_mut().set_index_open(true); app.help_mut().set_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_open_help_while_already_on_help_does_not_trap_keyboard_exit() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(ViewMode::FileDiff);
app.open_help();
assert_eq!(app.help().return_view(), ViewMode::FileDiff);
app.open_help();
assert_eq!(app.help().return_view(), ViewMode::FileDiff);
assert_eq!(app.view_mode(), ViewMode::Help);
}
#[test]
fn test_open_help_index_syncs_selection_to_current_topic() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.open_help();
app.help_mut().set_topic(HelpTopic::Mouse);
app.help_mut().open_index();
assert!(app.help().index_open());
assert_eq!(app.help().index_sel(), 3);
}
#[test]
fn test_close_help_index_stays_on_help_and_closes_index_only() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.open_help();
app.help_mut().open_index();
assert!(app.help().index_open());
app.help_mut().close_index();
assert!(!app.help().index_open());
assert_eq!(
app.view_mode(),
ViewMode::Help,
"unlike close_help, closing just the index must not leave Help"
);
}
#[test]
fn test_open_config_remembers_return_view() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(ViewMode::FileDiff);
app.open_config();
assert_eq!(app.config().return_view(), ViewMode::FileDiff);
assert_eq!(app.view_mode(), ViewMode::ConfigMenu);
}
#[test]
fn test_open_config_while_already_on_config_does_not_trap_keyboard_exit() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(ViewMode::FileDiff);
app.open_config();
assert_eq!(app.config().return_view(), ViewMode::FileDiff);
app.open_config();
assert_eq!(app.config().return_view(), ViewMode::FileDiff);
assert_eq!(app.view_mode(), ViewMode::ConfigMenu);
}
#[test]
fn test_close_config_restores_return_view() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(ViewMode::FileDiff);
app.open_config();
assert_eq!(app.view_mode(), ViewMode::ConfigMenu);
app.close_config();
assert_eq!(app.view_mode(), ViewMode::FileDiff);
assert_eq!(app.config().return_view(), ViewMode::FileDiff);
}
#[test]
fn test_config_rows_and_navigation() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_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(), 11);
assert!(matches!(
rows[0],
ConfigRowKind::Header("External Diff Tool")
));
assert!(matches!(rows[1], ConfigRowKind::DiffTool(0)));
assert!(matches!(rows[2], ConfigRowKind::DiffTool(1)));
assert!(matches!(rows[3], ConfigRowKind::Header("Updates")));
assert!(matches!(rows[4], ConfigRowKind::CheckUpdates));
assert!(matches!(rows[5], ConfigRowKind::Header("Mouse")));
assert!(matches!(rows[6], ConfigRowKind::Mouse));
assert!(matches!(rows[7], ConfigRowKind::Header("Theme")));
assert!(matches!(rows[8], ConfigRowKind::Theme));
assert!(matches!(rows[9], ConfigRowKind::Header("Diff View")));
assert!(matches!(rows[10], ConfigRowKind::DiffContext));
app.config_mut().set_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(), 4);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 6);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 8);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 10);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 1);
app.config_select_prev();
assert_eq!(app.config().selected_idx(), 10);
}
#[test]
fn test_mouse_toggle_persists_in_settings() {
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_mouse_enabled(app.settings().mouse);
assert!(!app.settings().mouse);
assert!(!app.mouse_enabled());
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::Mouse))
.unwrap();
app.config_mut().set_selected_idx(idx);
app.apply_config_selection();
assert!(app.settings().mouse);
assert!(app.mouse_enabled());
app.apply_config_selection();
assert!(!app.settings().mouse);
assert!(!app.mouse_enabled());
}
#[test]
fn test_theme_toggle_persists_in_settings() {
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Light);
assert_eq!(app.theme(), crate::theme::Theme::LIGHT);
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::Theme))
.unwrap();
app.config_mut().set_selected_idx(idx);
app.apply_config_selection();
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Dark);
assert_eq!(app.theme(), crate::theme::Theme::DARK);
app.apply_config_selection();
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Light);
}
#[test]
fn test_diff_context_adjust_persists_and_clamps() {
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.settings().diff_context, 7);
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::DiffContext))
.unwrap();
app.config_mut().set_selected_idx(idx);
app.adjust_config_selection(true);
assert_eq!(app.settings().diff_context, 8);
app.adjust_config_selection(false);
app.adjust_config_selection(false);
assert_eq!(app.settings().diff_context, 6);
for _ in 0..10 {
app.adjust_config_selection(false);
}
assert_eq!(app.settings().diff_context, 0);
for _ in 0..60 {
app.adjust_config_selection(true);
}
assert_eq!(app.settings().diff_context, 50);
}
#[test]
fn test_adjust_config_selection_is_noop_for_non_numeric_rows() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::CheckUpdates))
.unwrap();
app.config_mut().set_selected_idx(idx);
let before = app.settings().diff_context;
app.adjust_config_selection(true);
assert_eq!(app.settings().diff_context, before);
}
#[test]
fn test_apply_incremental_rescan_nested_file() {
use std::fs::{create_dir_all, write};
use tempfile::tempdir;
let left = tempdir().unwrap();
let right = tempdir().unwrap();
create_dir_all(left.path().join("nested")).unwrap();
create_dir_all(right.path().join("nested")).unwrap();
write(left.path().join("nested/a.txt"), "left").unwrap();
write(right.path().join("nested/a.txt"), "right-old").unwrap();
write(left.path().join("nested/b.txt"), "only-left").unwrap();
let root = crate::diff::align_directories(
left.path(),
right.path(),
std::path::Path::new(""),
false,
&IgnoreMatcher::default(),
)
.unwrap();
let mut app = App::new(left.path().to_path_buf(), right.path().to_path_buf());
app.root_node = Some(root);
app.restore_expanded_paths(&[PathBuf::from(""), PathBuf::from("nested")]);
app.flatten_tree();
let before_len = app.flat_rows.len();
write(right.path().join("nested/b.txt"), "only-left").unwrap();
app.apply_incremental_rescan(std::path::Path::new("nested/b.txt"), false)
.expect("nested incremental rescan");
assert!(
app.flat_rows
.iter()
.any(|r| r.relative_path == *"nested/b.txt"
&& r.left.is_some()
&& r.right.is_some()),
"copied file should appear on both sides after incremental rescan"
);
assert!(app.flat_rows.len() >= before_len);
assert!(app
.root_node
.as_ref()
.unwrap()
.children
.iter()
.any(|c| c.name == "nested"));
}
#[test]
fn test_check_updates_toggle_persists_in_settings() {
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_update_check_enabled(app.settings().check_updates);
assert!(!app.settings().check_updates);
assert!(!app.update_check_enabled());
app.open_config();
while !matches!(
app.config_rows().get(app.config().selected_idx()),
Some(ConfigRowKind::CheckUpdates)
) {
app.config_select_next();
}
app.apply_config_selection();
assert!(app.settings().check_updates);
assert!(app.update_check_enabled());
app.apply_config_selection();
assert!(!app.settings().check_updates);
assert!(!app.update_check_enabled());
}
#[test]
fn test_config_tests_never_touch_real_config_file() {
let _lock = lock_env_tests();
let real_path = crate::settings::AppSettings::config_search_paths()
.into_iter()
.next();
let snapshot = |p: &Option<PathBuf>| {
p.as_ref().map(|p| {
(
p.exists(),
std::fs::metadata(p).ok().and_then(|m| m.modified().ok()),
)
})
};
let before = snapshot(&real_path);
{
let _redirect = RedirectedConfigDir::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_mouse_enabled(app.settings().mouse);
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::Mouse))
.unwrap();
app.config_mut().set_selected_idx(idx);
app.apply_config_selection();
app.apply_config_selection();
}
let after = snapshot(&real_path);
assert_eq!(
before, after,
"exercising the config save path must not modify the real config file"
);
}
#[test]
fn test_first_run_detect_does_not_require_saved_config() {
let app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let _ = app.settings().external_diff_tool;
}
#[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_toggle_active_side_flips_focus() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.active_side_left(), "starts on left");
app.toggle_active_side();
assert!(!app.active_side_left());
app.toggle_active_side();
assert!(app.active_side_left());
app.set_active_side_left(false);
assert!(!app.active_side_left());
app.toggle_active_side();
assert!(app.active_side_left());
}
#[test]
fn test_request_quit_sets_should_quit() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.should_quit());
app.request_quit();
assert!(app.should_quit());
}
#[test]
fn test_toggle_precise_mode_flips_flag_only() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.precise_mode());
app.toggle_precise_mode();
assert!(app.precise_mode());
app.toggle_precise_mode();
assert!(!app.precise_mode());
app.set_precise_mode(true);
assert!(app.precise_mode());
}
#[test]
fn test_build_palette_actions_directory_tree() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::DirectoryTree);
let actions = app.build_palette_actions();
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::Quit));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::Help));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::Refresh));
}
#[test]
fn test_build_palette_actions_file_diff() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
let actions = app.build_palette_actions();
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::ToggleWrap));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::ToggleFullDiff));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::NextChange));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::PrevChange));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::CopyHunkLeftToRight));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::CopyHunkRightToLeft));
}
#[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.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_show_full(true);
app.refresh_file_diff().expect("diff should load");
app.diff_mut().set_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.viewport.diff_content_width = 40;
app.diff_mut().set_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);
}
fn flat_row(name: &str) -> FlatRow {
FlatRow {
depth: 0,
relative_path: PathBuf::from(name),
name: name.to_string(),
state: DiffState::Identical,
left: None,
right: None,
}
}
fn dir_node(name: &str) -> AlignedNode {
AlignedNode {
name: name.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![],
is_expanded: true,
}
}
fn equal_row(text: &str) -> crate::diff_view::DiffRow {
crate::diff_view::DiffRow::from((
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Equal,
text: text.to_string(),
}),
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Equal,
text: text.to_string(),
}),
))
}
fn deleted_row(text: &str) -> crate::diff_view::DiffRow {
crate::diff_view::DiffRow::from((
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Delete,
text: text.to_string(),
}),
None,
))
}
#[test]
fn test_diff_rows_accessor_reflects_set_rows() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.diff().rows().is_empty());
let rows = vec![equal_row("a"), equal_row("b")];
app.diff_mut().set_rows(rows.clone());
assert_eq!(app.diff().rows(), rows.as_slice());
}
#[test]
fn test_filter_rows_accessor_reflects_set_rows() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.filter().rows().is_empty());
let rows = vec![flat_row("a.txt"), flat_row("b.txt")];
app.filter_mut().set_rows(rows.clone());
assert_eq!(app.filter().rows().len(), 2);
assert_eq!(app.filter().rows()[0].name, "a.txt");
assert_eq!(app.filter().rows()[1].name, "b.txt");
}
#[test]
fn test_selected_row_none_when_empty_or_out_of_range() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.selected_row().is_none());
app.filter_mut().set_rows(vec![flat_row("a.txt")]);
app.set_selected_idx(0);
assert_eq!(app.selected_row().map(|r| r.name.as_str()), Some("a.txt"));
app.set_selected_idx(1);
assert!(app.selected_row().is_none());
}
#[test]
fn test_open_close_hide_palette_lifecycle() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.palette_visible());
app.open_palette_command();
assert!(app.palette_visible());
assert_eq!(app.palette().mode, Some(PaletteMode::Command));
app.palette_type_char('x');
assert_eq!(app.palette().query, "x");
app.hide_palette();
assert!(!app.palette_visible());
assert_eq!(app.palette().query, "x");
app.open_palette_menu();
assert!(app.palette_visible());
assert_eq!(app.palette().mode, Some(PaletteMode::Menu));
assert!(app.palette().query.is_empty(), "open clears query");
app.palette_type_char('y'); app.close_palette();
assert!(!app.palette_visible());
assert!(app.palette().query.is_empty(), "close clears query");
}
#[test]
fn test_palette_select_next_prev_wraps() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.open_palette_menu();
app.set_palette_items(vec![
crate::ui::PaletteAction {
key: "a".into(),
label: "A".into(),
action_id: crate::ui::PaletteActionId::Help,
enabled: true,
},
crate::ui::PaletteAction {
key: "b".into(),
label: "B".into(),
action_id: crate::ui::PaletteActionId::Quit,
enabled: true,
},
]);
app.set_palette_selected_idx(0);
app.palette_select_next();
assert_eq!(app.palette().selected_idx, 1);
app.palette_select_next();
assert_eq!(app.palette().selected_idx, 0, "wraps around");
app.palette_select_prev();
assert_eq!(app.palette().selected_idx, 1, "wraps backward");
}
#[test]
fn test_refresh_palette_items_filters_command_query() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::DirectoryTree);
app.open_palette_command();
app.palette_type_char('q'); app.palette_type_char('u');
app.palette_type_char('i');
app.palette_type_char('t');
app.refresh_palette_items();
assert!(
app.palette()
.items
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::Quit),
"query \"quit\" should keep the quit action"
);
assert!(
app.palette()
.items
.iter()
.all(|a| a.label.to_lowercase().contains("quit")
|| a.key.to_lowercase().contains("quit")),
"every remaining item must match the query"
);
}
#[test]
fn test_diff_has_changes_false_when_all_rows_equal() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.diff_mut()
.set_rows(vec![equal_row("a"), equal_row("b")]);
assert!(!app.diff().has_changes());
}
#[test]
fn test_diff_has_changes_true_when_a_row_differs() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.diff_mut()
.set_rows(vec![equal_row("a"), deleted_row("b")]);
assert!(app.diff().has_changes());
}
#[test]
fn test_sync_viewport_tree_derives_visible_height() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert_eq!(app.viewport().visible_height, 20);
app.set_status("copied", false);
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert_eq!(app.viewport().visible_height, 19);
}
#[test]
fn test_sync_viewport_tree_keeps_selection_visible() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.flat_rows = (0..40).map(|i| flat_row(&format!("f{i}.txt"))).collect();
app.apply_filter();
app.set_selected_idx(30);
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert_eq!(app.viewport().visible_height, 20);
assert_eq!(app.scroll_offset(), 11, "selection scrolled into view");
}
#[test]
fn test_sync_viewport_diff_derives_geometry_from_area() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_rows(vec![equal_row(&"a".repeat(100))]);
app.sync_viewport(Rect::new(0, 0, 80, 24));
let viewport = app.viewport();
assert_eq!(viewport.visible_height, 19);
assert_eq!(viewport.diff_content_width, 38);
assert_eq!(viewport.diff_max_line_width, 100);
assert_eq!(
viewport.diff_physical_rows, 1,
"no wrapping: one logical row is one physical row"
);
}
#[test]
fn test_sync_viewport_diff_counts_wrapped_rows() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut()
.set_rows(vec![equal_row(&"a".repeat(100)), equal_row("short")]);
app.diff_mut().set_wrap(true);
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert_eq!(app.viewport().diff_physical_rows, 4);
app.sync_viewport(Rect::new(0, 0, 40, 24));
let viewport = app.viewport();
assert_eq!(viewport.diff_content_width, 18);
assert_eq!(viewport.diff_physical_rows, 7);
}
#[test]
fn test_sync_viewport_after_resize_clamps_diff_paging() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut()
.set_rows((0..40).map(|i| equal_row(&format!("line {i}"))).collect());
app.sync_viewport(Rect::new(0, 0, 80, 24));
app.diff_page_down();
assert_eq!(app.diff().scroll(), 18, "page step is visible_height - 1");
app.sync_viewport(Rect::new(0, 0, 80, 40));
assert_eq!(app.viewport().visible_height, 35);
assert_eq!(app.diff().scroll(), 5, "clamped to 40 rows - 35 visible");
app.diff_page_down();
assert_eq!(app.diff().scroll(), 5, "already at the bottom, stays put");
}
#[test]
fn test_sync_viewport_clamps_horizontal_scroll_to_longest_line() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_rows(vec![equal_row(&"a".repeat(100))]);
app.sync_viewport(Rect::new(0, 0, 80, 24));
let max_h_scroll = app.viewport().max_diff_h_scroll();
app.diff_mut().set_h_scroll(max_h_scroll);
assert_eq!(app.diff().h_scroll(), 62, "100 chars less the 38 on screen");
app.diff_mut().set_rows(vec![equal_row(&"a".repeat(50))]);
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert_eq!(app.diff().h_scroll(), 12);
}
#[test]
fn test_sync_viewport_ignores_help_and_config_views() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.sync_viewport(Rect::new(0, 0, 80, 24));
let tree_viewport = app.viewport();
app.open_help();
app.sync_viewport(Rect::new(0, 0, 120, 60));
assert_eq!(
app.viewport(),
tree_viewport,
"Help scrolls by its own drawn lines and must not disturb list geometry"
);
}
#[test]
fn test_apply_scan_result_updates_tree_flag_and_rows_together() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let generation = app.begin_scan();
assert!(app.scan_in_progress());
assert!(app.apply_scan_result(generation, dir_node("root")));
assert!(!app.scan_in_progress(), "scan is no longer in flight");
assert_eq!(app.flat_rows().len(), 1);
assert_eq!(app.flat_rows()[0].name, "root");
assert_eq!(app.filter().rows().len(), 1, "filter view rebuilt too");
}
#[test]
fn test_apply_scan_result_ignores_stale_generation() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let stale = app.begin_scan();
app.apply_scan_result(stale, dir_node("first"));
app.begin_scan();
assert!(!app.apply_scan_result(stale, dir_node("stale")));
assert_eq!(app.flat_rows()[0].name, "first", "tree left untouched");
assert!(app.scan_in_progress(), "still waiting for the newer scan");
}
#[test]
fn test_apply_scan_result_restores_expanded_directories() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let mut node = dir_node("root");
node.children.push(AlignedNode {
name: "sub".to_string(),
relative_path: PathBuf::from("sub"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "leaf.txt".to_string(),
relative_path: PathBuf::from("sub/leaf.txt"),
left: Some(FileInfo {
is_dir: false,
size: 1,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
}],
is_expanded: true,
});
let generation = app.begin_scan();
app.apply_scan_result(generation, node.clone());
assert_eq!(app.flat_rows().len(), 3);
let mut collapsed = node;
collapsed.children[0].is_expanded = false;
let generation = app.begin_scan();
app.apply_scan_result(generation, collapsed);
assert_eq!(app.flat_rows().len(), 3, "sub stayed expanded");
}
#[test]
fn test_fail_scan_clears_flag_only_for_current_generation() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let stale = app.begin_scan();
app.begin_scan();
assert!(!app.fail_scan(stale));
assert!(app.scan_in_progress(), "stale failure changes nothing");
let current = app.scan_generation;
assert!(app.fail_scan(current));
assert!(!app.scan_in_progress());
}
#[test]
fn test_apply_update_check_outcome_updates_hint_state_per_outcome() {
let prior = crate::upgrade::state_path()
.ok()
.map(|path| (path.clone(), crate::upgrade::load_state(&path)));
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.apply_update_check_outcome(crate::upgrade::UpdateCheckOutcome::Newer(
"0.9.0".to_string(),
));
assert_eq!(app.update_available(), Some("0.9.0"));
app.apply_update_check_outcome(crate::upgrade::UpdateCheckOutcome::UpToDate);
assert_eq!(app.update_available(), None);
app.set_update_available(Some("0.7.0".to_string()));
app.apply_update_check_outcome(crate::upgrade::UpdateCheckOutcome::Failed);
assert_eq!(
app.update_available(),
Some("0.7.0"),
"Failed must stay silent and leave the previous hint alone"
);
if let Some((path, state)) = prior {
crate::upgrade::save_state(&path, &state);
}
}
#[test]
fn test_request_confirm_opens_modal_with_message_and_action() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(app.confirm_modal().is_none());
app.request_confirm(
"Copy foo.txt to right side?",
ConfirmAction::CopyLeftToRight,
);
let modal = app.confirm_modal().expect("modal should be open");
assert_eq!(modal.message, "Copy foo.txt to right side?");
assert_eq!(modal.action, ConfirmAction::CopyLeftToRight);
}
#[test]
fn test_request_copy_left_to_right_opens_modal_when_left_present() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_flat_rows(vec![{
let mut row = flat_row_with_sides(Some(file_info(false)), None);
row.name = "foo.txt".to_string();
row
}]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(ConfirmAction::CopyLeftToRight);
let modal = app.confirm_modal().expect("modal should be open");
assert_eq!(modal.message, "Copy 'foo.txt' to right side?");
assert_eq!(modal.action, ConfirmAction::CopyLeftToRight);
}
#[test]
fn test_request_copy_right_to_left_opens_modal_when_right_present() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_flat_rows(vec![{
let mut row = flat_row_with_sides(None, Some(file_info(false)));
row.name = "bar.txt".to_string();
row
}]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(ConfirmAction::CopyRightToLeft);
let modal = app.confirm_modal().expect("modal should be open");
assert_eq!(modal.message, "Copy 'bar.txt' to left side?");
assert_eq!(modal.action, ConfirmAction::CopyRightToLeft);
}
#[test]
fn test_request_copy_is_a_noop_when_the_source_side_is_missing() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_flat_rows(vec![flat_row_with_sides(None, Some(file_info(false)))]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(ConfirmAction::CopyLeftToRight);
assert!(app.confirm_modal().is_none());
}
#[test]
fn test_request_copy_is_a_noop_when_nothing_is_selected() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.request_copy(ConfirmAction::CopyLeftToRight);
assert!(app.confirm_modal().is_none());
}
#[test]
fn test_take_confirmed_action_closes_modal_and_returns_action() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.request_confirm("Copy foo.txt to left side?", ConfirmAction::CopyRightToLeft);
let action = app.take_confirmed_action();
assert_eq!(action, Some(ConfirmAction::CopyRightToLeft));
assert!(app.confirm_modal().is_none(), "modal closes after taking");
}
#[test]
fn test_take_confirmed_action_returns_none_when_no_modal_open() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.take_confirmed_action(), None);
}
#[test]
fn test_dismiss_confirm_closes_modal_and_discards_action() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.request_confirm(
"Copy foo.txt to right side?",
ConfirmAction::CopyLeftToRight,
);
app.dismiss_confirm();
assert!(app.confirm_modal().is_none());
}
#[test]
fn test_toggle_diffs_only_flips_the_flag() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.filter().diffs_only());
app.filter_mut().toggle_diffs_only();
assert!(app.filter().diffs_only());
app.filter_mut().toggle_diffs_only();
assert!(!app.filter().diffs_only());
}
#[test]
fn test_filter_input_mut_allows_key_by_key_editing() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.filter_mut().input_mut().insert('a');
app.filter_mut().input_mut().insert('b');
assert_eq!(app.filter().input(), "ab");
}
}