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 pub operation: Option<String>,
38}
39
40#[derive(Debug, Clone)]
42pub struct DiffLine {
43 pub content: String,
45 pub line_type: DiffLineType,
47 pub old_line_number: Option<usize>,
49 pub new_line_number: Option<usize>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum DiffLineType {
56 Context,
58 Added,
60 Removed,
62 Header,
64 HunkHeader,
66}
67
68impl DiffLine {
69 pub fn new(content: &str, line_type: DiffLineType) -> Self {
70 Self {
71 content: content.to_string(),
72 line_type,
73 old_line_number: None,
74 new_line_number: None,
75 }
76 }
77
78 pub fn with_line_numbers(
79 content: &str,
80 line_type: DiffLineType,
81 old: Option<usize>,
82 new: Option<usize>,
83 ) -> Self {
84 Self {
85 content: content.to_string(),
86 line_type,
87 old_line_number: old,
88 new_line_number: new,
89 }
90 }
91}
92
93pub struct DiffViewer {
95 pub hunks: Vec<DiffHunk>,
97 pub scroll: usize,
99 pub selected_hunk: usize,
101 pub view_mode: DiffViewMode,
103 theme: Theme,
105 total_lines: usize,
107 pub bundle_summary: Option<BundleSummary>,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct BundleSummary {
114 pub node_id: String,
116 pub node_class: String,
118 pub files_created: usize,
120 pub files_modified: usize,
122 pub writes_count: usize,
124 pub diffs_count: usize,
126}
127
128impl Default for DiffViewer {
129 fn default() -> Self {
130 Self {
131 hunks: Vec::new(),
132 scroll: 0,
133 selected_hunk: 0,
134 view_mode: DiffViewMode::Unified,
135 theme: Theme::default(),
136 total_lines: 0,
137 bundle_summary: None,
138 }
139 }
140}
141
142impl DiffViewer {
143 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn compute_diff(&mut self, file_path: &str, old_content: &str, new_content: &str) {
150 self.hunks.clear();
151
152 let diff = TextDiff::from_lines(old_content, new_content);
153 let extension = file_path.rsplit('.').next().map(String::from);
154
155 let mut current_hunk = DiffHunk {
156 file_path: file_path.to_string(),
157 extension: extension.clone(),
158 lines: vec![DiffLine::new(
159 &format!("diff --git a/{} b/{}", file_path, file_path),
160 DiffLineType::Header,
161 )],
162 old_start: 1,
163 new_start: 1,
164 operation: None,
165 };
166
167 let mut old_line = 1usize;
168 let mut new_line = 1usize;
169
170 for change in diff.iter_all_changes() {
171 let (line_type, old_num, new_num) = match change.tag() {
172 ChangeTag::Delete => {
173 let num = old_line;
174 old_line += 1;
175 (DiffLineType::Removed, Some(num), None)
176 }
177 ChangeTag::Insert => {
178 let num = new_line;
179 new_line += 1;
180 (DiffLineType::Added, None, Some(num))
181 }
182 ChangeTag::Equal => {
183 let o = old_line;
184 let n = new_line;
185 old_line += 1;
186 new_line += 1;
187 (DiffLineType::Context, Some(o), Some(n))
188 }
189 };
190
191 let content = change.value().trim_end_matches('\n');
193 current_hunk.lines.push(DiffLine::with_line_numbers(
194 content, line_type, old_num, new_num,
195 ));
196 }
197
198 if !current_hunk.lines.is_empty() {
199 self.hunks.push(current_hunk);
200 }
201
202 self.update_total_lines();
203 }
204
205 pub fn parse_diff(&mut self, diff_text: &str) {
207 self.hunks.clear();
208 let mut current_hunk: Option<DiffHunk> = None;
209 let mut old_line = 1usize;
210 let mut new_line = 1usize;
211
212 for line in diff_text.lines() {
213 if line.starts_with("diff --git") {
214 if let Some(hunk) = current_hunk.take() {
215 self.hunks.push(hunk);
216 }
217 let file_path = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
218 let extension = file_path.rsplit('.').next().map(String::from);
219 current_hunk = Some(DiffHunk {
220 file_path,
221 extension,
222 lines: vec![DiffLine::new(line, DiffLineType::Header)],
223 old_start: 1,
224 new_start: 1,
225 operation: None,
226 });
227 old_line = 1;
228 new_line = 1;
229 } else if line.starts_with("---") || line.starts_with("+++") {
230 if let Some(ref mut hunk) = current_hunk {
231 hunk.lines.push(DiffLine::new(line, DiffLineType::Header));
232 }
233 } else if line.starts_with("@@") {
234 if let Some(ref mut hunk) = current_hunk {
236 hunk.lines
237 .push(DiffLine::new(line, DiffLineType::HunkHeader));
238 if let Some(nums) = parse_hunk_header(line) {
240 old_line = nums.0;
241 new_line = nums.2;
242 hunk.old_start = nums.0;
243 hunk.new_start = nums.2;
244 }
245 }
246 } else if let Some(ref mut hunk) = current_hunk {
247 let (line_type, old_num, new_num) = if line.starts_with('+') {
248 let n = new_line;
249 new_line += 1;
250 (DiffLineType::Added, None, Some(n))
251 } else if line.starts_with('-') {
252 let o = old_line;
253 old_line += 1;
254 (DiffLineType::Removed, Some(o), None)
255 } else {
256 let o = old_line;
257 let n = new_line;
258 old_line += 1;
259 new_line += 1;
260 (DiffLineType::Context, Some(o), Some(n))
261 };
262
263 let content = if line.len() > 1
265 && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' '))
266 {
267 &line[1..]
268 } else {
269 line
270 };
271
272 hunk.lines.push(DiffLine::with_line_numbers(
273 content, line_type, old_num, new_num,
274 ));
275 }
276 }
277
278 if let Some(hunk) = current_hunk {
279 self.hunks.push(hunk);
280 }
281
282 self.update_total_lines();
283 }
284
285 pub fn clear(&mut self) {
287 self.hunks.clear();
288 self.scroll = 0;
289 self.selected_hunk = 0;
290 self.total_lines = 0;
291 self.bundle_summary = None;
292 }
293
294 fn update_total_lines(&mut self) {
295 self.total_lines = self.hunks.iter().map(|h| h.lines.len()).sum();
296 }
297
298 pub fn toggle_view_mode(&mut self) {
300 self.view_mode = match self.view_mode {
301 DiffViewMode::Unified => DiffViewMode::SideBySide,
302 DiffViewMode::SideBySide => DiffViewMode::Unified,
303 };
304 }
305
306 pub fn scroll_up(&mut self) {
308 self.scroll = self.scroll.saturating_sub(1);
309 }
310
311 pub fn scroll_down(&mut self) {
313 self.scroll = self.scroll.saturating_add(1);
314 }
315
316 pub fn page_up(&mut self, lines: usize) {
318 self.scroll = self.scroll.saturating_sub(lines);
319 }
320
321 pub fn page_down(&mut self, lines: usize) {
323 self.scroll = self.scroll.saturating_add(lines);
324 }
325
326 pub fn next_hunk(&mut self) {
328 if self.selected_hunk < self.hunks.len().saturating_sub(1) {
329 self.selected_hunk += 1;
330 let mut line_offset = 0;
332 for i in 0..self.selected_hunk {
333 line_offset += self.hunks[i].lines.len();
334 }
335 self.scroll = line_offset;
336 }
337 }
338
339 pub fn prev_hunk(&mut self) {
341 if self.selected_hunk > 0 {
342 self.selected_hunk -= 1;
343 let mut line_offset = 0;
344 for i in 0..self.selected_hunk {
345 line_offset += self.hunks[i].lines.len();
346 }
347 self.scroll = line_offset;
348 }
349 }
350
351 pub fn render(&self, frame: &mut Frame, area: Rect) {
353 let chunks = Layout::default()
354 .direction(Direction::Vertical)
355 .constraints([Constraint::Length(3), Constraint::Min(5)])
356 .split(area);
357
358 let tab_titles = vec!["Unified", "Side-by-Side"];
360 let tabs = Tabs::new(tab_titles)
361 .block(
362 Block::default()
363 .borders(Borders::ALL)
364 .title("View Mode")
365 .border_style(self.theme.border),
366 )
367 .select(match self.view_mode {
368 DiffViewMode::Unified => 0,
369 DiffViewMode::SideBySide => 1,
370 })
371 .style(Style::default().fg(Color::White))
372 .highlight_style(self.theme.highlight);
373 frame.render_widget(tabs, chunks[0]);
374
375 match self.view_mode {
377 DiffViewMode::Unified => self.render_unified(frame, chunks[1]),
378 DiffViewMode::SideBySide => self.render_side_by_side(frame, chunks[1]),
379 }
380 }
381
382 fn render_unified(&self, frame: &mut Frame, area: Rect) {
383 let mut lines: Vec<Line> = Vec::new();
384
385 if let Some(ref summary) = self.bundle_summary {
387 lines.push(Line::from(vec![
388 Span::styled(" Node: ", Style::default().fg(Color::DarkGray)),
389 Span::styled(
390 &summary.node_id,
391 Style::default()
392 .fg(Color::Rgb(129, 212, 250))
393 .add_modifier(Modifier::BOLD),
394 ),
395 Span::styled(
396 format!(" [{}]", summary.node_class),
397 Style::default().fg(Color::Rgb(179, 157, 219)),
398 ),
399 ]));
400 lines.push(Line::from(vec![Span::styled(
401 format!(
402 " {} created, {} modified — {} writes, {} diffs",
403 summary.files_created,
404 summary.files_modified,
405 summary.writes_count,
406 summary.diffs_count
407 ),
408 Style::default().fg(Color::Rgb(158, 158, 158)),
409 )]));
410 lines.push(Line::from(""));
411 }
412
413 for (hunk_idx, hunk) in self.hunks.iter().enumerate() {
414 if let Some(ref op) = hunk.operation {
416 let op_color = match op.as_str() {
417 "created" => Color::Rgb(102, 187, 106),
418 "modified" => Color::Rgb(255, 183, 77),
419 _ => Color::White,
420 };
421 lines.push(Line::from(vec![
422 Span::styled(
423 format!(" {} ", op),
424 Style::default().fg(op_color).add_modifier(Modifier::BOLD),
425 ),
426 Span::styled(
427 &hunk.file_path,
428 Style::default().fg(Color::Rgb(129, 212, 250)),
429 ),
430 ]));
431 }
432
433 for line in &hunk.lines {
434 let (fg_color, bg_color, prefix) = match line.line_type {
435 DiffLineType::Added => {
436 (Color::Rgb(200, 255, 200), Some(Color::Rgb(30, 50, 30)), "+")
437 }
438 DiffLineType::Removed => {
439 (Color::Rgb(255, 200, 200), Some(Color::Rgb(50, 30, 30)), "-")
440 }
441 DiffLineType::Header => (Color::Rgb(129, 212, 250), None, " "),
442 DiffLineType::HunkHeader => {
443 (Color::Rgb(186, 104, 200), Some(Color::Rgb(40, 30, 50)), " ")
444 }
445 DiffLineType::Context => (Color::Rgb(180, 180, 180), None, " "),
446 };
447
448 let line_nums = match (line.old_line_number, line.new_line_number) {
449 (Some(o), Some(n)) => format!("{:>4} {:>4} ", o, n),
450 (Some(o), None) => format!("{:>4} ", o),
451 (None, Some(n)) => format!(" {:>4} ", n),
452 (None, None) => " ".to_string(),
453 };
454
455 let mut spans = vec![
456 Span::styled(line_nums, Style::default().fg(Color::Rgb(100, 100, 100))),
457 Span::styled(format!("{} ", prefix), Style::default().fg(fg_color)),
458 ];
459
460 let content_style = if hunk_idx == self.selected_hunk {
461 Style::default().fg(fg_color).add_modifier(Modifier::BOLD)
462 } else {
463 Style::default().fg(fg_color)
464 };
465
466 let content_style = if let Some(bg) = bg_color {
467 content_style.bg(bg)
468 } else {
469 content_style
470 };
471
472 spans.push(Span::styled(&line.content, content_style));
473 lines.push(Line::from(spans));
474 }
475 }
476
477 let visible_lines = area.height.saturating_sub(2) as usize;
478 let max_scroll = self.total_lines.saturating_sub(visible_lines);
479 let scroll = self.scroll.min(max_scroll);
480
481 let stats = self.compute_stats();
482 let title = format!(
483 "📝 Diff: {} files, +{} -{} ({} hunks)",
484 self.hunks.len(),
485 stats.additions,
486 stats.deletions,
487 self.hunks.len()
488 );
489
490 let para = Paragraph::new(lines)
491 .block(
492 Block::default()
493 .title(title)
494 .borders(Borders::ALL)
495 .border_style(self.theme.border),
496 )
497 .scroll((scroll as u16, 0));
498
499 frame.render_widget(para, area);
500
501 let scrollbar = Scrollbar::default()
503 .orientation(ScrollbarOrientation::VerticalRight)
504 .begin_symbol(Some("↑"))
505 .end_symbol(Some("↓"));
506 let mut scrollbar_state = ScrollbarState::new(self.total_lines).position(scroll);
507 frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
508 }
509
510 fn render_side_by_side(&self, frame: &mut Frame, area: Rect) {
511 let columns = Layout::default()
513 .direction(Direction::Horizontal)
514 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
515 .split(area);
516
517 let old_lines: Vec<Line> = self
519 .hunks
520 .iter()
521 .flat_map(|hunk| {
522 hunk.lines.iter().filter_map(|line| {
523 match line.line_type {
524 DiffLineType::Removed | DiffLineType::Context => {
525 let num = line
526 .old_line_number
527 .map(|n| format!("{:>4} ", n))
528 .unwrap_or_else(|| " ".to_string());
529 let style = match line.line_type {
530 DiffLineType::Removed => Style::default()
531 .fg(Color::Rgb(255, 200, 200))
532 .bg(Color::Rgb(50, 30, 30)),
533 _ => Style::default().fg(Color::Rgb(180, 180, 180)),
534 };
535 Some(Line::from(vec![
536 Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
537 Span::styled(&line.content, style),
538 ]))
539 }
540 DiffLineType::Added => Some(Line::from("")), _ => None,
542 }
543 })
544 })
545 .collect();
546
547 let new_lines: Vec<Line> = self
549 .hunks
550 .iter()
551 .flat_map(|hunk| {
552 hunk.lines.iter().filter_map(|line| {
553 match line.line_type {
554 DiffLineType::Added | DiffLineType::Context => {
555 let num = line
556 .new_line_number
557 .map(|n| format!("{:>4} ", n))
558 .unwrap_or_else(|| " ".to_string());
559 let style = match line.line_type {
560 DiffLineType::Added => Style::default()
561 .fg(Color::Rgb(200, 255, 200))
562 .bg(Color::Rgb(30, 50, 30)),
563 _ => Style::default().fg(Color::Rgb(180, 180, 180)),
564 };
565 Some(Line::from(vec![
566 Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
567 Span::styled(&line.content, style),
568 ]))
569 }
570 DiffLineType::Removed => Some(Line::from("")), _ => None,
572 }
573 })
574 })
575 .collect();
576
577 let visible = area.height.saturating_sub(2) as usize;
578 let scroll = self.scroll.min(old_lines.len().saturating_sub(visible));
579
580 let old_para = Paragraph::new(old_lines)
581 .block(Block::default().title("Old").borders(Borders::ALL))
582 .scroll((scroll as u16, 0));
583 frame.render_widget(old_para, columns[0]);
584
585 let new_para = Paragraph::new(new_lines)
586 .block(Block::default().title("New").borders(Borders::ALL))
587 .scroll((scroll as u16, 0));
588 frame.render_widget(new_para, columns[1]);
589 }
590
591 fn compute_stats(&self) -> DiffStats {
593 let mut stats = DiffStats::default();
594 for hunk in &self.hunks {
595 for line in &hunk.lines {
596 match line.line_type {
597 DiffLineType::Added => stats.additions += 1,
598 DiffLineType::Removed => stats.deletions += 1,
599 _ => {}
600 }
601 }
602 }
603 stats
604 }
605}
606
607fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
609 let parts: Vec<&str> = line.split_whitespace().collect();
610 if parts.len() < 3 {
611 return None;
612 }
613
614 let old_range = parts.get(1)?;
615 let new_range = parts.get(2)?;
616
617 let parse_range = |s: &str| -> Option<(usize, usize)> {
618 let s = s.trim_start_matches(['-', '+'].as_ref());
619 let parts: Vec<&str> = s.split(',').collect();
620 let start = parts.first()?.parse().ok()?;
621 let count = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
622 Some((start, count))
623 };
624
625 let (old_start, old_count) = parse_range(old_range)?;
626 let (new_start, new_count) = parse_range(new_range)?;
627
628 Some((old_start, old_count, new_start, new_count))
629}
630
631#[derive(Default)]
632struct DiffStats {
633 additions: usize,
634 deletions: usize,
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
642 fn test_compute_diff() {
643 let mut viewer = DiffViewer::new();
644 viewer.compute_diff(
645 "test.rs",
646 "line1\nline2\nline3\n",
647 "line1\nmodified\nline3\nnew line\n",
648 );
649
650 assert_eq!(viewer.hunks.len(), 1);
651 let stats = viewer.compute_stats();
653 assert!(stats.additions > 0);
654 assert!(stats.deletions > 0);
655 }
656
657 #[test]
658 fn test_parse_hunk_header() {
659 let result = parse_hunk_header("@@ -1,5 +1,7 @@");
660 assert_eq!(result, Some((1, 5, 1, 7)));
661 }
662
663 #[test]
664 fn test_bundle_summary_default() {
665 let viewer = DiffViewer::new();
666 assert!(viewer.bundle_summary.is_none());
667 }
668
669 #[test]
670 fn test_bundle_summary_set_and_clear() {
671 let mut viewer = DiffViewer::new();
672 viewer.bundle_summary = Some(BundleSummary {
673 node_id: "node-1".to_string(),
674 node_class: "Implementation".to_string(),
675 files_created: 2,
676 files_modified: 3,
677 writes_count: 5,
678 diffs_count: 3,
679 });
680 assert!(viewer.bundle_summary.is_some());
681 viewer.clear();
682 assert!(viewer.bundle_summary.is_none());
683 }
684
685 #[test]
686 fn test_hunk_operation_label() {
687 let mut viewer = DiffViewer::new();
688 viewer.compute_diff("src/new.rs", "", "fn main() {}\n");
689 assert_eq!(viewer.hunks.len(), 1);
690 viewer.hunks[0].operation = Some("created".to_string());
692 assert_eq!(viewer.hunks[0].operation.as_deref(), Some("created"));
693 }
694
695 #[test]
696 fn test_parse_diff_multi_file() {
697 let mut viewer = DiffViewer::new();
698 let diff_text = "\
699diff --git a/src/a.rs b/src/a.rs
700--- a/src/a.rs
701+++ b/src/a.rs
702@@ -1,3 +1,4 @@
703 line1
704+new line
705 line2
706 line3
707diff --git a/src/b.rs b/src/b.rs
708--- a/src/b.rs
709+++ b/src/b.rs
710@@ -1,2 +1,2 @@
711-old
712+new
713 same
714";
715 viewer.parse_diff(diff_text);
716 assert_eq!(viewer.hunks.len(), 2);
717 assert_eq!(viewer.hunks[0].file_path, "src/a.rs");
718 assert_eq!(viewer.hunks[1].file_path, "src/b.rs");
719 }
720}