diff --git a/local/recipes/tui/tlc/source/src/filemanager/compare.rs b/local/recipes/tui/tlc/source/src/filemanager/compare.rs new file mode 100644 index 0000000000..8e02834c67 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/compare.rs @@ -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, + /// Current scroll offset. + scroll: usize, + /// Error message (if files could not be read). + error: Option, +} + +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 = 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, 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 { + 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 = Vec::new(); + let b: Vec = Vec::new(); + let diff = compute_diff(&a, &b); + assert!(diff.is_empty()); + } + + #[test] + fn diff_one_empty_one_not() { + let a: Vec = 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); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 0269e61eb1..49d33a03d2 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -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), /// Filtered command output viewer. FilteredView(Box), + /// File comparison diff viewer. + Compare(Box), } 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(), } } diff --git a/local/recipes/tui/tlc/source/src/keymap/mod.rs b/local/recipes/tui/tlc/source/src/keymap/mod.rs index 9d94c9b93a..4096c794e9 100644 --- a/local/recipes/tui/tlc/source/src/keymap/mod.rs +++ b/local/recipes/tui/tlc/source/src/keymap/mod.rs @@ -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 {