1use crate::theme::Theme;
6use ratatui::{
7 layout::{Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs},
11 Frame,
12};
13use similar::{ChangeTag, TextDiff};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum DiffViewMode {
18 #[default]
19 Unified,
20 SideBySide,
21}
22
23#[derive(Debug, Clone)]
25pub struct DiffHunk {
26 pub file_path: String,
28 pub extension: Option<String>,
30 pub lines: Vec<DiffLine>,
32 pub old_start: usize,
34 pub new_start: usize,
36}
37
38#[derive(Debug, Clone)]
40pub struct DiffLine {
41 pub content: String,
43 pub line_type: DiffLineType,
45 pub old_line_number: Option<usize>,
47 pub new_line_number: Option<usize>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DiffLineType {
54 Context,
56 Added,
58 Removed,
60 Header,
62 HunkHeader,
64}
65
66impl DiffLine {
67 pub fn new(content: &str, line_type: DiffLineType) -> Self {
68 Self {
69 content: content.to_string(),
70 line_type,
71 old_line_number: None,
72 new_line_number: None,
73 }
74 }
75
76 pub fn with_line_numbers(
77 content: &str,
78 line_type: DiffLineType,
79 old: Option<usize>,
80 new: Option<usize>,
81 ) -> Self {
82 Self {
83 content: content.to_string(),
84 line_type,
85 old_line_number: old,
86 new_line_number: new,
87 }
88 }
89}
90
91pub struct DiffViewer {
93 pub hunks: Vec<DiffHunk>,
95 pub scroll: usize,
97 pub selected_hunk: usize,
99 pub view_mode: DiffViewMode,
101 theme: Theme,
103 total_lines: usize,
105}
106
107impl Default for DiffViewer {
108 fn default() -> Self {
109 Self {
110 hunks: Vec::new(),
111 scroll: 0,
112 selected_hunk: 0,
113 view_mode: DiffViewMode::Unified,
114 theme: Theme::default(),
115 total_lines: 0,
116 }
117 }
118}
119
120impl DiffViewer {
121 pub fn new() -> Self {
123 Self::default()
124 }
125
126 pub fn compute_diff(&mut self, file_path: &str, old_content: &str, new_content: &str) {
128 self.hunks.clear();
129
130 let diff = TextDiff::from_lines(old_content, new_content);
131 let extension = file_path.rsplit('.').next().map(String::from);
132
133 let mut current_hunk = DiffHunk {
134 file_path: file_path.to_string(),
135 extension: extension.clone(),
136 lines: vec![DiffLine::new(
137 &format!("diff --git a/{} b/{}", file_path, file_path),
138 DiffLineType::Header,
139 )],
140 old_start: 1,
141 new_start: 1,
142 };
143
144 let mut old_line = 1usize;
145 let mut new_line = 1usize;
146
147 for change in diff.iter_all_changes() {
148 let (line_type, old_num, new_num) = match change.tag() {
149 ChangeTag::Delete => {
150 let num = old_line;
151 old_line += 1;
152 (DiffLineType::Removed, Some(num), None)
153 }
154 ChangeTag::Insert => {
155 let num = new_line;
156 new_line += 1;
157 (DiffLineType::Added, None, Some(num))
158 }
159 ChangeTag::Equal => {
160 let o = old_line;
161 let n = new_line;
162 old_line += 1;
163 new_line += 1;
164 (DiffLineType::Context, Some(o), Some(n))
165 }
166 };
167
168 let content = change.value().trim_end_matches('\n');
170 current_hunk.lines.push(DiffLine::with_line_numbers(
171 content, line_type, old_num, new_num,
172 ));
173 }
174
175 if !current_hunk.lines.is_empty() {
176 self.hunks.push(current_hunk);
177 }
178
179 self.update_total_lines();
180 }
181
182 pub fn parse_diff(&mut self, diff_text: &str) {
184 self.hunks.clear();
185 let mut current_hunk: Option<DiffHunk> = None;
186 let mut old_line = 1usize;
187 let mut new_line = 1usize;
188
189 for line in diff_text.lines() {
190 if line.starts_with("diff --git") {
191 if let Some(hunk) = current_hunk.take() {
192 self.hunks.push(hunk);
193 }
194 let file_path = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
195 let extension = file_path.rsplit('.').next().map(String::from);
196 current_hunk = Some(DiffHunk {
197 file_path,
198 extension,
199 lines: vec![DiffLine::new(line, DiffLineType::Header)],
200 old_start: 1,
201 new_start: 1,
202 });
203 old_line = 1;
204 new_line = 1;
205 } else if line.starts_with("---") || line.starts_with("+++") {
206 if let Some(ref mut hunk) = current_hunk {
207 hunk.lines.push(DiffLine::new(line, DiffLineType::Header));
208 }
209 } else if line.starts_with("@@") {
210 if let Some(ref mut hunk) = current_hunk {
212 hunk.lines
213 .push(DiffLine::new(line, DiffLineType::HunkHeader));
214 if let Some(nums) = parse_hunk_header(line) {
216 old_line = nums.0;
217 new_line = nums.2;
218 hunk.old_start = nums.0;
219 hunk.new_start = nums.2;
220 }
221 }
222 } else if let Some(ref mut hunk) = current_hunk {
223 let (line_type, old_num, new_num) = if line.starts_with('+') {
224 let n = new_line;
225 new_line += 1;
226 (DiffLineType::Added, None, Some(n))
227 } else if line.starts_with('-') {
228 let o = old_line;
229 old_line += 1;
230 (DiffLineType::Removed, Some(o), None)
231 } else {
232 let o = old_line;
233 let n = new_line;
234 old_line += 1;
235 new_line += 1;
236 (DiffLineType::Context, Some(o), Some(n))
237 };
238
239 let content = if line.len() > 1
241 && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' '))
242 {
243 &line[1..]
244 } else {
245 line
246 };
247
248 hunk.lines.push(DiffLine::with_line_numbers(
249 content, line_type, old_num, new_num,
250 ));
251 }
252 }
253
254 if let Some(hunk) = current_hunk {
255 self.hunks.push(hunk);
256 }
257
258 self.update_total_lines();
259 }
260
261 pub fn clear(&mut self) {
263 self.hunks.clear();
264 self.scroll = 0;
265 self.selected_hunk = 0;
266 self.total_lines = 0;
267 }
268
269 fn update_total_lines(&mut self) {
270 self.total_lines = self.hunks.iter().map(|h| h.lines.len()).sum();
271 }
272
273 pub fn toggle_view_mode(&mut self) {
275 self.view_mode = match self.view_mode {
276 DiffViewMode::Unified => DiffViewMode::SideBySide,
277 DiffViewMode::SideBySide => DiffViewMode::Unified,
278 };
279 }
280
281 pub fn scroll_up(&mut self) {
283 self.scroll = self.scroll.saturating_sub(1);
284 }
285
286 pub fn scroll_down(&mut self) {
288 self.scroll = self.scroll.saturating_add(1);
289 }
290
291 pub fn page_up(&mut self, lines: usize) {
293 self.scroll = self.scroll.saturating_sub(lines);
294 }
295
296 pub fn page_down(&mut self, lines: usize) {
298 self.scroll = self.scroll.saturating_add(lines);
299 }
300
301 pub fn next_hunk(&mut self) {
303 if self.selected_hunk < self.hunks.len().saturating_sub(1) {
304 self.selected_hunk += 1;
305 let mut line_offset = 0;
307 for i in 0..self.selected_hunk {
308 line_offset += self.hunks[i].lines.len();
309 }
310 self.scroll = line_offset;
311 }
312 }
313
314 pub fn prev_hunk(&mut self) {
316 if self.selected_hunk > 0 {
317 self.selected_hunk -= 1;
318 let mut line_offset = 0;
319 for i in 0..self.selected_hunk {
320 line_offset += self.hunks[i].lines.len();
321 }
322 self.scroll = line_offset;
323 }
324 }
325
326 pub fn render(&self, frame: &mut Frame, area: Rect) {
328 let chunks = Layout::default()
329 .direction(Direction::Vertical)
330 .constraints([Constraint::Length(3), Constraint::Min(5)])
331 .split(area);
332
333 let tab_titles = vec!["Unified", "Side-by-Side"];
335 let tabs = Tabs::new(tab_titles)
336 .block(
337 Block::default()
338 .borders(Borders::ALL)
339 .title("View Mode")
340 .border_style(self.theme.border),
341 )
342 .select(match self.view_mode {
343 DiffViewMode::Unified => 0,
344 DiffViewMode::SideBySide => 1,
345 })
346 .style(Style::default().fg(Color::White))
347 .highlight_style(self.theme.highlight);
348 frame.render_widget(tabs, chunks[0]);
349
350 match self.view_mode {
352 DiffViewMode::Unified => self.render_unified(frame, chunks[1]),
353 DiffViewMode::SideBySide => self.render_side_by_side(frame, chunks[1]),
354 }
355 }
356
357 fn render_unified(&self, frame: &mut Frame, area: Rect) {
358 let lines: Vec<Line> = self
359 .hunks
360 .iter()
361 .enumerate()
362 .flat_map(|(hunk_idx, hunk)| {
363 hunk.lines.iter().map(move |line| {
364 let (fg_color, bg_color, prefix) = match line.line_type {
365 DiffLineType::Added => {
366 (Color::Rgb(200, 255, 200), Some(Color::Rgb(30, 50, 30)), "+")
367 }
368 DiffLineType::Removed => {
369 (Color::Rgb(255, 200, 200), Some(Color::Rgb(50, 30, 30)), "-")
370 }
371 DiffLineType::Header => (Color::Rgb(129, 212, 250), None, " "),
372 DiffLineType::HunkHeader => {
373 (Color::Rgb(186, 104, 200), Some(Color::Rgb(40, 30, 50)), " ")
374 }
375 DiffLineType::Context => (Color::Rgb(180, 180, 180), None, " "),
376 };
377
378 let line_nums = match (line.old_line_number, line.new_line_number) {
380 (Some(o), Some(n)) => format!("{:>4} {:>4} ", o, n),
381 (Some(o), None) => format!("{:>4} ", o),
382 (None, Some(n)) => format!(" {:>4} ", n),
383 (None, None) => " ".to_string(),
384 };
385
386 let mut spans = vec![
387 Span::styled(line_nums, Style::default().fg(Color::Rgb(100, 100, 100))),
388 Span::styled(format!("{} ", prefix), Style::default().fg(fg_color)),
389 ];
390
391 let content_style = if hunk_idx == self.selected_hunk {
393 Style::default().fg(fg_color).add_modifier(Modifier::BOLD)
394 } else {
395 Style::default().fg(fg_color)
396 };
397
398 let content_style = if let Some(bg) = bg_color {
399 content_style.bg(bg)
400 } else {
401 content_style
402 };
403
404 spans.push(Span::styled(&line.content, content_style));
405
406 Line::from(spans)
407 })
408 })
409 .collect();
410
411 let visible_lines = area.height.saturating_sub(2) as usize;
412 let max_scroll = self.total_lines.saturating_sub(visible_lines);
413 let scroll = self.scroll.min(max_scroll);
414
415 let stats = self.compute_stats();
416 let title = format!(
417 "📝 Diff: {} files, +{} -{} ({} hunks)",
418 self.hunks.len(),
419 stats.additions,
420 stats.deletions,
421 self.hunks.len()
422 );
423
424 let para = Paragraph::new(lines)
425 .block(
426 Block::default()
427 .title(title)
428 .borders(Borders::ALL)
429 .border_style(self.theme.border),
430 )
431 .scroll((scroll as u16, 0));
432
433 frame.render_widget(para, area);
434
435 let scrollbar = Scrollbar::default()
437 .orientation(ScrollbarOrientation::VerticalRight)
438 .begin_symbol(Some("↑"))
439 .end_symbol(Some("↓"));
440 let mut scrollbar_state = ScrollbarState::new(self.total_lines).position(scroll);
441 frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
442 }
443
444 fn render_side_by_side(&self, frame: &mut Frame, area: Rect) {
445 let columns = Layout::default()
447 .direction(Direction::Horizontal)
448 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
449 .split(area);
450
451 let old_lines: Vec<Line> = self
453 .hunks
454 .iter()
455 .flat_map(|hunk| {
456 hunk.lines.iter().filter_map(|line| {
457 match line.line_type {
458 DiffLineType::Removed | DiffLineType::Context => {
459 let num = line
460 .old_line_number
461 .map(|n| format!("{:>4} ", n))
462 .unwrap_or_else(|| " ".to_string());
463 let style = match line.line_type {
464 DiffLineType::Removed => Style::default()
465 .fg(Color::Rgb(255, 200, 200))
466 .bg(Color::Rgb(50, 30, 30)),
467 _ => Style::default().fg(Color::Rgb(180, 180, 180)),
468 };
469 Some(Line::from(vec![
470 Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
471 Span::styled(&line.content, style),
472 ]))
473 }
474 DiffLineType::Added => Some(Line::from("")), _ => None,
476 }
477 })
478 })
479 .collect();
480
481 let new_lines: Vec<Line> = self
483 .hunks
484 .iter()
485 .flat_map(|hunk| {
486 hunk.lines.iter().filter_map(|line| {
487 match line.line_type {
488 DiffLineType::Added | DiffLineType::Context => {
489 let num = line
490 .new_line_number
491 .map(|n| format!("{:>4} ", n))
492 .unwrap_or_else(|| " ".to_string());
493 let style = match line.line_type {
494 DiffLineType::Added => Style::default()
495 .fg(Color::Rgb(200, 255, 200))
496 .bg(Color::Rgb(30, 50, 30)),
497 _ => Style::default().fg(Color::Rgb(180, 180, 180)),
498 };
499 Some(Line::from(vec![
500 Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
501 Span::styled(&line.content, style),
502 ]))
503 }
504 DiffLineType::Removed => Some(Line::from("")), _ => None,
506 }
507 })
508 })
509 .collect();
510
511 let visible = area.height.saturating_sub(2) as usize;
512 let scroll = self.scroll.min(old_lines.len().saturating_sub(visible));
513
514 let old_para = Paragraph::new(old_lines)
515 .block(Block::default().title("Old").borders(Borders::ALL))
516 .scroll((scroll as u16, 0));
517 frame.render_widget(old_para, columns[0]);
518
519 let new_para = Paragraph::new(new_lines)
520 .block(Block::default().title("New").borders(Borders::ALL))
521 .scroll((scroll as u16, 0));
522 frame.render_widget(new_para, columns[1]);
523 }
524
525 fn compute_stats(&self) -> DiffStats {
527 let mut stats = DiffStats::default();
528 for hunk in &self.hunks {
529 for line in &hunk.lines {
530 match line.line_type {
531 DiffLineType::Added => stats.additions += 1,
532 DiffLineType::Removed => stats.deletions += 1,
533 _ => {}
534 }
535 }
536 }
537 stats
538 }
539}
540
541fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
543 let parts: Vec<&str> = line.split_whitespace().collect();
544 if parts.len() < 3 {
545 return None;
546 }
547
548 let old_range = parts.get(1)?;
549 let new_range = parts.get(2)?;
550
551 let parse_range = |s: &str| -> Option<(usize, usize)> {
552 let s = s.trim_start_matches(['-', '+'].as_ref());
553 let parts: Vec<&str> = s.split(',').collect();
554 let start = parts.first()?.parse().ok()?;
555 let count = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
556 Some((start, count))
557 };
558
559 let (old_start, old_count) = parse_range(old_range)?;
560 let (new_start, new_count) = parse_range(new_range)?;
561
562 Some((old_start, old_count, new_start, new_count))
563}
564
565#[derive(Default)]
566struct DiffStats {
567 additions: usize,
568 deletions: usize,
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
576 fn test_compute_diff() {
577 let mut viewer = DiffViewer::new();
578 viewer.compute_diff(
579 "test.rs",
580 "line1\nline2\nline3\n",
581 "line1\nmodified\nline3\nnew line\n",
582 );
583
584 assert_eq!(viewer.hunks.len(), 1);
585 let stats = viewer.compute_stats();
587 assert!(stats.additions > 0);
588 assert!(stats.deletions > 0);
589 }
590
591 #[test]
592 fn test_parse_hunk_header() {
593 let result = parse_hunk_header("@@ -1,5 +1,7 @@");
594 assert_eq!(result, Some((1, 5, 1, 7)));
595 }
596}