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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user