1use anyhow::Result;
2use ratatui::{
3 layout::Rect,
4 widgets::{Block, Borders, Paragraph},
5 style::Style,
6 text::{Line, Span},
7};
8
9use crate::{
10 renderer::Renderer,
11 theme::Theme,
12 config::DiffStyle,
13};
14
15pub struct DiffViewer {
17 theme: Box<dyn Theme + Send + Sync>,
18 current_diff: Option<DiffContent>,
19 style: DiffStyle,
20 scroll_offset: usize,
21}
22
23#[derive(Debug, Clone)]
25pub struct DiffContent {
26 pub original_file: String,
27 pub modified_file: String,
28 pub hunks: Vec<DiffHunk>,
29}
30
31#[derive(Debug, Clone)]
33pub struct DiffHunk {
34 pub original_start: usize,
35 pub original_count: usize,
36 pub modified_start: usize,
37 pub modified_count: usize,
38 pub lines: Vec<DiffLine>,
39}
40
41#[derive(Debug, Clone)]
43pub struct DiffLine {
44 pub line_type: DiffLineType,
45 pub content: String,
46 pub original_line_number: Option<usize>,
47 pub modified_line_number: Option<usize>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum DiffLineType {
53 Context, Added, Removed, Header, }
58
59impl DiffViewer {
60 pub fn new(theme: &dyn Theme) -> Self {
62 Self {
63 theme: Box::new(crate::theme::DefaultTheme), current_diff: None,
65 style: DiffStyle::SideBySide,
66 scroll_offset: 0,
67 }
68 }
69
70 pub fn load_diff(&mut self, diff_text: &str) -> Result<()> {
72 let diff_content = self.parse_diff(diff_text)?;
73 self.current_diff = Some(diff_content);
74 self.scroll_offset = 0;
75 Ok(())
76 }
77
78 pub fn set_style(&mut self, style: DiffStyle) {
80 self.style = style;
81 }
82
83 pub fn toggle_style(&mut self) {
85 self.style = match self.style {
86 DiffStyle::Unified => DiffStyle::SideBySide,
87 DiffStyle::SideBySide => DiffStyle::Unified,
88 };
89 }
90
91 pub fn scroll_up(&mut self) {
93 if self.scroll_offset > 0 {
94 self.scroll_offset -= 1;
95 }
96 }
97
98 pub fn scroll_down(&mut self) {
100 if let Some(ref diff) = self.current_diff {
101 let total_lines = diff.hunks.iter().map(|h| h.lines.len()).sum::<usize>();
102 if self.scroll_offset < total_lines.saturating_sub(1) {
103 self.scroll_offset += 1;
104 }
105 }
106 }
107
108 fn parse_diff(&self, diff_text: &str) -> Result<DiffContent> {
110 let mut hunks = Vec::new();
111 let mut current_hunk: Option<DiffHunk> = None;
112 let mut original_file = String::new();
113 let mut modified_file = String::new();
114
115 for line in diff_text.lines() {
116 if line.starts_with("--- ") {
117 original_file = line[4..].to_string();
118 } else if line.starts_with("+++ ") {
119 modified_file = line[4..].to_string();
120 } else if line.starts_with("@@ ") {
121 if let Some(hunk) = current_hunk.take() {
123 hunks.push(hunk);
124 }
125
126 let hunk = self.parse_hunk_header(line)?;
128 current_hunk = Some(hunk);
129 } else if let Some(ref mut hunk) = current_hunk {
130 let diff_line = self.parse_diff_line(line, &hunk.lines)?;
132 hunk.lines.push(diff_line);
133 }
134 }
135
136 if let Some(hunk) = current_hunk {
138 hunks.push(hunk);
139 }
140
141 Ok(DiffContent {
142 original_file,
143 modified_file,
144 hunks,
145 })
146 }
147
148 fn parse_hunk_header(&self, line: &str) -> Result<DiffHunk> {
150 let parts: Vec<&str> = line.split_whitespace().collect();
152 if parts.len() < 3 {
153 return Err(anyhow::anyhow!("Invalid hunk header: {}", line));
154 }
155
156 let original_part = parts[1];
157 let modified_part = parts[2];
158
159 let (original_start, original_count) = self.parse_range(original_part)?;
160 let (modified_start, modified_count) = self.parse_range(modified_part)?;
161
162 Ok(DiffHunk {
163 original_start,
164 original_count,
165 modified_start,
166 modified_count,
167 lines: Vec::new(),
168 })
169 }
170
171 fn parse_range(&self, range: &str) -> Result<(usize, usize)> {
173 let range = &range[1..]; if let Some(comma_pos) = range.find(',') {
176 let start = range[..comma_pos].parse::<usize>()?;
177 let count = range[comma_pos + 1..].parse::<usize>()?;
178 Ok((start, count))
179 } else {
180 let start = range.parse::<usize>()?;
181 Ok((start, 1))
182 }
183 }
184
185 fn parse_diff_line(&self, line: &str, existing_lines: &[DiffLine]) -> Result<DiffLine> {
187 if line.is_empty() {
188 return Ok(DiffLine {
189 line_type: DiffLineType::Context,
190 content: String::new(),
191 original_line_number: None,
192 modified_line_number: None,
193 });
194 }
195
196 let line_type = match line.chars().next().unwrap_or(' ') {
197 '+' => DiffLineType::Added,
198 '-' => DiffLineType::Removed,
199 ' ' => DiffLineType::Context,
200 _ => DiffLineType::Header,
201 };
202
203 let content = if line.len() > 1 {
204 line[1..].to_string()
205 } else {
206 String::new()
207 };
208
209 let (original_line_number, modified_line_number) = match line_type {
211 DiffLineType::Added => (None, Some(existing_lines.len() + 1)),
212 DiffLineType::Removed => (Some(existing_lines.len() + 1), None),
213 DiffLineType::Context => (Some(existing_lines.len() + 1), Some(existing_lines.len() + 1)),
214 DiffLineType::Header => (None, None),
215 };
216
217 Ok(DiffLine {
218 line_type,
219 content,
220 original_line_number,
221 modified_line_number,
222 })
223 }
224
225 pub fn render(&self, renderer: &Renderer, area: Rect) {
227 let title = if let Some(ref diff) = self.current_diff {
228 format!("Diff: {} → {}", diff.original_file, diff.modified_file)
229 } else {
230 "No diff loaded".to_string()
231 };
232
233 let block = Block::default()
234 .title(title)
235 .borders(Borders::ALL)
236 .border_style(Style::default().fg(self.theme.border()));
237
238 renderer.render_widget(block.clone(), area);
239
240 let inner_area = block.inner(area);
241
242 if let Some(ref diff) = self.current_diff {
243 match self.style {
244 DiffStyle::Unified => self.render_unified(renderer, inner_area, diff),
245 DiffStyle::SideBySide => self.render_side_by_side(renderer, inner_area, diff),
246 }
247 } else {
248 let empty_msg = Paragraph::new("No diff loaded")
249 .style(Style::default().fg(self.theme.text_muted()));
250 renderer.render_widget(empty_msg, inner_area);
251 }
252 }
253
254 fn render_unified(&self, renderer: &Renderer, area: Rect, diff: &DiffContent) {
256 let mut lines = Vec::new();
257
258 let mut all_lines = Vec::new();
260 for hunk in &diff.hunks {
261 for line in &hunk.lines {
262 all_lines.push(line);
263 }
264 }
265
266 let visible_height = area.height as usize;
268 let start_line = self.scroll_offset;
269 let end_line = (start_line + visible_height).min(all_lines.len());
270 let visible_lines = &all_lines[start_line..end_line];
271
272 for line in visible_lines {
273 let (prefix, style) = match line.line_type {
274 DiffLineType::Added => ("+", Style::default().fg(self.theme.diff_added())),
275 DiffLineType::Removed => ("-", Style::default().fg(self.theme.diff_removed())),
276 DiffLineType::Context => (" ", Style::default().fg(self.theme.text())),
277 DiffLineType::Header => ("@", Style::default().fg(self.theme.accent())),
278 };
279
280 let formatted_line = Line::from(vec![
281 Span::styled(prefix, style),
282 Span::styled(&line.content, style),
283 ]);
284
285 lines.push(formatted_line);
286 }
287
288 let paragraph = Paragraph::new(lines)
289 .style(Style::default().bg(self.theme.background()));
290
291 renderer.render_widget(paragraph, area);
292 }
293
294 fn render_side_by_side(&self, renderer: &Renderer, area: Rect, diff: &DiffContent) {
296 let chunks = ratatui::layout::Layout::default()
298 .direction(ratatui::layout::Direction::Horizontal)
299 .constraints([
300 ratatui::layout::Constraint::Percentage(50),
301 ratatui::layout::Constraint::Percentage(50),
302 ])
303 .split(area);
304
305 self.render_side(renderer, chunks[0], diff, true);
307
308 self.render_side(renderer, chunks[1], diff, false);
310 }
311
312 fn render_side(&self, renderer: &Renderer, area: Rect, diff: &DiffContent, is_original: bool) {
314 let title = if is_original {
315 &diff.original_file
316 } else {
317 &diff.modified_file
318 };
319
320 let block = Block::default()
321 .title(title)
322 .borders(Borders::ALL)
323 .border_style(Style::default().fg(self.theme.border()));
324
325 renderer.render_widget(block.clone(), area);
326
327 let inner_area = block.inner(area);
328
329 let mut lines = Vec::new();
330
331 let mut all_lines = Vec::new();
333 for hunk in &diff.hunks {
334 for line in &hunk.lines {
335 match (&line.line_type, is_original) {
336 (DiffLineType::Context, _) => all_lines.push(line),
337 (DiffLineType::Added, false) => all_lines.push(line),
338 (DiffLineType::Removed, true) => all_lines.push(line),
339 _ => {
340 if !is_original && line.line_type == DiffLineType::Removed {
342 } else if is_original && line.line_type == DiffLineType::Added {
344 }
346 }
347 }
348 }
349 }
350
351 let visible_height = inner_area.height as usize;
353 let start_line = self.scroll_offset;
354 let end_line = (start_line + visible_height).min(all_lines.len());
355 let visible_lines = &all_lines[start_line..end_line];
356
357 for line in visible_lines {
358 let style = match line.line_type {
359 DiffLineType::Added => Style::default().fg(self.theme.diff_added()),
360 DiffLineType::Removed => Style::default().fg(self.theme.diff_removed()),
361 DiffLineType::Context => Style::default().fg(self.theme.text()),
362 DiffLineType::Header => Style::default().fg(self.theme.accent()),
363 };
364
365 let line_number = if is_original {
366 line.original_line_number
367 } else {
368 line.modified_line_number
369 };
370
371 let formatted_line = if let Some(num) = line_number {
372 Line::from(vec![
373 Span::styled(format!("{:4} ", num), Style::default().fg(self.theme.text_muted())),
374 Span::styled(&line.content, style),
375 ])
376 } else {
377 Line::from(Span::styled(&line.content, style))
378 };
379
380 lines.push(formatted_line);
381 }
382
383 let paragraph = Paragraph::new(lines)
384 .style(Style::default().bg(self.theme.background()));
385
386 renderer.render_widget(paragraph, inner_area);
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn test_diff_parsing() {
396 let diff_text = r#"--- a/file.txt
397+++ b/file.txt
398@@ -1,3 +1,4 @@
399 line 1
400-line 2
401+line 2 modified
402+line 2.5 added
403 line 3"#;
404
405 let theme = crate::theme::DefaultTheme;
406 let mut viewer = DiffViewer::new(&theme);
407 let result = viewer.load_diff(diff_text);
408 assert!(result.is_ok());
409
410 let diff = viewer.current_diff.unwrap();
411 assert_eq!(diff.original_file, "a/file.txt");
412 assert_eq!(diff.modified_file, "b/file.txt");
413 assert_eq!(diff.hunks.len(), 1);
414 assert_eq!(diff.hunks[0].lines.len(), 4);
415 }
416
417 #[test]
418 fn test_range_parsing() {
419 let theme = crate::theme::DefaultTheme;
420 let viewer = DiffViewer::new(&theme);
421
422 assert_eq!(viewer.parse_range("-1,3").unwrap(), (1, 3));
423 assert_eq!(viewer.parse_range("+5,2").unwrap(), (5, 2));
424 assert_eq!(viewer.parse_range("-10").unwrap(), (10, 1));
425 }
426}