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 left_name: Option<String>,
pub right_name: Option<String>,
pub left_relative_path: Option<PathBuf>,
pub right_relative_path: Option<PathBuf>,
pub state: DiffState,
pub left: Option<FileInfo>,
pub right: Option<FileInfo>,
pub has_case_conflict: bool,
pub contains_case_conflict: bool,
pub is_ambiguous_case_collision: bool,
}
impl Default for FlatRow {
fn default() -> Self {
Self {
depth: 0,
relative_path: PathBuf::new(),
name: String::new(),
left_name: None,
right_name: None,
left_relative_path: None,
right_relative_path: None,
state: DiffState::Identical,
left: None,
right: None,
has_case_conflict: false,
contains_case_conflict: false,
is_ambiguous_case_collision: false,
}
}
}
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)
}
pub fn left_relative_path(&self) -> &Path {
self.left_relative_path
.as_deref()
.or(if self.left.is_some() {
Some(&self.relative_path)
} else {
None
})
.unwrap_or(&self.relative_path)
}
pub fn right_relative_path(&self) -> &Path {
self.right_relative_path
.as_deref()
.or(if self.right.is_some() {
Some(&self.relative_path)
} else {
None
})
.unwrap_or(&self.relative_path)
}
pub fn left_name(&self) -> &str {
self.left_name
.as_deref()
.or(if self.left.is_some() {
Some(&self.name)
} else {
None
})
.unwrap_or(&self.name)
}
pub fn right_name(&self) -> &str {
self.right_name
.as_deref()
.or(if self.right.is_some() {
Some(&self.name)
} else {
None
})
.unwrap_or(&self.name)
}
}
#[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),
DiffToolAuto,
DiffToolDisabled,
DiffTool {
idx: usize,
available: bool,
},
DiffToolUnknown,
CheckUpdates,
Mouse,
Theme,
DiffContext,
ScanMode,
RespectGitignore,
GlobalExclusions,
IgnoreSources,
}
#[derive(Clone, Debug, Default)]
pub struct ExclusionEditorState {
draft: Vec<String>,
selected_idx: usize,
scroll_offset: usize,
input: crate::text_input::TextInput,
editing: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExclusionEditorAction {
None,
Apply,
Cancel,
}
impl ExclusionEditorState {
fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> ExclusionEditorAction {
use crossterm::event::{KeyCode, KeyModifiers};
if self.editing {
match key.code {
KeyCode::Enter => self.finish_edit(),
KeyCode::Esc => self.editing = false,
code => self.input.apply_edit(code),
}
return ExclusionEditorAction::None;
}
match key.code {
KeyCode::Esc => return ExclusionEditorAction::Cancel,
KeyCode::Char('a') => self.add(),
KeyCode::Enter => self.begin_edit(),
KeyCode::Char('d') => self.delete(),
KeyCode::Char('r') => self.restore_defaults(),
KeyCode::Char('j') | KeyCode::Down => self.select_next(),
KeyCode::Char('k') | KeyCode::Up => self.select_prev(),
KeyCode::Char('J') => self.move_down(),
KeyCode::Char('K') => self.move_up(),
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return ExclusionEditorAction::Apply;
}
_ => {}
}
ExclusionEditorAction::None
}
fn add(&mut self) {
self.draft.push(String::new());
self.selected_idx = self.draft.len() - 1;
self.input.clear();
self.editing = true;
}
fn begin_edit(&mut self) {
if let Some(pattern) = self.draft.get(self.selected_idx) {
self.input.set(pattern.clone());
self.editing = true;
}
}
fn finish_edit(&mut self) {
if let Some(entry) = self.draft.get_mut(self.selected_idx) {
*entry = self.input.to_string();
}
self.editing = false;
}
fn delete(&mut self) {
if self.draft.is_empty() {
return;
}
self.draft.remove(self.selected_idx);
self.selected_idx = self.selected_idx.min(self.draft.len().saturating_sub(1));
}
fn restore_defaults(&mut self) {
self.draft = crate::settings::AppSettings::default().global_exclusions;
self.selected_idx = 0;
self.editing = false;
self.input.clear();
}
fn select_next(&mut self) {
if !self.draft.is_empty() {
self.selected_idx = (self.selected_idx + 1) % self.draft.len();
}
}
fn select_prev(&mut self) {
if !self.draft.is_empty() {
self.selected_idx = self
.selected_idx
.checked_sub(1)
.unwrap_or(self.draft.len() - 1);
}
}
fn move_down(&mut self) {
if self.selected_idx + 1 < self.draft.len() {
self.draft.swap(self.selected_idx, self.selected_idx + 1);
self.selected_idx += 1;
}
}
fn move_up(&mut self) {
if self.selected_idx > 0 {
self.draft.swap(self.selected_idx, self.selected_idx - 1);
self.selected_idx -= 1;
}
}
}
impl ConfigRowKind {
pub fn is_selectable(self) -> bool {
!matches!(
self,
ConfigRowKind::Header(_)
| ConfigRowKind::IgnoreSources
| ConfigRowKind::DiffToolUnknown
| ConfigRowKind::DiffTool {
available: false,
..
}
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfirmAction {
CopyLeftToRight,
CopyRightToLeft,
SaveStaged,
SaveStagedThenLeave,
DiscardStagedThenLeave,
ReloadDiscardStaged,
Cancel,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConfirmModal {
pub title: String,
pub lines: Vec<String>,
pub choices: Vec<ConfirmChoice>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConfirmChoice {
pub key: char,
pub label: String,
pub action: ConfirmAction,
}
impl ConfirmModal {
pub fn action_for_key(&self, typed: char) -> Option<ConfirmAction> {
let typed = typed.to_ascii_lowercase();
self.choices
.iter()
.find(|c| c.key.to_ascii_lowercase() == typed)
.map(|c| c.action.clone())
}
pub fn default_action(&self) -> Option<ConfirmAction> {
self.choices.first().map(|c| c.action.clone())
}
pub fn cancel_action(&self) -> Option<ConfirmAction> {
self.choices
.iter()
.find(|c| c.action == ConfirmAction::Cancel)
.map(|c| c.action.clone())
}
}
#[derive(Clone, Debug, Default)]
pub struct PaletteState {
pub visible: bool,
pub query: String,
pub items: Vec<crate::ui::PaletteAction>,
pub selected_idx: usize,
pub scroll_offset: 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,
draft_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 editing_diffs_only(&self) -> bool {
if self.active {
self.draft_diffs_only
} else {
self.diffs_only
}
}
pub(crate) fn toggle_diffs_only(&mut self) {
self.draft_diffs_only = !self.draft_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());
self.draft_diffs_only = self.diffs_only;
}
pub(crate) fn commit(&mut self) {
self.active = false;
self.pattern = self.input.to_string();
self.diffs_only = self.draft_diffs_only;
}
pub(crate) fn cancel(&mut self) {
self.active = false;
self.input.set(self.pattern.clone());
self.draft_diffs_only = self.diffs_only;
}
pub(crate) fn clear(&mut self) {
self.pattern.clear();
self.input.clear();
self.diffs_only = false;
self.draft_diffs_only = false;
}
pub(crate) fn recompute_from_tree(&mut self, root: Option<&AlignedNode>, source: &[FlatRow]) {
let pattern = &self.pattern;
let diffs_only = self.diffs_only;
if pattern.is_empty() && !diffs_only {
self.rows = source.to_vec();
return;
}
if let Some(root_node) = root {
let mut matches = Vec::new();
collect_matching_rows(root_node, pattern, diffs_only, &mut matches);
self.rows = matches;
} else {
let norm_pattern = crate::diff::normalize_for_matching(pattern);
self.rows = source
.iter()
.filter(|row| {
if diffs_only
&& row.state == DiffState::Identical
&& !row.has_case_conflict
&& !row.contains_case_conflict
&& !row.is_ambiguous_case_collision
{
return false;
}
if pattern.is_empty() {
return true;
}
let norm_name = crate::diff::normalize_for_matching(&row.name);
let norm_path =
crate::diff::normalize_for_matching(&row.relative_path.to_string_lossy());
let norm_l_name = row
.left_name
.as_ref()
.map(|n| crate::diff::normalize_for_matching(n));
let norm_r_name = row
.right_name
.as_ref()
.map(|n| crate::diff::normalize_for_matching(n));
let norm_l_path = row
.left_relative_path
.as_ref()
.map(|p| crate::diff::normalize_for_matching(&p.to_string_lossy()));
let norm_r_path = row
.right_relative_path
.as_ref()
.map(|p| crate::diff::normalize_for_matching(&p.to_string_lossy()));
norm_name.contains(&norm_pattern)
|| norm_path.contains(&norm_pattern)
|| norm_l_name
.as_ref()
.is_some_and(|n| n.contains(&norm_pattern))
|| norm_r_name
.as_ref()
.is_some_and(|n| n.contains(&norm_pattern))
|| norm_l_path
.as_ref()
.is_some_and(|p| p.contains(&norm_pattern))
|| norm_r_path
.as_ref()
.is_some_and(|p| p.contains(&norm_pattern))
})
.cloned()
.collect();
}
}
#[allow(dead_code)]
pub(crate) fn recompute(&mut self, source: &[FlatRow]) {
self.recompute_from_tree(None, source);
}
#[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();
}
}
fn collect_matching_rows(
root: &AlignedNode,
pattern: &str,
diffs_only: bool,
out: &mut Vec<FlatRow>,
) {
let norm_pattern = crate::diff::normalize_for_matching(pattern);
for child in &root.children {
collect_matching_rows_rec(child, pattern, &norm_pattern, diffs_only, out);
}
}
fn collect_matching_rows_rec(
node: &AlignedNode,
pattern: &str,
norm_pattern: &str,
diffs_only: bool,
out: &mut Vec<FlatRow>,
) {
let diffs_match = if diffs_only {
node.state.is_known_difference()
|| node.has_case_conflict
|| node.contains_case_conflict
|| node.is_ambiguous_case_collision
} else {
true
};
let text_match = if pattern.is_empty() {
true
} else {
let norm_name = crate::diff::normalize_for_matching(&node.name);
let norm_path = crate::diff::normalize_for_matching(&node.relative_path.to_string_lossy());
let norm_l_name = node
.left_name
.as_ref()
.map(|n| crate::diff::normalize_for_matching(n));
let norm_r_name = node
.right_name
.as_ref()
.map(|n| crate::diff::normalize_for_matching(n));
let norm_l_path = node
.left_relative_path
.as_ref()
.map(|p| crate::diff::normalize_for_matching(&p.to_string_lossy()));
let norm_r_path = node
.right_relative_path
.as_ref()
.map(|p| crate::diff::normalize_for_matching(&p.to_string_lossy()));
norm_name.contains(norm_pattern)
|| norm_path.contains(norm_pattern)
|| norm_l_name
.as_ref()
.is_some_and(|n| n.contains(norm_pattern))
|| norm_r_name
.as_ref()
.is_some_and(|n| n.contains(norm_pattern))
|| norm_l_path
.as_ref()
.is_some_and(|p| p.contains(norm_pattern))
|| norm_r_path
.as_ref()
.is_some_and(|p| p.contains(norm_pattern))
};
if diffs_match && text_match {
out.push(FlatRow {
depth: 0,
relative_path: node.relative_path.clone(),
name: node.name.clone(),
left_name: node.left_name.clone(),
right_name: node.right_name.clone(),
left_relative_path: node.left_relative_path.clone(),
right_relative_path: node.right_relative_path.clone(),
state: node.state,
left: node.left.clone(),
right: node.right.clone(),
has_case_conflict: node.has_case_conflict,
contains_case_conflict: node.contains_case_conflict,
is_ambiguous_case_collision: node.is_ambiguous_case_collision,
});
}
for child in &node.children {
collect_matching_rows_rec(child, pattern, norm_pattern, diffs_only, out);
}
}
#[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>,
left: crate::diff_view::TextBuffer,
right: crate::diff_view::TextBuffer,
left_baseline: crate::diff_view::TextBuffer,
right_baseline: crate::diff_view::TextBuffer,
undo_stack: Vec<(crate::diff_view::TextBuffer, crate::diff_view::TextBuffer)>,
}
impl FileDiffState {
pub(crate) fn rows(&self) -> &[crate::diff_view::DiffRow] {
&self.rows
}
pub(crate) fn left_line_count(&self) -> usize {
self.left
.lines
.len()
.max(crate::diff_view::diff_side_line_count(&self.rows, true))
}
pub(crate) fn right_line_count(&self) -> usize {
self.right
.lines
.len()
.max(crate::diff_view::diff_side_line_count(&self.rows, false))
}
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> {
let left_text =
crate::diff_view::load_text_for_diff(left_file).map_err(|e| e.to_string())?;
let right_text =
crate::diff_view::load_text_for_diff(right_file).map_err(|e| e.to_string())?;
self.left = crate::diff_view::TextBuffer::from_text(&left_text);
self.right = crate::diff_view::TextBuffer::from_text(&right_text);
self.left_baseline = self.left.clone();
self.right_baseline = self.right.clone();
self.undo_stack.clear();
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);
self.recompute_rows(diff_context);
Ok(())
}
pub(crate) fn recompute_rows(&mut self, diff_context: usize) {
self.rows = crate::diff_view::compare_texts(
&self.left.to_text(),
&self.right.to_text(),
self.show_full,
diff_context,
);
}
pub(crate) fn left_buffer(&self) -> &crate::diff_view::TextBuffer {
&self.left
}
pub(crate) fn right_buffer(&self) -> &crate::diff_view::TextBuffer {
&self.right
}
pub(crate) fn left_dirty(&self) -> bool {
self.left != self.left_baseline
}
pub(crate) fn right_dirty(&self) -> bool {
self.right != self.right_baseline
}
pub(crate) fn is_dirty(&self) -> bool {
self.left_dirty() || self.right_dirty()
}
pub(crate) fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
pub(crate) fn stage_hunk(
&mut self,
hunk_index: usize,
direction: crate::diff_view::HunkCopyDirection,
diff_context: usize,
) -> Result<(), std::io::Error> {
let snapshot = (self.left.clone(), self.right.clone());
let rows = std::mem::take(&mut self.rows);
let result = crate::diff_view::stage_hunk_copy(
&mut self.left,
&mut self.right,
&rows,
hunk_index,
direction,
);
self.rows = rows;
match result {
Ok(()) => {
self.undo_stack.push(snapshot);
self.recompute_rows(diff_context);
Ok(())
}
Err(e) => {
self.left = snapshot.0;
self.right = snapshot.1;
Err(e)
}
}
}
pub(crate) fn undo_staged(&mut self, diff_context: usize) -> bool {
let Some((left, right)) = self.undo_stack.pop() else {
return false;
};
self.left = left;
self.right = right;
self.recompute_rows(diff_context);
true
}
pub(crate) fn discard_staged(&mut self, diff_context: usize) {
self.left = self.left_baseline.clone();
self.right = self.right_baseline.clone();
self.undo_stack.clear();
self.recompute_rows(diff_context);
}
pub(crate) fn left_baseline_text(&self) -> String {
self.left_baseline.to_text()
}
pub(crate) fn right_baseline_text(&self) -> String {
self.right_baseline.to_text()
}
pub(crate) fn commit_baselines(
&mut self,
left_hash: Option<String>,
right_hash: Option<String>,
) {
self.left_baseline = self.left.clone();
self.right_baseline = self.right.clone();
self.undo_stack.clear();
self.left_hash = left_hash;
self.right_hash = right_hash;
}
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,
scan_mode: crate::settings::ScanMode,
root_node: Option<AlignedNode>,
tree_summary: Option<crate::diff::TreeSummary>,
scan_in_progress: bool,
scan_progress_count: usize,
spinner_frame: usize,
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,
exclusion_editor: Option<ExclusionEditorState>,
palette: PaletteState,
confirm_modal: Option<ConfirmModal>,
status_message: Option<(String, bool, Instant)>,
filter: FilterState,
left_ignore_matcher: IgnoreMatcher,
right_ignore_matcher: IgnoreMatcher,
cli_exclusions: Vec<String>,
gitignore_override: Option<bool>,
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 {
let left_ignore = IgnoreMatcher::for_root(left.clone(), &[], true, &[])
.expect("empty ignore matcher is valid");
let right_ignore = IgnoreMatcher::for_root(right.clone(), &[], true, &[])
.expect("empty ignore matcher is valid");
Self::new_with_ignore(left, right, left_ignore, right_ignore)
}
pub fn new_with_ignore(
left: PathBuf,
right: PathBuf,
left_ignore_matcher: IgnoreMatcher,
right_ignore_matcher: IgnoreMatcher,
) -> Self {
let settings = crate::settings::AppSettings::load();
let detected_diff_tools = crate::diff_tool::detect_diff_tools();
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,
scan_mode: settings.scan_mode,
root_node: None,
tree_summary: None,
scan_in_progress: false,
scan_progress_count: 0,
spinner_frame: 0,
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(),
exclusion_editor: None,
palette: PaletteState::default(),
confirm_modal: None,
status_message: None,
filter: FilterState::default(),
left_ignore_matcher,
right_ignore_matcher,
cli_exclusions: Vec::new(),
gitignore_override: None,
update_check_enabled: true,
mouse_enabled: true,
update_available: None,
install_method,
help: HelpState::default(),
should_quit: false,
}
}
pub fn tick(&mut self) {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
}
pub fn spinner_frame(&self) -> usize {
self.spinner_frame
}
pub fn scan_progress_count(&self) -> usize {
self.scan_progress_count
}
pub fn set_scan_progress(&mut self, count: usize) {
self.scan_progress_count = count;
}
pub fn begin_scan(&mut self) -> u64 {
self.scan_generation = self.scan_generation.wrapping_add(1);
self.scan_in_progress = true;
self.scan_progress_count = 0;
self.scan_generation
}
pub fn scan_generation(&self) -> u64 {
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.scan_progress_count = 0;
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;
self.scan_progress_count = 0;
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;
let pane_inner = layout.left.width.saturating_sub(2) as usize;
self.viewport.diff_content_width = crate::diff_view::diff_text_width(
pane_inner,
self.diff.left_line_count(),
self.diff.right_line_count(),
);
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 scan_mode(&self) -> crate::settings::ScanMode {
self.scan_mode
}
pub fn saved_scan_mode(&self) -> crate::settings::ScanMode {
self.settings.scan_mode
}
pub fn scan_mode_is_session_override(&self) -> bool {
self.scan_mode != self.settings.scan_mode
}
pub(crate) fn set_scan_mode(&mut self, mode: crate::settings::ScanMode) {
self.scan_mode = mode;
}
pub fn apply_scan_mode(
&mut self,
mode: crate::settings::ScanMode,
) -> Result<(), std::io::Error> {
let previous = self.settings.scan_mode;
self.settings.scan_mode = mode;
if let Err(e) = self.settings.save() {
self.settings.scan_mode = previous;
return Err(e);
}
self.scan_mode = mode;
Ok(())
}
#[must_use]
pub fn switch_scan_mode(&mut self, mode: crate::settings::ScanMode) -> bool {
match self.apply_scan_mode(mode) {
Ok(()) => {
self.set_status(format!("Scan mode: {}", mode.label()), false);
true
}
Err(e) => {
self.set_status(format!("Could not save scan mode: {e}"), true);
false
}
}
}
pub fn precise_mode(&self) -> bool {
self.scan_mode.is_precise()
}
pub fn ignore_matchers(&self) -> (&IgnoreMatcher, &IgnoreMatcher) {
(&self.left_ignore_matcher, &self.right_ignore_matcher)
}
pub(crate) fn set_ignore_cli_overrides(
&mut self,
patterns: Vec<String>,
gitignore_override: Option<bool>,
) {
self.cli_exclusions = patterns;
self.gitignore_override = gitignore_override;
}
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 refresh_diff_tools(&mut self) {
self.detected_diff_tools = crate::diff_tool::detect_diff_tools();
}
pub fn resolve_auto_diff_tool(&self) -> Option<crate::diff_tool::ExternalDiffTool> {
self.detected_diff_tools
.iter()
.find(|(_, avail)| *avail)
.map(|(tool, _)| *tool)
}
pub fn resolve_effective_diff_tool(&self) -> Option<crate::diff_tool::ExternalDiffTool> {
match &self.settings.external_diff_tool {
crate::settings::DiffToolSetting::Auto => self.resolve_auto_diff_tool(),
crate::settings::DiffToolSetting::Disabled => None,
crate::settings::DiffToolSetting::Pinned(tool) => {
if self
.detected_diff_tools
.iter()
.any(|(t, avail)| t == tool && *avail)
{
Some(*tool)
} else {
None
}
}
crate::settings::DiffToolSetting::Unknown(_) => None,
}
}
pub fn config_rows(&self) -> Vec<ConfigRowKind> {
let mut rows = vec![ConfigRowKind::Header("External Diff Tool")];
rows.push(ConfigRowKind::DiffToolAuto);
rows.push(ConfigRowKind::DiffToolDisabled);
rows.extend(
self.detected_diff_tools
.iter()
.enumerate()
.map(|(i, (_, avail))| ConfigRowKind::DiffTool {
idx: i,
available: *avail,
}),
);
if self.settings.external_diff_tool.unknown_name().is_some() {
rows.push(ConfigRowKind::DiffToolUnknown);
}
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.push(ConfigRowKind::Header("Scan"));
rows.push(ConfigRowKind::ScanMode);
rows.push(ConfigRowKind::Header("Exclusions"));
rows.push(ConfigRowKind::RespectGitignore);
rows.push(ConfigRowKind::GlobalExclusions);
rows.push(ConfigRowKind::IgnoreSources);
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.refresh_diff_tools();
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)
}
#[must_use]
pub fn apply_config_selection(&mut self) -> bool {
let rows = self.config_rows();
match rows.get(self.config.selected_idx()) {
Some(ConfigRowKind::ScanMode) => {
return self.switch_scan_mode(self.scan_mode.toggled());
}
Some(ConfigRowKind::RespectGitignore) => {
self.settings.respect_gitignore = !self.settings.respect_gitignore;
let patterns = self.settings.global_exclusions.clone();
if let Err(error) = self.rebuild_ignore_matchers(&patterns) {
self.settings.respect_gitignore = !self.settings.respect_gitignore;
self.set_status(format!("Cannot rebuild exclusions: {error}"), true);
} else if let Err(error) = self.settings.save() {
self.set_status(format!("Cannot save configuration: {error}"), true);
} else {
return true;
}
}
Some(ConfigRowKind::GlobalExclusions) => self.open_exclusion_editor(),
Some(ConfigRowKind::DiffToolAuto) => {
self.settings.external_diff_tool = crate::settings::DiffToolSetting::Auto;
let _ = self.settings.save();
}
Some(ConfigRowKind::DiffToolDisabled) => {
self.settings.external_diff_tool = crate::settings::DiffToolSetting::Disabled;
let _ = self.settings.save();
}
Some(ConfigRowKind::DiffTool {
idx,
available: true,
}) => {
if let Some((tool, _)) = self.detected_diff_tools.get(*idx) {
self.settings.external_diff_tool =
crate::settings::DiffToolSetting::Pinned(*tool);
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();
}
_ => {}
}
false
}
fn rebuild_ignore_matchers(&mut self, patterns: &[String]) -> Result<(), String> {
let respect_gitignore = crate::settings::resolve_respect_gitignore(
self.settings.respect_gitignore,
self.gitignore_override,
);
let left = IgnoreMatcher::for_root(
self.left_path.clone(),
patterns,
respect_gitignore,
&self.cli_exclusions,
)?;
let right = IgnoreMatcher::for_root(
self.right_path.clone(),
patterns,
respect_gitignore,
&self.cli_exclusions,
)?;
self.left_ignore_matcher = left;
self.right_ignore_matcher = right;
Ok(())
}
pub(crate) fn open_exclusion_editor(&mut self) {
self.exclusion_editor = Some(ExclusionEditorState {
draft: self.settings.global_exclusions.clone(),
..ExclusionEditorState::default()
});
}
pub(crate) fn exclusion_editor_open(&self) -> bool {
self.exclusion_editor.is_some()
}
pub(crate) fn exclusion_editor_view(&self) -> Option<crate::ui::ExclusionEditorView> {
self.exclusion_editor
.as_ref()
.map(|editor| crate::ui::ExclusionEditorView {
draft: editor.draft.clone(),
selected_idx: editor.selected_idx,
scroll_offset: editor.scroll_offset,
editing: editor.editing,
input: editor.input.clone(),
theme: self.theme(),
})
}
pub(crate) fn sync_exclusion_editor_viewport(&mut self, visible_rows: usize) {
let Some(editor) = self.exclusion_editor.as_mut() else {
return;
};
if visible_rows == 0 {
editor.scroll_offset = 0;
return;
}
let max_offset = editor.draft.len().saturating_sub(visible_rows);
if editor.selected_idx < editor.scroll_offset {
editor.scroll_offset = editor.selected_idx;
} else if editor.selected_idx >= editor.scroll_offset + visible_rows {
editor.scroll_offset = editor.selected_idx + 1 - visible_rows;
}
editor.scroll_offset = editor.scroll_offset.min(max_offset);
}
pub(crate) fn exclusion_editor_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
let Some(editor) = self.exclusion_editor.as_mut() else {
return false;
};
match editor.handle_key(key) {
ExclusionEditorAction::None => false,
ExclusionEditorAction::Cancel => {
self.exclusion_editor = None;
false
}
ExclusionEditorAction::Apply => self.apply_exclusion_editor(),
}
}
fn apply_exclusion_editor(&mut self) -> bool {
let draft = self
.exclusion_editor
.as_ref()
.expect("apply only while editor is open")
.draft
.clone();
let roots = [self.left_path.clone(), self.right_path.clone()];
for root in &roots {
if let Some((index, error)) = draft.iter().enumerate().find_map(|(index, pattern)| {
IgnoreMatcher::validate_patterns(root, std::slice::from_ref(pattern))
.err()
.map(|error| (index, error))
}) {
self.exclusion_editor
.as_mut()
.expect("editor remains open after invalid input")
.selected_idx = index;
self.set_status(format!("Invalid exclusion {}: {error}", index + 1), true);
return false;
}
}
if let Err(error) = self.rebuild_ignore_matchers(&draft) {
self.set_status(format!("Cannot rebuild exclusions: {error}"), true);
return false;
}
self.settings.global_exclusions = draft;
if let Err(error) = self.settings.save() {
self.set_status(format!("Cannot save configuration: {error}"), true);
return false;
}
self.exclusion_editor = None;
true
}
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_line_count: self.diff.left_line_count(),
right_line_count: self.diff.right_line_count(),
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(),
left_dirty: self.diff.left_dirty(),
right_dirty: self.diff.right_dirty(),
can_undo: self.diff.can_undo(),
}
}
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(),
is_filter_active: !self.filter.pattern().is_empty() || self.filter.diffs_only(),
}
}
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.editing_diffs_only(),
scan_in_progress: self.scan_in_progress(),
scan_progress_count: self.scan_progress_count,
spinner_frame: self.spinner_frame,
update_available: self.update_available(),
install_method: self.install_method(),
theme: self.theme(),
summary: self.tree_summary,
}
}
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(),
has_summary: self.tree_summary.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<'_> {
let respect_gitignore = crate::settings::resolve_respect_gitignore(
self.settings.respect_gitignore,
self.gitignore_override,
);
let project_sources = if respect_gitignore {
".gitignore + .duodiffignore"
} else {
".gitignore (off) + .duodiffignore"
};
crate::ui::ConfigView {
rows: self.config_rows(),
selected_idx: self.config.selected_idx(),
detected_diff_tools: &self.detected_diff_tools,
diff_tool_setting: &self.settings.external_diff_tool,
resolved_auto_tool: self.resolve_auto_diff_tool(),
check_updates: self.settings.check_updates,
mouse: self.settings.mouse,
theme_choice: self.settings.theme,
diff_context: self.settings.diff_context,
scan_mode: self.scan_mode,
saved_scan_mode: self.settings.scan_mode,
respect_gitignore,
global_exclusion_count: self.settings.global_exclusions.len(),
cli_exclusion_count: self.cli_exclusions.len(),
left_ignore_source: format!(
"{}/{}",
Self::display_path_with_home_tilde(&self.left_path),
project_sources
),
right_ignore_source: format!(
"{}/{}",
Self::display_path_with_home_tilde(&self.right_path),
project_sources
),
theme: self.theme(),
}
}
fn display_path_with_home_tilde(path: &Path) -> String {
if let Some(home) = crate::settings::AppSettings::home_dir() {
if let Ok(rest) = path.strip_prefix(&home) {
return if rest.as_os_str().is_empty() {
"~".to_string()
} else {
let rest = rest.to_string_lossy().replace('\\', "/");
format!("~/{rest}")
};
}
}
path.display().to_string()
}
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(),
scan_in_progress: self.scan_in_progress(),
scan_progress_count: self.scan_progress_count,
spinner_frame: self.spinner_frame,
theme: self.theme(),
}
}
pub(crate) fn confirm_view(&self) -> crate::ui::ConfirmView<'_> {
static NO_LINES: &[String] = &[];
static NO_CHOICES: &[ConfirmChoice] = &[];
crate::ui::ConfirmView {
title: self
.confirm_modal
.as_ref()
.map(|m| m.title.as_str())
.unwrap_or(""),
lines: self
.confirm_modal
.as_ref()
.map(|m| m.lines.as_slice())
.unwrap_or(NO_LINES),
choices: self
.confirm_modal
.as_ref()
.map(|m| m.choices.as_slice())
.unwrap_or(NO_CHOICES),
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.left_relative_path());
let right_file = self.right_path.join(row.right_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();
self.diff.recompute_rows(self.settings.diff_context);
self.resync_diff_geometry();
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 stage_hunk_at_cursor(
&mut self,
direction: crate::diff_view::HunkCopyDirection,
) -> Result<(), std::io::Error> {
if self.selected_row().is_none() {
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 hunk_start_row = crate::diff_view::diff_hunk_row_ranges(self.diff.rows())
.get(hunk_index)
.map(|r| r.start)
.unwrap_or(0);
self.diff
.stage_hunk(hunk_index, direction, self.settings.diff_context)?;
self.resync_diff_geometry();
self.select_hunk_after(hunk_start_row);
Ok(())
}
pub fn undo_staged_hunk(&mut self) -> bool {
if !self.diff.undo_staged(self.settings.diff_context) {
return false;
}
self.resync_diff_geometry();
self.clamp_diff_scroll();
true
}
fn select_hunk_after(&mut self, previous_row: usize) {
let width = self.viewport.diff_content_width.max(1);
let offsets =
crate::diff_view::diff_row_physical_offsets(self.diff.rows(), width, self.diff.wrap());
let next = crate::diff_view::diff_hunk_row_ranges(self.diff.rows())
.into_iter()
.find(|range| range.start >= previous_row)
.and_then(|range| offsets.get(range.start).copied());
let max_scroll = self.viewport.max_diff_scroll();
match next {
Some(offset) => self.diff.set_scroll(offset.min(max_scroll)),
None => self.diff.set_scroll(self.diff.scroll().min(max_scroll)),
}
}
pub fn scanned_subtree_entries(
&self,
relative_path: &Path,
from_left: bool,
) -> Option<Vec<(PathBuf, bool)>> {
let root = self.root_node.as_ref()?;
let node = find_node(root, relative_path)?;
let present = if from_left {
node.left.as_ref()
} else {
node.right.as_ref()
};
if !present.is_some_and(|f| f.is_dir) {
return None;
}
let mut entries = Vec::new();
collect_scanned_entries(node, relative_path, from_left, &mut entries);
Some(entries)
}
pub fn flatten_tree(&mut self) {
self.flat_rows.clear();
if let Some(root) = self.root_node.take() {
for child in &root.children {
self.flatten_node(child, 0);
}
self.root_node = Some(root);
}
self.tree_summary = self
.root_node
.as_ref()
.map(crate::diff::TreeSummary::from_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(),
left_name: node.left_name.clone(),
right_name: node.right_name.clone(),
left_relative_path: node.left_relative_path.clone(),
right_relative_path: node.right_relative_path.clone(),
state: node.state,
left: node.left.clone(),
right: node.right.clone(),
has_case_conflict: node.has_case_conflict,
contains_case_conflict: node.contains_case_conflict,
is_ambiguous_case_collision: node.is_ambiguous_case_collision,
});
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 {
for child in &root.children {
Self::collect_expanded_paths_node(child, &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 let Some(ref mut root) = self.root_node {
root.is_expanded = true;
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 left_path = self.left_path.clone();
let right_path = self.right_path.clone();
let precise_mode = self.precise_mode();
let new_node = crate::diff::align_directories_with_matchers(
&left_path,
&right_path,
&scan_rel,
precise_mode,
&mut self.left_ignore_matcher,
&mut self.right_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_from_tree(self.root_node.as_ref(), &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
|| r.left_relative_path.as_ref() == Some(&path)
|| r.right_relative_path.as_ref() == Some(&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 {
title: "Confirm Action".to_string(),
lines: vec![message.into()],
choices: vec![
ConfirmChoice {
key: 'y',
label: "Yes".to_string(),
action,
},
ConfirmChoice {
key: 'n',
label: "No (Cancel)".to_string(),
action: ConfirmAction::Cancel,
},
],
});
}
fn absolute_lexical(path: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
crate::actions::normalize_lexically(&joined)
}
pub fn request_copy(&mut self, direction: ConfirmAction) {
if self.view_mode == ViewMode::FileDiff && self.diff.is_dirty() {
self.set_status(
"Staged changes are unsaved — press s to save or Esc to review them first",
true,
);
return;
}
let Some(row) = self.selected_row() else {
return;
};
if row.relative_path.as_os_str().is_empty() {
return;
}
if row.is_ambiguous_case_collision {
self.set_status("Cannot copy: ambiguous case collision", true);
return;
}
let source_present = match direction {
ConfirmAction::CopyLeftToRight => row.left.is_some(),
ConfirmAction::CopyRightToLeft => row.right.is_some(),
_ => return,
};
if !source_present {
return;
}
if row.state == crate::diff::DiffState::Identical && !row.has_case_conflict {
self.set_status("Files are already identical — nothing to copy", false);
return;
}
let left_to_right = direction == ConfirmAction::CopyLeftToRight;
let src_rel = if left_to_right {
row.left_relative_path()
} else {
row.right_relative_path()
};
let dst_rel = if left_to_right {
row.right_relative_path()
} else {
row.left_relative_path()
};
let src_name = if left_to_right {
row.left_name()
} else {
row.right_name()
};
let dst_name = if left_to_right {
row.right_name()
} else {
row.left_name()
};
let source_is_dir = match direction {
ConfirmAction::CopyLeftToRight => row.left.as_ref().is_some_and(|f| f.is_dir),
_ => row.right.as_ref().is_some_and(|f| f.is_dir),
};
let (src_root, dst_root) = match direction {
ConfirmAction::CopyLeftToRight => (&self.left_path, &self.right_path),
_ => (&self.right_path, &self.left_path),
};
let src = Self::absolute_lexical(&src_root.join(src_rel));
let dst = Self::absolute_lexical(&dst_root.join(dst_rel));
let dst_meta = std::fs::symlink_metadata(&dst).ok();
let dst_is_dir = dst_meta
.as_ref()
.is_some_and(|m| m.file_type().is_dir() && !m.file_type().is_symlink());
let operation = if dst_meta.is_none() {
"Create"
} else if source_is_dir && dst_is_dir {
"Merge"
} else {
"Overwrite"
};
let mut lines = vec![
format!("{operation} {src_name}"),
String::new(),
format!("From: {}", Self::display_path_with_home_tilde(&src)),
format!("To: {}", Self::display_path_with_home_tilde(&dst)),
];
if row.has_case_conflict && src_name != dst_name {
lines.push(String::new());
lines.push(format!(
"Note: Casing mismatch ('{src_name}' vs '{dst_name}'). Destination spelling will be preserved."
));
}
if operation == "Merge" {
lines.push(String::new());
lines.push(
"Merges into the existing directory: colliding entries are overwritten, \
others are left in place. Only the entries this scan lists are copied."
.to_string(),
);
} else if operation == "Overwrite" {
lines.push(String::new());
lines.push("The destination already exists and will be replaced.".to_string());
}
self.confirm_modal = Some(ConfirmModal {
title: format!("{operation} — confirm copy"),
lines,
choices: vec![
ConfirmChoice {
key: 'y',
label: "Yes".to_string(),
action: direction,
},
ConfirmChoice {
key: 'n',
label: "No (Cancel)".to_string(),
action: ConfirmAction::Cancel,
},
],
});
}
#[allow(dead_code)]
pub fn take_confirmed_action(&mut self) -> Option<ConfirmAction> {
self.confirm_modal
.take()
.and_then(|modal| modal.default_action())
}
pub fn staged_save_targets(&self) -> Vec<PathBuf> {
let Some(row) = self.selected_row() else {
return Vec::new();
};
let mut targets = Vec::new();
if self.diff.left_dirty() {
targets.push(Self::absolute_lexical(
&self.left_path.join(&row.relative_path),
));
}
if self.diff.right_dirty() {
targets.push(Self::absolute_lexical(
&self.right_path.join(&row.relative_path),
));
}
targets
}
pub fn request_save_staged(&mut self, then_leave: bool) {
if !self.diff.is_dirty() {
self.set_status("No staged changes to save", false);
return;
}
let mut lines = vec!["Write the staged changes to:".to_string(), String::new()];
for target in self.staged_save_targets() {
lines.push(format!(" {}", Self::display_path_with_home_tilde(&target)));
}
self.confirm_modal = Some(ConfirmModal {
title: "Save staged changes".to_string(),
lines,
choices: vec![
ConfirmChoice {
key: 's',
label: "Save".to_string(),
action: if then_leave {
ConfirmAction::SaveStagedThenLeave
} else {
ConfirmAction::SaveStaged
},
},
ConfirmChoice {
key: 'c',
label: "Cancel".to_string(),
action: ConfirmAction::Cancel,
},
],
});
}
pub fn guard_staged_exit(&mut self) -> bool {
if !self.diff.is_dirty() {
return false;
}
let mut lines = vec![
"This file diff has staged changes that are not written yet.".to_string(),
String::new(),
];
for target in self.staged_save_targets() {
lines.push(format!(" {}", Self::display_path_with_home_tilde(&target)));
}
self.confirm_modal = Some(ConfirmModal {
title: "Staged changes not saved".to_string(),
lines,
choices: vec![
ConfirmChoice {
key: 's',
label: "Save".to_string(),
action: ConfirmAction::SaveStagedThenLeave,
},
ConfirmChoice {
key: 'd',
label: "Discard".to_string(),
action: ConfirmAction::DiscardStagedThenLeave,
},
ConfirmChoice {
key: 'c',
label: "Cancel".to_string(),
action: ConfirmAction::Cancel,
},
],
});
true
}
fn request_conflict_resolution(&mut self, conflicted: &[PathBuf]) {
let mut lines = vec![
"These files changed on disk since this diff was opened:".to_string(),
String::new(),
];
for path in conflicted {
lines.push(format!(" {}", Self::display_path_with_home_tilde(path)));
}
lines.push(String::new());
lines.push("Saving would overwrite those changes.".to_string());
self.confirm_modal = Some(ConfirmModal {
title: "Files changed on disk".to_string(),
lines,
choices: vec![
ConfirmChoice {
key: 'r',
label: "Reload & Discard Staged Changes".to_string(),
action: ConfirmAction::ReloadDiscardStaged,
},
ConfirmChoice {
key: 'c',
label: "Cancel".to_string(),
action: ConfirmAction::Cancel,
},
],
});
}
pub fn staged_conflicts(&self) -> Vec<PathBuf> {
let Some(row) = self.selected_row() else {
return Vec::new();
};
let mut conflicted = Vec::new();
if self.diff.left_dirty() {
let path = self.left_path.join(row.left_relative_path());
let on_disk = crate::diff::compute_file_sha256(&path).ok();
if on_disk.as_deref() != self.diff.left_hash() {
conflicted.push(Self::absolute_lexical(&path));
}
}
if self.diff.right_dirty() {
let path = self.right_path.join(row.right_relative_path());
let on_disk = crate::diff::compute_file_sha256(&path).ok();
if on_disk.as_deref() != self.diff.right_hash() {
conflicted.push(Self::absolute_lexical(&path));
}
}
conflicted
}
pub fn save_staged(&mut self) -> Result<bool, std::io::Error> {
if !self.diff.is_dirty() {
return Ok(true);
}
let conflicted = self.staged_conflicts();
if !conflicted.is_empty() {
self.request_conflict_resolution(&conflicted);
return Ok(false);
}
let Some(row) = self.selected_row() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"no file selected",
));
};
let left_rel = row.left_relative_path().to_path_buf();
let right_rel = row.right_relative_path().to_path_buf();
let mut writes: Vec<(PathBuf, String, String)> = Vec::new();
if self.diff.left_dirty() {
writes.push((
self.left_path.join(&left_rel),
self.diff.left_buffer().to_text(),
self.diff.left_baseline_text(),
));
}
if self.diff.right_dirty() {
writes.push((
self.right_path.join(&right_rel),
self.diff.right_buffer().to_text(),
self.diff.right_baseline_text(),
));
}
crate::actions::commit_all_or_nothing(&writes)?;
let left_file = self.left_path.join(&left_rel);
let right_file = self.right_path.join(&right_rel);
self.diff.commit_baselines(
crate::diff::compute_file_sha256(&left_file).ok(),
crate::diff::compute_file_sha256(&right_file).ok(),
);
self.diff.recompute_rows(self.settings.diff_context);
self.resync_diff_geometry();
self.clamp_diff_scroll();
Ok(true)
}
pub fn reload_discarding_staged(&mut self) -> Result<(), String> {
self.refresh_file_diff()?;
self.clamp_diff_scroll();
Ok(())
}
pub fn discard_staged(&mut self) {
self.diff.discard_staged(self.settings.diff_context);
self.resync_diff_geometry();
self.clamp_diff_scroll();
}
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.filter.rows().is_empty() && 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(&mut self) {
self.palette.visible = true;
self.palette.query.clear();
self.refresh_palette_items();
self.palette_select_first_enabled();
}
pub(crate) fn close_palette(&mut self) {
self.palette.visible = false;
self.palette.query.clear();
}
fn palette_select_first_enabled(&mut self) {
self.palette.selected_idx = self
.palette
.items
.iter()
.position(|a| a.enabled())
.unwrap_or(0);
self.palette.scroll_offset = 0;
}
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 sync_palette_viewport(&mut self, visible_rows: usize) {
if visible_rows == 0 {
self.palette.scroll_offset = 0;
return;
}
let max_offset = self.palette.items.len().saturating_sub(visible_rows);
if self.palette.selected_idx < self.palette.scroll_offset {
self.palette.scroll_offset = self.palette.selected_idx;
} else if self.palette.selected_idx >= self.palette.scroll_offset + visible_rows {
self.palette.scroll_offset = self.palette.selected_idx + 1 - visible_rows;
}
self.palette.scroll_offset = self.palette.scroll_offset.min(max_offset);
}
pub(crate) fn palette_type_char(&mut self, c: char) {
self.palette.query.push(c);
self.refresh_palette_items();
self.palette_select_first_enabled();
}
pub(crate) fn palette_backspace(&mut self) {
self.palette.query.pop();
self.refresh_palette_items();
self.palette_select_first_enabled();
}
pub(crate) fn refresh_palette_items(&mut self) {
let query = self.palette.query.to_lowercase();
self.palette.items = self
.build_palette_actions()
.into_iter()
.filter(|a| {
a.label.to_lowercase().contains(&query) || a.key.to_lowercase().contains(&query)
})
.collect();
if self.palette.selected_idx >= self.palette.items.len() {
self.palette.selected_idx = self.palette.items.len().saturating_sub(1);
}
}
pub(crate) fn palette(&self) -> &PaletteState {
&self.palette
}
pub(crate) fn palette_view(&self) -> crate::ui::PaletteView<'_> {
crate::ui::PaletteView {
items: &self.palette.items,
selected_idx: self.palette.selected_idx,
scroll_offset: self.palette.scroll_offset,
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> {
use crate::ui::{PaletteAction as A, PaletteActionId as Id};
let mut actions = Vec::new();
match self.view_mode {
ViewMode::DirectoryTree => {
let row = self.selected_row();
let has_row = row.is_some();
let is_dir = row.is_some_and(|r| r.is_dir());
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)
}
});
let reason = |specific: &'static str| {
if has_row {
specific
} else {
"no row is selected"
}
};
actions.push(A::gated(
"Enter",
"Open built-in Diff view",
Id::BuiltinDiff,
row.is_some_and(|r| !r.is_dir()),
reason("the selected row is a directory"),
));
let effective_diff_tool = self.resolve_effective_diff_tool();
let diff_tool_reason = if !is_file_pair {
"needs a file present on both sides"
} else {
match &self.settings.external_diff_tool {
crate::settings::DiffToolSetting::Disabled => "external diff is disabled",
crate::settings::DiffToolSetting::Auto => {
"no external diff tool is available"
}
crate::settings::DiffToolSetting::Pinned(_) => {
"external diff tool is not available"
}
crate::settings::DiffToolSetting::Unknown(_) => {
"external diff tool is not available"
}
}
};
actions.push(A::gated(
"D",
"Compare via External Diff Tool",
Id::ExternalDiff,
is_file_pair && effective_diff_tool.is_some(),
diff_tool_reason,
));
actions.push(A::gated(
"E",
"Edit via External Editor",
Id::ExternalEdit,
is_file_active,
"the focused pane has no file at this row",
));
let copy_left_enabled =
row.is_some_and(|r| r.left.is_some() && !r.is_ambiguous_case_collision);
let copy_left_reason = if row.is_some_and(|r| r.is_ambiguous_case_collision) {
"cannot copy: ambiguous case collision"
} else {
reason("nothing on the left side to copy")
};
actions.push(A::gated(
"R",
"Copy Left to Right",
Id::CopyLeftToRight,
copy_left_enabled,
copy_left_reason,
));
let copy_right_enabled =
row.is_some_and(|r| r.right.is_some() && !r.is_ambiguous_case_collision);
let copy_right_reason = if row.is_some_and(|r| r.is_ambiguous_case_collision) {
"cannot copy: ambiguous case collision"
} else {
reason("nothing on the right side to copy")
};
actions.push(A::gated(
"L",
"Copy Right to Left",
Id::CopyRightToLeft,
copy_right_enabled,
copy_right_reason,
));
actions.push(A::gated(
"l / Right",
"Expand selected directory",
Id::ExpandSelected,
is_dir,
reason("the selected row is not a directory"),
));
actions.push(A::gated(
"h / Left",
"Collapse selected directory",
Id::CollapseSelected,
is_dir,
reason("the selected row is not a directory"),
));
actions.push(A::new("Tab", "Switch focused pane", Id::ToggleFocus));
actions.push(A::new("1", "Focus Left pane", Id::FocusLeft));
actions.push(A::new("2", "Focus Right pane", Id::FocusRight));
actions.push(A::new("/", "Open Filter Input", Id::Filter));
actions.push(A::new("s", "Swap Left/Right Paths", Id::SwapPaths));
actions.push(A::new(
"c",
"Toggle Scan Mode (Fast/Precise)",
Id::ToggleScan,
));
actions.push(A::new("r", "Manual Re-scan / Refresh", Id::Refresh));
actions.push(A::new("T", "Toggle Light/Dark Theme", Id::ToggleTheme));
actions.push(A::new("C", "Edit Configuration", Id::Config));
actions.push(A::new("?", "Open Help Screen", Id::Help));
actions.push(A::new("q", "Quit duodiff", Id::Quit));
}
ViewMode::FileDiff => {
let has_changes = self.diff.has_changes();
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 no_changes = "the two sides have no differing lines";
actions.push(A::gated(
"N",
"Next Change",
Id::NextChange,
has_changes,
no_changes,
));
actions.push(A::gated(
"P",
"Previous Change",
Id::PrevChange,
has_changes,
no_changes,
));
actions.push(A::gated(
"]",
"Copy Change Block to Right",
Id::CopyHunkLeftToRight,
has_changes,
no_changes,
));
actions.push(A::gated(
"[",
"Copy Change Block to Left",
Id::CopyHunkRightToLeft,
has_changes,
no_changes,
));
actions.push(A::gated(
"R",
"Copy Whole File Left to Right",
Id::CopyLeftToRight,
row.is_some_and(|r| r.left.is_some() && !r.is_ambiguous_case_collision),
if row.is_some_and(|r| r.is_ambiguous_case_collision) {
"cannot copy: ambiguous case collision"
} else {
"nothing on the left side to copy"
},
));
actions.push(A::gated(
"L",
"Copy Whole File Right to Left",
Id::CopyRightToLeft,
row.is_some_and(|r| r.right.is_some() && !r.is_ambiguous_case_collision),
if row.is_some_and(|r| r.is_ambiguous_case_collision) {
"cannot copy: ambiguous case collision"
} else {
"nothing on the right side to copy"
},
));
let effective_diff_tool = self.resolve_effective_diff_tool();
let diff_tool_reason = if !is_file_pair {
"needs a file present on both sides"
} else {
match &self.settings.external_diff_tool {
crate::settings::DiffToolSetting::Disabled => "external diff is disabled",
crate::settings::DiffToolSetting::Auto => {
"no external diff tool is available"
}
crate::settings::DiffToolSetting::Pinned(_) => {
"external diff tool is not available"
}
crate::settings::DiffToolSetting::Unknown(_) => {
"external diff tool is not available"
}
}
};
actions.push(A::gated(
"D",
"Compare via External Diff Tool",
Id::ExternalDiff,
is_file_pair && effective_diff_tool.is_some(),
diff_tool_reason,
));
actions.push(A::gated(
"E",
"Edit via External Editor",
Id::ExternalEdit,
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)
}
}),
"the focused pane has no file at this row",
));
actions.push(A::gated(
"s",
"Save staged changes",
Id::SaveStaged,
self.diff.is_dirty(),
"no staged changes to save",
));
actions.push(A::gated(
"u",
"Undo last staged change block",
Id::UndoStaged,
self.diff.can_undo(),
"nothing staged to undo",
));
actions.push(A::new("w", "Toggle Wrap Mode", Id::ToggleWrap));
actions.push(A::new("f", "Toggle Full Content", Id::ToggleFullDiff));
actions.push(A::new("T", "Toggle Light/Dark Theme", Id::ToggleTheme));
actions.push(A::new("C", "Edit Configuration", Id::Config));
actions.push(A::new("?", "Open Help Screen", Id::Help));
actions.push(A::new("Esc", "Return to Tree View", Id::Back));
}
ViewMode::ConfigMenu | ViewMode::Help => {
actions.push(A::new("T", "Toggle Light/Dark Theme", Id::ToggleTheme));
if self.view_mode == ViewMode::Help {
actions.push(A::new("C", "Edit Configuration", Id::Config));
} else {
actions.push(A::new("?", "Open Help Screen", Id::Help));
}
actions.push(A::new("Esc", "Go Back", Id::Back));
}
}
actions
}
}
fn find_node<'a>(node: &'a AlignedNode, relative_path: &Path) -> Option<&'a AlignedNode> {
if node.relative_path == relative_path
|| node.left_relative_path.as_deref() == Some(relative_path)
|| node.right_relative_path.as_deref() == Some(relative_path)
{
return Some(node);
}
node.children
.iter()
.find_map(|child| find_node(child, relative_path))
}
fn collect_scanned_entries(
node: &AlignedNode,
base: &Path,
from_left: bool,
out: &mut Vec<(PathBuf, bool)>,
) {
for child in &node.children {
let info = if from_left {
child.left.as_ref()
} else {
child.right.as_ref()
};
let Some(info) = info else {
continue;
};
let child_path = if from_left {
child
.left_relative_path
.as_deref()
.unwrap_or(&child.relative_path)
} else {
child
.right_relative_path
.as_deref()
.unwrap_or(&child.relative_path)
};
let Ok(relative) = child_path.strip_prefix(base) else {
continue;
};
out.push((relative.to_path_buf(), info.is_dir));
if info.is_dir {
collect_scanned_entries(child, base, from_left, out);
}
}
}
#[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: crate::settings::DiffToolSetting) {
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;
}
}
#[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,
..Default::default()
}
}
#[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: String::new(),
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: "top_dir".to_string(),
relative_path: PathBuf::from("top_dir"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "nested.txt".to_string(),
relative_path: PathBuf::from("top_dir/nested.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
}],
is_expanded: true,
..Default::default()
},
AlignedNode {
name: "top_file.txt".to_string(),
relative_path: PathBuf::from("top_file.txt"),
left: Some(FileInfo {
is_dir: false,
size: 5,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
},
],
is_expanded: true,
..Default::default()
};
app.root_node = Some(node);
app.flatten_tree();
assert_eq!(app.flat_rows.len(), 3, "Expected 3 flattened rows");
assert_eq!(app.flat_rows[0].name, "top_dir");
assert_eq!(
app.flat_rows[0].depth, 0,
"Top-level directory depth should be 0"
);
assert_eq!(app.flat_rows[1].name, "nested.txt");
assert_eq!(app.flat_rows[1].depth, 1, "Child depth should be 1");
assert_eq!(app.flat_rows[2].name, "top_file.txt");
assert_eq!(
app.flat_rows[2].depth, 0,
"Top-level file depth should be 0"
);
}
#[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,
..Default::default()
},
FlatRow {
depth: 1,
relative_path: PathBuf::from("child"),
name: "child".to_string(),
state: DiffState::Identical,
left: None,
right: None,
..Default::default()
},
];
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,
..Default::default()
})
.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_scan_progress_and_ticker() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert_eq!(app.scan_progress_count(), 0);
assert_eq!(app.spinner_frame(), 0);
app.tick();
assert_eq!(app.spinner_frame(), 1);
let g = app.begin_scan();
assert!(app.scan_in_progress());
assert_eq!(app.scan_progress_count(), 0);
app.set_scan_progress(75);
assert_eq!(app.scan_progress_count(), 75);
let top_view = app.top_bar_view();
assert!(top_view.scan_in_progress);
assert_eq!(top_view.scan_progress_count, 75);
assert_eq!(top_view.spinner_frame, 1);
let footer_view = app.tree_footer_view();
assert!(footer_view.scan_in_progress);
assert_eq!(footer_view.scan_progress_count, 75);
assert_eq!(footer_view.spinner_frame, 1);
let node = AlignedNode {
name: String::new(),
relative_path: PathBuf::from(""),
left: None,
right: None,
state: DiffState::Identical,
children: vec![],
..Default::default()
};
assert!(app.apply_scan_result(g, node));
assert!(!app.scan_in_progress());
assert_eq!(app.scan_progress_count(), 0);
}
#[test]
fn test_tree_footer_summary_counts_the_scanned_tree() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert_eq!(app.tree_footer_view().summary, None);
let g = app.begin_scan();
let node = AlignedNode {
children: vec![
AlignedNode {
name: "a.txt".into(),
relative_path: PathBuf::from("a.txt"),
state: DiffState::Identical,
..Default::default()
},
AlignedNode {
name: "b.txt".into(),
relative_path: PathBuf::from("b.txt"),
state: DiffState::LeftOnly,
..Default::default()
},
],
..Default::default()
};
assert!(app.apply_scan_result(g, node));
assert_eq!(
app.tree_footer_view().summary,
Some(crate::diff::TreeSummary {
identical: 1,
left_only: 1,
..Default::default()
})
);
assert!(app.tree_layout_inputs().has_summary);
}
#[test]
fn test_toggle_expand() {
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let node = AlignedNode {
name: String::new(),
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: "dir".to_string(),
relative_path: PathBuf::from("dir"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "child.txt".to_string(),
relative_path: PathBuf::from("dir/child.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
}],
is_expanded: true,
..Default::default()
}],
is_expanded: true,
..Default::default()
};
app.root_node = Some(node);
app.flatten_tree();
assert_eq!(app.flat_rows.len(), 2);
assert_eq!(app.flat_rows[0].name, "dir");
assert_eq!(app.flat_rows[1].name, "child.txt");
app.set_selected_idx(0);
app.toggle_expand();
assert_eq!(app.flat_rows.len(), 1);
assert_eq!(app.flat_rows[0].name, "dir");
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: String::new(),
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: "dir".to_string(),
relative_path: PathBuf::from("dir"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "child.txt".to_string(),
relative_path: PathBuf::from("dir/child.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
}],
is_expanded: true,
..Default::default()
}],
is_expanded: true,
..Default::default()
};
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("beta.txt"),
name: "beta.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("gamma.txt"),
name: "gamma.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
..Default::default()
},
];
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff.txt"),
name: "diff.txt".to_string(),
state: DiffState::DifferentNewerLeft,
left: None,
right: None,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("only.txt"),
name: "only.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
..Default::default()
},
];
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_filter();
assert_eq!(app.filter().rows().len(), 2);
assert!(app
.filter()
.rows()
.iter()
.all(|r| r.state != DiffState::Identical));
}
#[test]
fn test_filter_diffs_only_retains_unverified_rows() {
use crate::diff::UnverifiedReason;
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("image.png"),
name: "image.png".to_string(),
state: DiffState::Unverified(UnverifiedReason::NotCompared),
left: None,
right: None,
..Default::default()
},
];
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_filter();
assert_eq!(app.filter().rows().len(), 1);
assert_eq!(app.filter().rows()[0].name, "image.png");
}
#[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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff_a.txt"),
name: "diff_a.txt".to_string(),
state: DiffState::DifferentNewerLeft,
left: None,
right: None,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff_b.txt"),
name: "diff_b.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
..Default::default()
},
];
app.filter_mut().set_pattern("a");
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_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,
..Default::default()
}];
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("b.txt"),
name: "b.txt".to_string(),
state: DiffState::LeftOnly,
left: None,
right: None,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("c.txt"),
name: "c.txt".to_string(),
state: DiffState::RightOnly,
left: None,
right: None,
..Default::default()
},
];
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("diff.txt"),
name: "diff.txt".to_string(),
state: DiffState::DifferentNewerLeft,
left: None,
right: None,
..Default::default()
},
];
app.apply_filter();
app.set_selected_idx(0); app.set_scroll_offset(0);
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_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: String::new(),
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,
..Default::default()
},
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,
..Default::default()
},
],
is_expanded: true,
..Default::default()
};
app.root_node = Some(node);
app.flatten_tree();
app.set_selected_idx(1); app.set_scroll_offset(1);
app.viewport.visible_height = 10;
app.flatten_tree();
assert_eq!(app.selected_idx(), 1);
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: String::new(),
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,
..Default::default()
}],
is_expanded: true,
..Default::default()
}],
is_expanded: true,
..Default::default()
};
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: String::new(),
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,
..Default::default()
}],
is_expanded: false,
..Default::default()
}],
is_expanded: true,
..Default::default()
};
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,
..Default::default()
},
FlatRow {
depth: 0,
relative_path: PathBuf::from("b.txt"),
name: "b.txt".to_string(),
state: DiffState::Identical,
left: None,
right: None,
..Default::default()
},
];
app.filter_mut().set_pattern("a");
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_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(), 19);
assert!(matches!(
rows[0],
ConfigRowKind::Header("External Diff Tool")
));
assert!(matches!(rows[1], ConfigRowKind::DiffToolAuto));
assert!(matches!(rows[2], ConfigRowKind::DiffToolDisabled));
assert!(matches!(
rows[3],
ConfigRowKind::DiffTool {
idx: 0,
available: true
}
));
assert!(matches!(
rows[4],
ConfigRowKind::DiffTool {
idx: 1,
available: false
}
));
assert!(matches!(rows[5], ConfigRowKind::Header("Updates")));
assert!(matches!(rows[6], ConfigRowKind::CheckUpdates));
assert!(matches!(rows[7], ConfigRowKind::Header("Mouse")));
assert!(matches!(rows[8], ConfigRowKind::Mouse));
assert!(matches!(rows[9], ConfigRowKind::Header("Theme")));
assert!(matches!(rows[10], ConfigRowKind::Theme));
assert!(matches!(rows[11], ConfigRowKind::Header("Diff View")));
assert!(matches!(rows[12], ConfigRowKind::DiffContext));
assert!(matches!(rows[13], ConfigRowKind::Header("Scan")));
assert!(matches!(rows[14], ConfigRowKind::ScanMode));
assert!(matches!(rows[15], ConfigRowKind::Header("Exclusions")));
assert!(matches!(rows[16], ConfigRowKind::RespectGitignore));
assert!(matches!(rows[17], ConfigRowKind::GlobalExclusions));
assert!(matches!(rows[18], ConfigRowKind::IgnoreSources));
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(), 3);
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(), 12);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 14);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 16);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 17);
app.config_select_next();
assert_eq!(app.config().selected_idx(), 1);
app.config_select_prev();
assert_eq!(app.config().selected_idx(), 17);
assert!(!app.config_select_at(4));
assert_eq!(app.config().selected_idx(), 17); }
#[test]
fn config_diff_tool_selection_and_unknown_row() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_external_diff_tool(crate::settings::DiffToolSetting::Auto);
app.set_detected_diff_tools(vec![
(crate::diff_tool::ExternalDiffTool::Vim, true),
(crate::diff_tool::ExternalDiffTool::Nvim, false),
]);
assert_eq!(
app.settings().external_diff_tool,
crate::settings::DiffToolSetting::Auto
);
assert_eq!(
app.resolve_effective_diff_tool(),
Some(crate::diff_tool::ExternalDiffTool::Vim)
);
assert!(app.config_select_at(2));
assert!(!app.apply_config_selection());
assert_eq!(
app.settings().external_diff_tool,
crate::settings::DiffToolSetting::Disabled
);
assert_eq!(app.resolve_effective_diff_tool(), None);
assert!(app.config_select_at(3));
assert!(!app.apply_config_selection());
assert_eq!(
app.settings().external_diff_tool,
crate::settings::DiffToolSetting::Pinned(crate::diff_tool::ExternalDiffTool::Vim)
);
assert_eq!(
app.resolve_effective_diff_tool(),
Some(crate::diff_tool::ExternalDiffTool::Vim)
);
app.set_detected_diff_tools(vec![
(crate::diff_tool::ExternalDiffTool::Vim, false),
(crate::diff_tool::ExternalDiffTool::Nvim, true),
]);
assert_eq!(app.resolve_effective_diff_tool(), None);
app.set_external_diff_tool(crate::settings::DiffToolSetting::Unknown(
"custom-diff".to_string(),
));
let rows = app.config_rows();
assert!(rows.contains(&ConfigRowKind::DiffToolUnknown));
assert_eq!(app.resolve_effective_diff_tool(), None);
}
#[test]
fn config_view_abbreviates_home_directory_in_ignore_sources() {
let _guard = ConfigEnvGuard::new();
let home = PathBuf::from(std::env::var("HOME").expect("ConfigEnvGuard sets HOME"));
let other = PathBuf::from("/opt/other");
let app = App::new(home.join("Notes"), other.clone());
let view = app.config_view();
assert_eq!(
view.left_ignore_source,
"~/Notes/.gitignore + .duodiffignore"
);
assert_eq!(
view.right_ignore_source,
format!("{}/.gitignore + .duodiffignore", other.display())
);
let app = App::new(home, other);
let view = app.config_view();
assert_eq!(view.left_ignore_source, "~/.gitignore + .duodiffignore");
}
#[test]
fn exclusion_editor_cancel_discards_all_draft_changes() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let saved = app.settings().global_exclusions.clone();
app.open_exclusion_editor();
app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
app.exclusion_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.exclusion_editor_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!app.exclusion_editor_open());
assert_eq!(app.settings().global_exclusions, saved);
}
#[test]
fn exclusion_editor_apply_persists_rules_and_requests_one_rescan() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.open_exclusion_editor();
app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
for ch in "*.generated".chars() {
app.exclusion_editor_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
app.exclusion_editor_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL,)));
assert!(!app.exclusion_editor_open());
assert!(app
.settings()
.global_exclusions
.iter()
.any(|p| p == "*.generated"));
}
#[test]
fn exclusion_editor_r_restores_builtin_defaults_into_the_draft_without_saving() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let saved = app.settings().global_exclusions.clone();
app.open_exclusion_editor();
for _ in 0..saved.len() {
app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
}
assert!(
app.exclusion_editor_view()
.expect("editor open")
.draft
.is_empty(),
"precondition: every rule was deleted from the draft"
);
assert!(!app.exclusion_editor_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)));
assert!(app.exclusion_editor_open());
assert_eq!(
app.exclusion_editor_view().expect("editor open").draft,
crate::settings::AppSettings::default().global_exclusions
);
assert_eq!(
app.settings().global_exclusions,
saved,
"r must not persist until Ctrl+s"
);
}
#[test]
fn test_config_scan_mode_row_persists_and_requests_a_rescan() {
use crate::settings::ScanMode;
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.scan_mode(), ScanMode::Precise);
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::ScanMode))
.unwrap();
app.config_mut().set_selected_idx(idx);
assert!(
app.apply_config_selection(),
"a successful scan-mode change needs a rescan"
);
assert_eq!(app.scan_mode(), ScanMode::Fast);
assert_eq!(
crate::settings::AppSettings::load().scan_mode,
ScanMode::Fast
);
assert!(!app.scan_mode_is_session_override());
let theme_idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::Theme))
.unwrap();
app.config_mut().set_selected_idx(theme_idx);
assert!(!app.apply_config_selection());
}
#[cfg(unix)]
#[test]
fn test_scan_mode_save_failure_keeps_the_previous_mode_and_skips_the_rescan() {
use crate::settings::ScanMode;
use std::os::unix::fs::PermissionsExt;
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.scan_mode(), ScanMode::Precise);
let path = crate::settings::AppSettings::config_path().unwrap();
let original = std::fs::metadata(&path).unwrap().permissions();
let mut locked = original.clone();
locked.set_mode(0o444);
std::fs::set_permissions(&path, locked).unwrap();
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::ScanMode))
.unwrap();
app.config_mut().set_selected_idx(idx);
let needs_rescan = app.apply_config_selection();
std::fs::set_permissions(&path, original).unwrap();
assert!(!needs_rescan, "a failed save must not trigger a rescan");
assert_eq!(
app.scan_mode(),
ScanMode::Precise,
"the runtime mode must survive a failed save"
);
assert_eq!(app.saved_scan_mode(), ScanMode::Precise);
let (msg, is_error, _) = app.status_message.clone().unwrap();
assert!(is_error, "{msg}");
assert!(msg.contains("Could not save scan mode"), "{msg}");
}
#[test]
fn test_scan_mode_change_from_file_diff_keeps_the_diff_session() {
use crate::settings::ScanMode;
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut()
.set_rows(vec![crate::diff_view::DiffRow::from((
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Equal,
text: "kept".to_string(),
}),
None,
))]);
app.open_config();
let idx = app
.config_rows()
.iter()
.position(|r| matches!(r, ConfigRowKind::ScanMode))
.unwrap();
app.config_mut().set_selected_idx(idx);
assert!(app.apply_config_selection());
assert_eq!(app.scan_mode(), ScanMode::Fast);
app.close_config();
assert_eq!(app.view_mode(), ViewMode::FileDiff);
assert_eq!(app.diff().rows().len(), 1, "the diff session is preserved");
}
#[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);
let _ = app.apply_config_selection();
assert!(app.settings().mouse);
assert!(app.mouse_enabled());
let _ = 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);
let _ = app.apply_config_selection();
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Dark);
assert_eq!(app.theme(), crate::theme::Theme::DARK);
let _ = 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();
}
let _ = app.apply_config_selection();
assert!(app.settings().check_updates);
assert!(app.update_check_enabled());
let _ = 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);
let _ = app.apply_config_selection();
let _ = 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_cli_scan_mode_overrides_the_session_without_persisting() {
use crate::settings::ScanMode;
let _guard = ConfigEnvGuard::new();
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert_eq!(app.scan_mode(), ScanMode::Precise);
assert!(app.precise_mode());
assert!(!app.scan_mode_is_session_override());
app.set_scan_mode(ScanMode::Fast);
assert!(!app.precise_mode());
assert_eq!(
app.saved_scan_mode(),
ScanMode::Precise,
"the CLI value must not write the config file"
);
assert!(app.scan_mode_is_session_override());
app.apply_scan_mode(ScanMode::Fast).unwrap();
assert_eq!(app.saved_scan_mode(), ScanMode::Fast);
assert!(!app.scan_mode_is_session_override());
assert_eq!(
crate::settings::AppSettings::load().scan_mode,
ScanMode::Fast,
"apply_scan_mode persists before adopting the 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));
for expected in [
crate::ui::PaletteActionId::ExternalDiff,
crate::ui::PaletteActionId::ExternalEdit,
crate::ui::PaletteActionId::Config,
crate::ui::PaletteActionId::ToggleTheme,
crate::ui::PaletteActionId::Back,
] {
assert!(
actions.iter().any(|a| a.action_id == expected),
"File Diff must list {expected:?}"
);
}
}
#[test]
fn test_build_palette_actions_config_and_help_views() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::ConfigMenu);
let config_ids: Vec<_> = app
.build_palette_actions()
.iter()
.map(|a| a.action_id)
.collect();
assert_eq!(
config_ids,
vec![
crate::ui::PaletteActionId::ToggleTheme,
crate::ui::PaletteActionId::Help,
crate::ui::PaletteActionId::Back,
]
);
app.set_view_mode(ViewMode::Help);
let help_ids: Vec<_> = app
.build_palette_actions()
.iter()
.map(|a| a.action_id)
.collect();
assert_eq!(
help_ids,
vec![
crate::ui::PaletteActionId::ToggleTheme,
crate::ui::PaletteActionId::Config,
crate::ui::PaletteActionId::Back,
]
);
assert!(app.build_palette_actions().iter().all(|a| a.enabled()));
}
#[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,
}),
..Default::default()
}];
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.stage_hunk_at_cursor(HunkCopyDirection::LeftToRight)
.expect("hunk copy should stage");
assert!(app.diff().right_dirty());
assert!(app.diff().right_buffer().to_text().contains("left-line"));
let on_disk = read_to_string(right_dir.path().join("merge.txt")).unwrap();
assert!(on_disk.contains("right-line"), "nothing is written yet");
app.save_staged().expect("save 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"));
assert!(!app.diff().is_dirty(), "a save clears the dirty state");
}
#[test]
fn test_staged_hunk_undo_restores_the_working_buffers_without_writing() {
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.set_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: 16,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 17,
modified: SystemTime::UNIX_EPOCH,
}),
..Default::default()
}]);
app.apply_filter();
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_show_full(true);
app.refresh_file_diff().unwrap();
app.diff_mut().set_scroll(1);
app.stage_hunk_at_cursor(HunkCopyDirection::LeftToRight)
.unwrap();
assert!(app.diff().right_dirty());
assert!(app.diff().can_undo());
assert!(app.undo_staged_hunk());
assert!(!app.diff().is_dirty());
assert!(!app.diff().can_undo());
assert_eq!(app.diff().right_buffer().to_text(), "keep\nright-line\n");
assert_eq!(
read_to_string(right_dir.path().join("merge.txt")).unwrap(),
"keep\nright-line\n"
);
}
#[test]
fn test_save_conflict_offers_only_reload_or_cancel() {
use crate::diff::FileInfo;
use crate::diff_view::HunkCopyDirection;
use std::fs::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.set_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: 16,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 17,
modified: SystemTime::UNIX_EPOCH,
}),
..Default::default()
}]);
app.apply_filter();
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_show_full(true);
app.refresh_file_diff().unwrap();
app.diff_mut().set_scroll(1);
app.stage_hunk_at_cursor(HunkCopyDirection::LeftToRight)
.unwrap();
write(right_dir.path().join("merge.txt"), "external edit\n").unwrap();
assert!(
!app.save_staged().unwrap(),
"an external edit must prevent writing"
);
let modal = app.confirm_modal().unwrap();
assert_eq!(
modal
.choices
.iter()
.map(|choice| choice.action.clone())
.collect::<Vec<_>>(),
vec![ConfirmAction::ReloadDiscardStaged, ConfirmAction::Cancel]
);
app.dismiss_confirm();
assert!(
app.diff().is_dirty(),
"Cancel must keep the staged edit intact"
);
assert!(!app.save_staged().unwrap());
app.reload_discarding_staged().unwrap();
assert!(!app.diff().is_dirty());
assert_eq!(app.diff().right_buffer().to_text(), "external edit\n");
}
#[test]
fn test_dirty_exit_gate_offers_save_discard_and_cancel() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.diff.left = crate::diff_view::TextBuffer::from_text("staged\n");
app.diff.left_baseline = crate::diff_view::TextBuffer::from_text("baseline\n");
app.set_flat_rows(vec![flat_row("file.txt")]);
app.apply_filter();
assert!(app.guard_staged_exit());
let modal = app.confirm_modal().unwrap();
assert_eq!(
modal
.choices
.iter()
.map(|choice| choice.action.clone())
.collect::<Vec<_>>(),
vec![
ConfirmAction::SaveStagedThenLeave,
ConfirmAction::DiscardStagedThenLeave,
ConfirmAction::Cancel,
]
);
}
#[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,
..Default::default()
}
}
fn dir_node(name: &str) -> AlignedNode {
AlignedNode {
name: String::new(),
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: name.to_string(),
relative_path: PathBuf::from(name),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: true,
..Default::default()
}],
is_expanded: true,
..Default::default()
}
}
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_palette_clears_the_query_and_selects_the_first_enabled_action() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.palette_visible());
app.open_palette();
assert!(app.palette_visible());
assert!(app.palette().query.is_empty());
let selected = app.palette().items[app.palette().selected_idx].clone();
assert!(selected.enabled(), "{selected:?}");
assert!(
app.palette().items[..app.palette().selected_idx]
.iter()
.all(|a| !a.enabled()),
"the first enabled action wins"
);
app.palette_type_char('x');
assert_eq!(app.palette().query, "x");
app.close_palette();
assert!(!app.palette_visible());
assert!(app.palette().query.is_empty(), "close clears query");
app.open_palette();
assert!(app.palette().query.is_empty(), "open clears query");
}
#[test]
fn test_palette_select_next_prev_wraps() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.open_palette();
app.set_palette_items(vec![
crate::ui::PaletteAction::new("a", "A", crate::ui::PaletteActionId::Help),
crate::ui::PaletteAction::new("b", "B", crate::ui::PaletteActionId::Quit),
]);
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_palette_search_is_case_insensitive_substring() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::DirectoryTree);
app.open_palette();
for c in "QUIT".chars() {
app.palette_type_char(c);
}
assert!(
app.palette()
.items
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::Quit),
"an upper-case query must still match the lower-case label"
);
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"
);
for c in "zzz".chars() {
app.palette_type_char(c);
}
assert!(app.palette().items.is_empty());
app.palette_backspace();
app.palette_backspace();
app.palette_backspace();
app.palette_backspace();
app.palette_backspace();
app.palette_backspace();
app.palette_backspace();
assert_eq!(app.palette().query, "");
for c in "qit".chars() {
app.palette_type_char(c);
}
assert!(
app.palette()
.items
.iter()
.all(|a| a.action_id != crate::ui::PaletteActionId::Quit),
"\"qit\" is a subsequence of \"quit\" but not a substring"
);
}
#[test]
fn test_palette_keeps_unavailable_actions_visible_with_a_reason() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_view_mode(ViewMode::DirectoryTree);
let actions = app.build_palette_actions();
let diff = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::BuiltinDiff)
.expect("the built-in diff action stays listed with no row selected");
assert!(!diff.enabled());
assert_eq!(diff.disabled_reason, Some("no row is selected"));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::ToggleTheme));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::ToggleFocus));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::FocusLeft));
assert!(actions
.iter()
.any(|a| a.action_id == crate::ui::PaletteActionId::ExpandSelected));
}
#[test]
fn test_sync_palette_viewport_keeps_the_selection_visible() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.open_palette();
app.set_palette_items(
(0..20)
.map(|i| {
crate::ui::PaletteAction::new(
&i.to_string(),
&format!("Action {i}"),
crate::ui::PaletteActionId::Help,
)
})
.collect(),
);
app.set_palette_selected_idx(0);
app.sync_palette_viewport(8);
assert_eq!(app.palette().scroll_offset, 0);
app.set_palette_selected_idx(8);
app.sync_palette_viewport(8);
assert_eq!(app.palette().scroll_offset, 1, "scrolls just far enough");
app.set_palette_selected_idx(19);
app.sync_palette_viewport(8);
assert_eq!(app.palette().scroll_offset, 12);
app.set_palette_selected_idx(0);
app.sync_palette_viewport(8);
assert_eq!(app.palette().scroll_offset, 0, "scrolls back up");
}
#[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, 32);
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_collapsed_diff_gutter_uses_file_line_count_not_visible_rows() {
use crate::diff::FileInfo;
use std::fs::write;
use std::time::SystemTime;
use tempfile::tempdir;
let left_dir = tempdir().unwrap();
let right_dir = tempdir().unwrap();
let mut left_lines = Vec::new();
let mut right_lines = Vec::new();
for i in 0..100 {
if i == 50 {
left_lines.push("left-mid");
right_lines.push("right-mid");
} else {
left_lines.push("same");
right_lines.push("same");
}
}
write(
left_dir.path().join("big.txt"),
left_lines.join("\n") + "\n",
)
.unwrap();
write(
right_dir.path().join("big.txt"),
right_lines.join("\n") + "\n",
)
.unwrap();
let mut app = App::new(
left_dir.path().to_path_buf(),
right_dir.path().to_path_buf(),
);
app.set_flat_rows(vec![FlatRow {
depth: 0,
relative_path: PathBuf::from("big.txt"),
name: "big.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,
}),
..Default::default()
}]);
app.apply_filter();
app.set_view_mode(ViewMode::FileDiff);
app.diff_mut().set_show_full(false);
app.refresh_file_diff().expect("diff should load");
app.sync_viewport(Rect::new(0, 0, 80, 24));
assert!(
app.diff().rows().len() < 20,
"collapsed view shows a handful of rows, not the whole file"
);
assert_eq!(
app.viewport().diff_content_width,
30,
"100-line files keep a 3-digit gutter (38 inner - 8) even when collapsed"
);
}
#[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, 5);
app.sync_viewport(Rect::new(0, 0, 40, 24));
let viewport = app.viewport();
assert_eq!(viewport.diff_content_width, 12);
assert_eq!(viewport.diff_physical_rows, 10);
}
#[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(),
68,
"100 chars less the 32 text columns"
);
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(), 18);
}
#[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[0].children.push(AlignedNode {
name: "leaf.txt".to_string(),
relative_path: PathBuf::from("root/leaf.txt"),
left: Some(FileInfo {
is_dir: false,
size: 1,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
});
let generation = app.begin_scan();
app.apply_scan_result(generation, node.clone());
assert_eq!(app.flat_rows().len(), 2);
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(), 2, "root 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.lines, vec!["Copy foo.txt to right side?".to_string()]);
assert_eq!(modal.default_action(), Some(ConfirmAction::CopyLeftToRight));
assert_eq!(modal.cancel_action(), Some(ConfirmAction::Cancel));
}
#[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.state = crate::diff::DiffState::LeftOnly;
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.default_action(), Some(ConfirmAction::CopyLeftToRight));
assert!(modal.title.contains("Create"), "{}", modal.title);
assert!(modal.lines.iter().any(|l| l.contains("foo.txt")));
}
#[test]
fn confirm_dialogs_abbreviate_home_directory_as_tilde() {
let _guard = ConfigEnvGuard::new();
let home = PathBuf::from(std::env::var("HOME").expect("ConfigEnvGuard sets HOME"));
let mut app = App::new(home.join("proj-a"), home.join("proj-b"));
app.set_flat_rows(vec![{
let mut row = flat_row_with_sides(Some(file_info(false)), None);
row.name = "foo.txt".to_string();
row.relative_path = PathBuf::from("foo.txt");
row.state = crate::diff::DiffState::LeftOnly;
row
}]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(ConfirmAction::CopyLeftToRight);
let modal = app.confirm_modal().expect("copy preview should open");
assert!(
modal.lines.iter().any(|l| l == "From: ~/proj-a/foo.txt"),
"copy From path should use ~: {:?}",
modal.lines
);
assert!(
modal.lines.iter().any(|l| l == "To: ~/proj-b/foo.txt"),
"copy To path should use ~: {:?}",
modal.lines
);
app.dismiss_confirm();
app.diff.left = crate::diff_view::TextBuffer::from_text("staged\n");
app.diff.left_baseline = crate::diff_view::TextBuffer::from_text("baseline\n");
assert!(app.guard_staged_exit());
let modal = app.confirm_modal().expect("dirty-exit dialog should open");
assert!(
modal.lines.iter().any(|l| l.contains("~/proj-a/foo.txt")),
"staged-exit paths should use ~: {:?}",
modal.lines
);
app.dismiss_confirm();
app.request_save_staged(false);
let modal = app.confirm_modal().expect("save dialog should open");
assert!(
modal.lines.iter().any(|l| l.contains("~/proj-a/foo.txt")),
"save-staged paths should use ~: {:?}",
modal.lines
);
}
#[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.state = crate::diff::DiffState::RightOnly;
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.default_action(), Some(ConfirmAction::CopyRightToLeft));
assert!(modal.title.contains("Create"), "{}", modal.title);
assert!(modal.lines.iter().any(|l| l.contains("bar.txt")));
}
#[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_diffs_only_is_drafted_until_commit_and_restored_on_cancel() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
assert!(!app.filter().diffs_only());
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
assert!(
app.filter().editing_diffs_only(),
"the badge follows the draft straight away"
);
assert!(
!app.filter().diffs_only(),
"but the committed flag is untouched until Enter"
);
app.commit_filter();
assert!(app.filter().diffs_only());
assert!(app.filter().editing_diffs_only());
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
assert!(!app.filter().editing_diffs_only());
app.filter_mut().cancel();
assert!(app.filter().diffs_only());
assert!(app.filter().editing_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");
}
#[test]
fn test_enter_file_diff_on_identical_binary_file_shows_toast_feedback() {
use crate::diff::FileInfo;
use std::fs::write;
use std::time::SystemTime;
use tempfile::tempdir;
let left_dir = tempdir().unwrap();
let right_dir = tempdir().unwrap();
let bin_content = b"PNG\0\r\n\x1a\n\0\0\0\rIHDR";
write(left_dir.path().join("image.png"), bin_content).unwrap();
write(right_dir.path().join("image.png"), bin_content).unwrap();
let mut app = App::new(
left_dir.path().to_path_buf(),
right_dir.path().to_path_buf(),
);
app.set_flat_rows(vec![FlatRow {
depth: 0,
relative_path: PathBuf::from("image.png"),
name: "image.png".to_string(),
state: crate::diff::DiffState::Identical,
left: Some(FileInfo {
is_dir: false,
size: bin_content.len() as u64,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: bin_content.len() as u64,
modified: SystemTime::UNIX_EPOCH,
}),
..Default::default()
}]);
app.apply_filter();
let opened = app.enter_file_diff();
assert!(!opened, "Binary files cannot be opened in built-in diff");
assert_eq!(
app.view_mode(),
ViewMode::DirectoryTree,
"View mode should stay on DirectoryTree"
);
let toast = app.status_toast();
assert!(
toast.is_some(),
"Must emit a status toast on identical binary file"
);
let (msg, is_error) = toast.unwrap();
assert!(is_error, "Toast should be an error toast");
assert!(
msg.contains("binary file not supported"),
"Toast message should explain binary file not supported: {msg}"
);
assert!(
msg.contains("image.png"),
"Toast message should mention filename: {msg}"
);
assert!(
msg.contains("press D for external diff"),
"Toast message should suggest pressing D: {msg}"
);
}
#[test]
fn test_enter_file_diff_on_identical_text_file_opens_diff_view() {
use crate::diff::FileInfo;
use std::fs::write;
use std::time::SystemTime;
use tempfile::tempdir;
let left_dir = tempdir().unwrap();
let right_dir = tempdir().unwrap();
write(left_dir.path().join("doc.txt"), "hello world\n").unwrap();
write(right_dir.path().join("doc.txt"), "hello world\n").unwrap();
let mut app = App::new(
left_dir.path().to_path_buf(),
right_dir.path().to_path_buf(),
);
app.set_flat_rows(vec![FlatRow {
depth: 0,
relative_path: PathBuf::from("doc.txt"),
name: "doc.txt".to_string(),
state: crate::diff::DiffState::Identical,
left: Some(FileInfo {
is_dir: false,
size: 12,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 12,
modified: SystemTime::UNIX_EPOCH,
}),
..Default::default()
}]);
app.apply_filter();
let opened = app.enter_file_diff();
assert!(opened, "Identical text file should open in diff view");
assert_eq!(
app.view_mode(),
ViewMode::FileDiff,
"View mode should change to FileDiff"
);
}
#[test]
fn test_empty_tree_produces_no_flat_rows_and_none_selection() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let root = AlignedNode {
name: String::new(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
state: DiffState::Identical,
children: vec![],
is_expanded: true,
..Default::default()
};
app.set_root_node(root);
assert!(app.flat_rows().is_empty());
assert!(app.filter().rows().is_empty());
assert_eq!(app.selected_row(), None);
assert_eq!(app.selected_relative_path(), None);
assert_eq!(app.selected_idx(), 0);
assert_eq!(app.scroll_offset(), 0);
}
#[test]
fn test_empty_tree_navigation_and_actions_are_noops() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let root = AlignedNode {
name: String::new(),
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,
..Default::default()
};
app.set_root_node(root);
app.select_next();
assert_eq!(app.selected_idx(), 0);
app.select_prev();
assert_eq!(app.selected_idx(), 0);
app.page_down();
assert_eq!(app.selected_idx(), 0);
app.page_up();
assert_eq!(app.selected_idx(), 0);
assert!(!app.select_row_at(0));
assert!(!app.select_row_at(5));
assert!(!app.enter_file_diff());
assert!(app.refresh_file_diff().is_err());
assert!(app
.stage_hunk_at_cursor(crate::diff_view::HunkCopyDirection::LeftToRight)
.is_err());
app.expand_selected();
app.collapse_selected();
app.toggle_expand();
assert!(app.flat_rows().is_empty());
app.request_copy(ConfirmAction::CopyLeftToRight);
assert!(app.confirm_modal().is_none());
app.request_copy(ConfirmAction::CopyRightToLeft);
assert!(app.confirm_modal().is_none());
}
#[test]
fn test_request_copy_with_empty_relative_path_is_blocked() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_flat_rows(vec![FlatRow {
depth: 0,
relative_path: PathBuf::from(""),
name: String::new(),
state: DiffState::LeftOnly,
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
..Default::default()
}]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(ConfirmAction::CopyLeftToRight);
assert!(
app.confirm_modal().is_none(),
"Copying the root directory with empty relative path must never open a modal"
);
}
#[test]
fn test_palette_actions_on_empty_tree_disables_item_commands() {
let app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let actions = app.build_palette_actions();
let builtin_diff = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::BuiltinDiff)
.unwrap();
assert!(!builtin_diff.enabled());
assert_eq!(builtin_diff.disabled_reason, Some("no row is selected"));
let copy_lr = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::CopyLeftToRight)
.unwrap();
assert!(!copy_lr.enabled());
assert_eq!(copy_lr.disabled_reason, Some("no row is selected"));
let copy_rl = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::CopyRightToLeft)
.unwrap();
assert!(!copy_rl.enabled());
assert_eq!(copy_rl.disabled_reason, Some("no row is selected"));
let expand = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::ExpandSelected)
.unwrap();
assert!(!expand.enabled());
assert_eq!(expand.disabled_reason, Some("no row is selected"));
let quit = actions
.iter()
.find(|a| a.action_id == crate::ui::PaletteActionId::Quit)
.unwrap();
assert!(quit.enabled());
}
#[test]
fn test_filtering_complete_tree_hides_all_entries_without_exposing_root() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let node = AlignedNode {
name: String::new(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
state: DiffState::Identical,
children: vec![
AlignedNode {
name: "alpha.txt".to_string(),
relative_path: PathBuf::from("alpha.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
state: DiffState::Identical,
children: vec![],
is_expanded: false,
..Default::default()
},
AlignedNode {
name: "beta.txt".to_string(),
relative_path: PathBuf::from("beta.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
state: DiffState::Identical,
children: vec![],
is_expanded: false,
..Default::default()
},
],
is_expanded: true,
..Default::default()
};
app.set_root_node(node);
assert_eq!(app.flat_rows().len(), 2);
app.filter_mut().set_pattern("gamma");
app.apply_filter();
assert!(app.filter().rows().is_empty());
assert_eq!(app.selected_row(), None);
assert_eq!(app.selected_idx(), 0);
app.filter_mut().clear();
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_filter();
app.apply_filter();
assert!(app.filter().rows().is_empty());
assert_eq!(app.selected_row(), None);
app.filter_mut().clear();
app.filter_mut().set_pattern("");
app.apply_filter();
assert_eq!(app.filter().rows().len(), 2);
assert_eq!(app.filter().rows()[0].name, "alpha.txt");
assert_eq!(app.filter().rows()[1].name, "beta.txt");
}
#[test]
fn test_filter_searches_full_tree_ignoring_collapse_state() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let node = AlignedNode {
name: String::new(),
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: "collapsed_folder".to_string(),
relative_path: PathBuf::from("collapsed_folder"),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![AlignedNode {
name: "deep_target.txt".to_string(),
relative_path: PathBuf::from("collapsed_folder/deep_target.txt"),
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
state: DiffState::LeftOnly,
children: vec![],
is_expanded: false,
..Default::default()
}],
is_expanded: false, ..Default::default()
}],
is_expanded: true,
..Default::default()
};
app.set_root_node(node);
assert_eq!(app.flat_rows().len(), 1);
app.filter_mut().set_pattern("target");
app.apply_filter();
assert_eq!(app.filter().rows().len(), 1);
assert_eq!(app.filter().rows()[0].name, "deep_target.txt");
assert_eq!(
app.filter().rows()[0].relative_path,
PathBuf::from("collapsed_folder/deep_target.txt")
);
assert_eq!(app.filter().rows()[0].depth, 0); }
#[test]
fn test_filter_diffs_only_retains_content_identical_case_conflicts() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
let node = AlignedNode {
name: String::new(),
relative_path: PathBuf::from(""),
left: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: true,
size: 0,
modified: SystemTime::UNIX_EPOCH,
}),
state: DiffState::Identical,
children: vec![
AlignedNode {
name: "FILE.txt".to_string(),
left_name: Some("FILE.txt".to_string()),
right_name: Some("file.txt".to_string()),
relative_path: PathBuf::from("FILE.txt"),
left_relative_path: Some(PathBuf::from("FILE.txt")),
right_relative_path: Some(PathBuf::from("file.txt")),
has_case_conflict: true,
contains_case_conflict: true,
state: DiffState::Identical, left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
children: vec![],
is_expanded: false,
..Default::default()
},
AlignedNode {
name: "regular.txt".to_string(),
relative_path: PathBuf::from("regular.txt"),
has_case_conflict: false,
state: DiffState::Identical,
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
children: vec![],
is_expanded: false,
..Default::default()
},
],
is_expanded: true,
..Default::default()
};
app.set_root_node(node);
app.filter_mut().open();
app.filter_mut().toggle_diffs_only();
app.commit_filter();
app.apply_filter();
assert_eq!(app.filter().rows().len(), 1);
assert_eq!(app.filter().rows()[0].name, "FILE.txt");
assert!(app.filter().rows()[0].has_case_conflict);
}
#[test]
fn test_request_copy_rejects_ambiguous_case_collision() {
let mut app = App::new(PathBuf::from("/left"), PathBuf::from("/right"));
app.set_flat_rows(vec![FlatRow {
name: "Foo".to_string(),
relative_path: PathBuf::from("Foo"),
is_ambiguous_case_collision: true,
state: DiffState::LeftOnly,
left: Some(FileInfo {
is_dir: false,
size: 10,
modified: SystemTime::UNIX_EPOCH,
}),
right: None,
..Default::default()
}]);
app.apply_filter();
app.set_selected_idx(0);
app.request_copy(crate::app::ConfirmAction::CopyLeftToRight);
assert!(app.confirm_modal().is_none());
assert_eq!(
app.status_toast().as_ref().map(|t| t.0),
Some("Cannot copy: ambiguous case collision")
);
}
}