tlc: compare files (Ctrl-d) — LCS-based diff viewer dialog

New compare.rs module with CompareDialog showing line-by-line diff
of two files (active panel cursor vs other panel cursor). Uses
LCS dynamic programming algorithm for optimal diff.

Color-coded output: context lines (foreground), removed lines
(error/red, bold), added lines (info/green, bold). Supports
scrolling (arrows, PageUp/Down, g/G for Home/End).

Error handling for unreadable files. MAX_LINES=10000 OOM guard.
10 new tests covering diff algorithm edge cases and dialog lifecycle.
986 tests total, 0 failures.
This commit is contained in:
2026-06-19 10:39:25 +03:00
parent cc90bb1337
commit 6ade75f02b
3 changed files with 408 additions and 1 deletions
@@ -0,0 +1,374 @@
//! File comparison dialog — shows a line-by-line diff of two files.
//!
//! Activated via `Ctrl-d` when the cursor file in the active panel and the
//! cursor file in the other panel are both regular files.
use std::fs;
use std::path::Path;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::key::Key;
use crate::terminal::color::Theme;
/// Maximum lines to read from each file (prevents OOM on huge files).
const MAX_LINES: usize = 10_000;
/// A single line in the diff output.
#[derive(Debug, Clone, PartialEq)]
enum DiffLine {
/// Line present in both files (context).
Context(String),
/// Line only in the left file (removed).
Removed(String),
/// Line only in the right file (added).
Added(String),
}
/// Outcome of a key press in the compare dialog.
#[derive(Debug, Clone)]
pub enum CompareOutcome {
/// Still navigating — keep the dialog open.
Running,
/// User pressed Esc/Enter — close the dialog.
Close,
}
/// The file-comparison dialog.
pub struct CompareDialog {
/// Left file path (display).
left_name: String,
/// Right file path (display).
right_name: String,
/// Diff lines to display.
lines: Vec<DiffLine>,
/// Current scroll offset.
scroll: usize,
/// Error message (if files could not be read).
error: Option<String>,
}
impl CompareDialog {
/// Create a new compare dialog for two files.
pub fn new(left: &Path, right: &Path) -> Self {
let left_name = left
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| left.display().to_string());
let right_name = right
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| right.display().to_string());
match (read_lines(left), read_lines(right)) {
(Ok(a), Ok(b)) => {
let diff = compute_diff(&a, &b);
Self {
left_name,
right_name,
lines: diff,
scroll: 0,
error: None,
}
}
(Err(e), _) | (_, Err(e)) => Self {
left_name,
right_name,
lines: Vec::new(),
scroll: 0,
error: Some(e),
},
}
}
/// Handle a key press.
pub fn handle_key(&mut self, key: Key) -> CompareOutcome {
match key {
Key::ESCAPE | Key::ENTER => CompareOutcome::Close,
k if k == Key::from_char('q') => CompareOutcome::Close,
k if k.code == 0x2191 => {
self.scroll = self.scroll.saturating_sub(1);
CompareOutcome::Running
}
k if k.code == 0x2193 => {
self.scroll = self.scroll.saturating_add(1);
CompareOutcome::Running
}
k if k.code == 0x21A5 => {
self.scroll = self.scroll.saturating_sub(20);
CompareOutcome::Running
}
k if k.code == 0x21A6 => {
self.scroll = self.scroll.saturating_add(20);
CompareOutcome::Running
}
k if k == Key::from_char('g') => {
self.scroll = 0;
CompareOutcome::Running
}
k if k == Key::from_char('G') => {
let max = self.lines.len().saturating_sub(1);
self.scroll = max;
CompareOutcome::Running
}
_ => CompareOutcome::Running,
}
}
/// Render the dialog.
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let popup = centered_rect(area, 90, 80);
frame.render_widget(Clear, popup);
let chunks = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(popup);
let title = if self.error.is_some() {
format!(" Compare: error ")
} else {
format!(" Compare: {}{} ", self.left_name, self.right_name)
};
let header = Paragraph::new(Line::from(vec![Span::styled(
title,
Style::default()
.fg(theme.title_fg)
.bg(theme.title_bg)
.add_modifier(Modifier::BOLD),
)]))
.alignment(Alignment::Center);
frame.render_widget(header, chunks[0]);
let body_area = chunks[1];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let inner = block.inner(body_area);
frame.render_widget(block, body_area);
let body = self.build_body(theme, inner.height as usize);
frame.render_widget(body, inner);
}
fn build_body(&self, theme: &Theme, visible: usize) -> Paragraph<'_> {
if let Some(ref err) = self.error {
return Paragraph::new(err.as_str());
}
let max_scroll = self.lines.len().saturating_sub(visible);
let start = self.scroll.min(max_scroll);
let end = (start + visible).min(self.lines.len());
let lines: Vec<Line> = self.lines[start..end]
.iter()
.map(|dl| match dl {
DiffLine::Context(s) => Line::from(vec![Span::styled(
format!(" {s}"),
Style::default().fg(theme.foreground),
)]),
DiffLine::Removed(s) => Line::from(vec![Span::styled(
format!("- {s}"),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)]),
DiffLine::Added(s) => Line::from(vec![Span::styled(
format!("+ {s}"),
Style::default()
.fg(theme.info)
.add_modifier(Modifier::BOLD),
)]),
})
.collect();
Paragraph::new(lines)
}
}
fn read_lines(path: &Path) -> Result<Vec<String>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
Ok(content.lines().take(MAX_LINES).map(String::from).collect())
}
fn compute_diff(a: &[String], b: &[String]) -> Vec<DiffLine> {
let m = a.len();
let n = b.len();
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in (0..m).rev() {
for j in (0..n).rev() {
dp[i][j] = if a[i] == b[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
let mut result = Vec::new();
let (mut i, mut j) = (0, 0);
while i < m && j < n {
if a[i] == b[j] {
result.push(DiffLine::Context(a[i].clone()));
i += 1;
j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
result.push(DiffLine::Removed(a[i].clone()));
i += 1;
} else {
result.push(DiffLine::Added(b[j].clone()));
j += 1;
}
}
while i < m {
result.push(DiffLine::Removed(a[i].clone()));
i += 1;
}
while j < n {
result.push(DiffLine::Added(b[j].clone()));
j += 1;
}
result
}
fn centered_rect(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
let pop_rect = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(pop_rect[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_identical_files() {
let a = vec!["hello".to_string(), "world".to_string()];
let b = a.clone();
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 2);
assert!(diff.iter().all(|d| matches!(d, DiffLine::Context(_))));
}
#[test]
fn diff_added_lines() {
let a = vec!["a".to_string()];
let b = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 3);
assert!(matches!(diff[0], DiffLine::Context(_)));
assert!(matches!(diff[1], DiffLine::Added(_)));
assert!(matches!(diff[2], DiffLine::Added(_)));
}
#[test]
fn diff_removed_lines() {
let a = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let b = vec!["a".to_string()];
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 3);
assert!(matches!(diff[0], DiffLine::Context(_)));
assert!(matches!(diff[1], DiffLine::Removed(_)));
assert!(matches!(diff[2], DiffLine::Removed(_)));
}
#[test]
fn diff_completely_different() {
let a = vec!["x".to_string(), "y".to_string()];
let b = vec!["p".to_string(), "q".to_string()];
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 4);
assert!(matches!(diff[0], DiffLine::Removed(_)));
assert!(matches!(diff[1], DiffLine::Removed(_)));
assert!(matches!(diff[2], DiffLine::Added(_)));
assert!(matches!(diff[3], DiffLine::Added(_)));
}
#[test]
fn diff_empty_files() {
let a: Vec<String> = Vec::new();
let b: Vec<String> = Vec::new();
let diff = compute_diff(&a, &b);
assert!(diff.is_empty());
}
#[test]
fn diff_one_empty_one_not() {
let a: Vec<String> = Vec::new();
let b = vec!["line1".to_string(), "line2".to_string()];
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 2);
assert!(diff.iter().all(|d| matches!(d, DiffLine::Added(_))));
}
#[test]
fn diff_preserves_common_subsequence() {
let a = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let b = vec!["a".to_string(), "x".to_string(), "c".to_string()];
let diff = compute_diff(&a, &b);
assert_eq!(diff.len(), 4);
assert!(matches!(diff[0], DiffLine::Context(ref s) if s == "a"));
assert!(matches!(diff[1], DiffLine::Removed(_)));
assert!(matches!(diff[2], DiffLine::Added(_)));
assert!(matches!(diff[3], DiffLine::Context(ref s) if s == "c"));
}
#[test]
fn compare_dialog_reads_files() {
let dir = std::env::temp_dir().join("tlc-compare-test");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let f1 = dir.join("a.txt");
let f2 = dir.join("b.txt");
fs::write(&f1, "hello\nworld\n").unwrap();
fs::write(&f2, "hello\nrust\n").unwrap();
let dlg = CompareDialog::new(&f1, &f2);
assert!(dlg.error.is_none());
assert_eq!(dlg.lines.len(), 3);
assert!(matches!(&dlg.lines[0], DiffLine::Context(_)));
assert!(matches!(&dlg.lines[1], DiffLine::Removed(_)));
assert!(matches!(&dlg.lines[2], DiffLine::Added(_)));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn compare_dialog_handles_missing_file() {
let dlg = CompareDialog::new(
Path::new("/nonexistent/a"),
Path::new("/nonexistent/b"),
);
assert!(dlg.error.is_some());
}
#[test]
fn compare_dialog_close_on_esc() {
let dir = std::env::temp_dir().join("tlc-compare-esc");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let f1 = dir.join("a.txt");
let f2 = dir.join("b.txt");
fs::write(&f1, "x\n").unwrap();
fs::write(&f2, "y\n").unwrap();
let mut dlg = CompareDialog::new(&f1, &f2);
assert!(matches!(dlg.handle_key(Key::ESCAPE), CompareOutcome::Close));
let _ = fs::remove_dir_all(&dir);
}
}
@@ -5,6 +5,7 @@
//! constructs one of these and dispatches keys to it.
pub mod cmdline;
pub mod compare;
pub mod config_dialog;
pub mod confirm_dialog;
pub mod connection_manager;
@@ -259,6 +260,8 @@ pub enum DialogState {
EditHistory(Box<edit_history::EditHistoryDialog>),
/// Filtered command output viewer.
FilteredView(Box<filtered_view::FilteredViewDialog>),
/// File comparison diff viewer.
Compare(Box<compare::CompareDialog>),
}
impl DialogState {
@@ -309,6 +312,7 @@ impl DialogState {
DialogState::ScreenList(_) => false,
DialogState::EditHistory(_) => false,
DialogState::FilteredView(_) => false,
DialogState::Compare(_) => false,
}
}
}
@@ -805,6 +809,21 @@ impl FileManager {
)));
Ok(true)
}
Cmd::CompareFiles => {
let left = self.active_panel().cursor_path();
let right = self.other_panel().cursor_path();
if left.is_file() && right.is_file() {
self.dialog = Some(DialogState::Compare(Box::new(
compare::CompareDialog::new(&left, &right),
)));
Ok(true)
} else {
self.status.set_message(
"Compare: both panels must have a regular file under cursor".to_string(),
);
Ok(true)
}
}
Cmd::Suspend => {
self.status
.set_message("Suspend: use Ctrl-O to drop to a shell".to_string());
@@ -1452,6 +1471,13 @@ impl FileManager {
filtered_view_outcome = Some(d.handle_key(key));
consumed = true;
}
Some(DialogState::Compare(d)) => {
let o = d.handle_key(key);
if matches!(o, compare::CompareOutcome::Close) {
self.dialog = None;
}
consumed = true;
}
None => return false,
}
// Apply captured outcomes.
@@ -1794,7 +1820,8 @@ impl FileManager {
| Some(DialogState::VfsList(_))
| Some(DialogState::ScreenList(_))
| Some(DialogState::EditHistory(_))
| Some(DialogState::FilteredView(_)) => {
| Some(DialogState::FilteredView(_))
| Some(DialogState::Compare(_)) => {
// No-op: those dialogs clear themselves.
}
// The Help dialog also clears itself in `handle_dialog_key`
@@ -2239,6 +2266,7 @@ impl FileManager {
DialogState::ScreenList(d) => d.render(frame, area, &self.theme),
DialogState::EditHistory(d) => d.render(frame, area, &self.theme),
DialogState::FilteredView(d) => d.render(frame, area, &self.theme),
DialogState::Compare(d) => d.render(frame, area, &self.theme),
}
}
}
@@ -2891,6 +2919,7 @@ fn dialog_label(d: &DialogState) -> String {
DialogState::ScreenList(_) => "Screen list".to_string(),
DialogState::EditHistory(_) => "Directory history".to_string(),
DialogState::FilteredView(_) => "Filtered view".to_string(),
DialogState::Compare(_) => "Compare files".to_string(),
}
}
@@ -106,6 +106,8 @@ pub enum Cmd {
/// main event loop (`app.rs`) so that one `pending Ctrl-X`
/// state is shared across the filemanager and the panels.
CompareDirs,
/// Ctrl-d — compare two files (active panel cursor vs other panel cursor).
CompareFiles,
/// Ctrl-F in the viewer — open the next file in the parent
/// directory (wraps around at the end). Not bound by the
/// keymap; handled by the viewer's own `handle_key` so the
@@ -220,6 +222,7 @@ impl Cmd {
Cmd::HistoryBack => "History back",
Cmd::HistoryForward => "History forward",
Cmd::CompareDirs => "Compare directories",
Cmd::CompareFiles => "Compare files",
Cmd::ViewerNextFile => "Viewer next file",
Cmd::ViewerPrevFile => "Viewer previous file",
Cmd::LayoutDialog => "Layout options",
@@ -429,6 +432,7 @@ pub fn default_keymap() -> Keymap {
km.bind(Key::alt(';'), Cmd::InsertCurFile);
km.bind(Key::f(20), Cmd::QuitQuiet);
km.bind(Key::ctrl('z'), Cmd::Suspend);
km.bind(Key::ctrl('d'), Cmd::CompareFiles);
// Alt-Shift-arrow (delivered once crossterm lands; harmless under termion).
km.bind(
Key {