1use crate::studio::theme;
6use crate::studio::utils::{expand_tabs, truncate_width};
7use ratatui::Frame;
8use ratatui::layout::Rect;
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12 Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
13};
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DiffLineType {
23 Context,
25 Added,
27 Removed,
29 HunkHeader,
31 FileHeader,
33 Empty,
35}
36
37impl DiffLineType {
38 #[must_use]
40 pub fn style(self) -> Style {
41 match self {
42 Self::Context => theme::diff_context(),
43 Self::Added => theme::diff_added(),
44 Self::Removed => theme::diff_removed(),
45 Self::HunkHeader => theme::diff_hunk(),
46 Self::FileHeader => Style::default()
47 .fg(theme::text_primary_color())
48 .add_modifier(Modifier::BOLD),
49 Self::Empty => Style::default(),
50 }
51 }
52
53 #[must_use]
55 pub fn prefix(self) -> &'static str {
56 match self {
57 Self::Context => " ",
58 Self::Added => "+",
59 Self::Removed => "-",
60 Self::HunkHeader => "@",
61 Self::FileHeader => "",
62 Self::Empty => " ",
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
73pub struct DiffLine {
74 pub line_type: DiffLineType,
76 pub content: String,
78 pub old_line_num: Option<usize>,
80 pub new_line_num: Option<usize>,
82}
83
84impl DiffLine {
85 pub fn context(content: impl Into<String>, old_num: usize, new_num: usize) -> Self {
87 Self {
88 line_type: DiffLineType::Context,
89 content: content.into(),
90 old_line_num: Some(old_num),
91 new_line_num: Some(new_num),
92 }
93 }
94
95 pub fn added(content: impl Into<String>, new_num: usize) -> Self {
97 Self {
98 line_type: DiffLineType::Added,
99 content: content.into(),
100 old_line_num: None,
101 new_line_num: Some(new_num),
102 }
103 }
104
105 pub fn removed(content: impl Into<String>, old_num: usize) -> Self {
107 Self {
108 line_type: DiffLineType::Removed,
109 content: content.into(),
110 old_line_num: Some(old_num),
111 new_line_num: None,
112 }
113 }
114
115 pub fn hunk_header(content: impl Into<String>) -> Self {
117 Self {
118 line_type: DiffLineType::HunkHeader,
119 content: content.into(),
120 old_line_num: None,
121 new_line_num: None,
122 }
123 }
124
125 pub fn file_header(content: impl Into<String>) -> Self {
127 Self {
128 line_type: DiffLineType::FileHeader,
129 content: content.into(),
130 old_line_num: None,
131 new_line_num: None,
132 }
133 }
134}
135
136#[derive(Debug, Clone)]
142pub struct DiffHunk {
143 pub header: String,
145 pub lines: Vec<DiffLine>,
147 pub old_start: usize,
149 pub old_count: usize,
151 pub new_start: usize,
153 pub new_count: usize,
155}
156
157#[derive(Debug, Clone)]
163pub struct FileDiff {
164 pub path: PathBuf,
166 pub old_path: Option<PathBuf>,
168 pub is_new: bool,
170 pub is_deleted: bool,
172 pub is_binary: bool,
174 pub hunks: Vec<DiffHunk>,
176}
177
178impl FileDiff {
179 pub fn new(path: impl Into<PathBuf>) -> Self {
181 Self {
182 path: path.into(),
183 old_path: None,
184 is_new: false,
185 is_deleted: false,
186 is_binary: false,
187 hunks: Vec::new(),
188 }
189 }
190
191 #[must_use]
193 pub fn lines_changed(&self) -> (usize, usize) {
194 let mut added = 0;
195 let mut removed = 0;
196 for hunk in &self.hunks {
197 for line in &hunk.lines {
198 match line.line_type {
199 DiffLineType::Added => added += 1,
200 DiffLineType::Removed => removed += 1,
201 _ => {}
202 }
203 }
204 }
205 (added, removed)
206 }
207
208 #[must_use]
210 pub fn all_lines(&self) -> Vec<DiffLine> {
211 let mut lines = Vec::new();
212
213 let status = if self.is_new {
215 " (new)"
216 } else if self.is_deleted {
217 " (deleted)"
218 } else {
219 ""
220 };
221 lines.push(DiffLine::file_header(format!(
222 "{}{}",
223 self.path.display(),
224 status
225 )));
226
227 if self.is_binary {
228 lines.push(DiffLine {
229 line_type: DiffLineType::Empty,
230 content: "Binary file".to_string(),
231 old_line_num: None,
232 new_line_num: None,
233 });
234 return lines;
235 }
236
237 for hunk in &self.hunks {
238 lines.push(DiffLine::hunk_header(&hunk.header));
239 lines.extend(hunk.lines.clone());
240 }
241
242 lines
243 }
244}
245
246#[derive(Debug, Clone)]
252pub struct DiffViewState {
253 diffs: Vec<FileDiff>,
255 selected_file: usize,
257 scroll_offset: usize,
259 selected_line: usize,
261 cached_lines: Vec<DiffLine>,
263}
264
265impl Default for DiffViewState {
266 fn default() -> Self {
267 Self::new()
268 }
269}
270
271impl DiffViewState {
272 #[must_use]
274 pub fn new() -> Self {
275 Self {
276 diffs: Vec::new(),
277 selected_file: 0,
278 scroll_offset: 0,
279 selected_line: 0,
280 cached_lines: Vec::new(),
281 }
282 }
283
284 pub fn set_diffs(&mut self, diffs: Vec<FileDiff>) {
286 self.diffs = diffs;
287 self.selected_file = 0;
288 self.scroll_offset = 0;
289 self.selected_line = 0;
290 self.update_cache();
291 }
292
293 fn update_cache(&mut self) {
295 self.cached_lines = if let Some(diff) = self.diffs.get(self.selected_file) {
296 diff.all_lines()
297 } else {
298 Vec::new()
299 };
300 }
301
302 #[must_use]
304 pub fn current_diff(&self) -> Option<&FileDiff> {
305 self.diffs.get(self.selected_file)
306 }
307
308 #[must_use]
310 pub fn file_count(&self) -> usize {
311 self.diffs.len()
312 }
313
314 pub fn next_file(&mut self) {
316 if self.selected_file + 1 < self.diffs.len() {
317 self.selected_file += 1;
318 self.scroll_offset = 0;
319 self.selected_line = 0;
320 self.update_cache();
321 }
322 }
323
324 pub fn prev_file(&mut self) {
326 if self.selected_file > 0 {
327 self.selected_file -= 1;
328 self.scroll_offset = 0;
329 self.selected_line = 0;
330 self.update_cache();
331 }
332 }
333
334 pub fn select_file(&mut self, index: usize) {
336 if index < self.diffs.len() {
337 self.selected_file = index;
338 self.scroll_offset = 0;
339 self.selected_line = 0;
340 self.update_cache();
341 }
342 }
343
344 pub fn scroll_up(&mut self, amount: usize) {
346 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
347 }
348
349 pub fn scroll_down(&mut self, amount: usize) {
351 let max_offset = self.cached_lines.len().saturating_sub(1);
352 self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
353 }
354
355 pub fn scroll_to_top(&mut self) {
357 self.scroll_offset = 0;
358 }
359
360 pub fn scroll_to_bottom(&mut self) {
362 self.scroll_offset = self.cached_lines.len().saturating_sub(1);
363 }
364
365 pub fn next_hunk(&mut self) {
367 let lines = &self.cached_lines;
368 for (i, line) in lines.iter().enumerate().skip(self.scroll_offset + 1) {
369 if line.line_type == DiffLineType::HunkHeader {
370 self.scroll_offset = i;
371 return;
372 }
373 }
374 }
375
376 pub fn prev_hunk(&mut self) {
378 let lines = &self.cached_lines;
379 for i in (0..self.scroll_offset).rev() {
380 if lines
381 .get(i)
382 .is_some_and(|l| l.line_type == DiffLineType::HunkHeader)
383 {
384 self.scroll_offset = i;
385 return;
386 }
387 }
388 }
389
390 #[must_use]
392 pub fn lines(&self) -> &[DiffLine] {
393 &self.cached_lines
394 }
395
396 #[must_use]
398 pub fn scroll_offset(&self) -> usize {
399 self.scroll_offset
400 }
401
402 #[must_use]
404 pub fn selected_file_index(&self) -> usize {
405 self.selected_file
406 }
407
408 pub fn select_file_by_path(&mut self, path: &std::path::Path) -> bool {
410 for (i, diff) in self.diffs.iter().enumerate() {
411 if diff.path == path {
412 self.select_file(i);
413 return true;
414 }
415 }
416 false
417 }
418
419 #[must_use]
421 pub fn file_paths(&self) -> Vec<&std::path::Path> {
422 self.diffs.iter().map(|d| d.path.as_path()).collect()
423 }
424}
425
426#[must_use]
432pub fn parse_diff(diff_text: &str) -> Vec<FileDiff> {
433 let mut diffs = Vec::new();
434 let mut current_diff: Option<FileDiff> = None;
435 let mut current_hunk: Option<DiffHunk> = None;
436 let mut old_line = 0;
437 let mut new_line = 0;
438
439 for line in diff_text.lines() {
440 if line.starts_with("diff --git") {
441 if let Some(mut diff) = current_diff.take() {
443 if let Some(hunk) = current_hunk.take() {
444 diff.hunks.push(hunk);
445 }
446 diffs.push(diff);
447 }
448
449 let parts: Vec<&str> = line.split(' ').collect();
451 if parts.len() >= 4 {
452 let path = parts[3].strip_prefix("b/").unwrap_or(parts[3]);
453 current_diff = Some(FileDiff::new(path));
454 }
455 } else if line.starts_with("new file") {
456 if let Some(ref mut diff) = current_diff {
457 diff.is_new = true;
458 }
459 } else if line.starts_with("deleted file") {
460 if let Some(ref mut diff) = current_diff {
461 diff.is_deleted = true;
462 }
463 } else if line.starts_with("Binary files") {
464 if let Some(ref mut diff) = current_diff {
465 diff.is_binary = true;
466 }
467 } else if line.starts_with("@@") {
468 if let Some(ref mut diff) = current_diff
470 && let Some(hunk) = current_hunk.take()
471 {
472 diff.hunks.push(hunk);
473 }
474
475 let mut hunk = DiffHunk {
477 header: line.to_string(),
478 lines: Vec::new(),
479 old_start: 0,
480 old_count: 0,
481 new_start: 0,
482 new_count: 0,
483 };
484
485 if let Some(at_section) = line.strip_prefix("@@ ")
487 && let Some(end) = at_section.find(" @@")
488 {
489 let range_part = &at_section[..end];
490 let parts: Vec<&str> = range_part.split(' ').collect();
491
492 for part in parts {
493 if let Some(old) = part.strip_prefix('-') {
494 let nums: Vec<&str> = old.split(',').collect();
495 hunk.old_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
496 hunk.old_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
497 } else if let Some(new) = part.strip_prefix('+') {
498 let nums: Vec<&str> = new.split(',').collect();
499 hunk.new_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
500 hunk.new_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
501 }
502 }
503 }
504
505 old_line = hunk.old_start;
506 new_line = hunk.new_start;
507 current_hunk = Some(hunk);
508 } else if let Some(ref mut hunk) = current_hunk {
509 let diff_line = if let Some(content) = line.strip_prefix('+') {
510 let dl = DiffLine::added(content, new_line);
511 new_line += 1;
512 dl
513 } else if let Some(content) = line.strip_prefix('-') {
514 let dl = DiffLine::removed(content, old_line);
515 old_line += 1;
516 dl
517 } else if let Some(content) = line.strip_prefix(' ') {
518 let dl = DiffLine::context(content, old_line, new_line);
519 old_line += 1;
520 new_line += 1;
521 dl
522 } else {
523 let dl = DiffLine::context(line, old_line, new_line);
525 old_line += 1;
526 new_line += 1;
527 dl
528 };
529 hunk.lines.push(diff_line);
530 }
531 }
532
533 if let Some(mut diff) = current_diff {
535 if let Some(hunk) = current_hunk {
536 diff.hunks.push(hunk);
537 }
538 diffs.push(diff);
539 }
540
541 diffs
542}
543
544pub fn render_diff_view(
550 frame: &mut Frame,
551 area: Rect,
552 state: &DiffViewState,
553 title: &str,
554 focused: bool,
555) {
556 let block = Block::default()
557 .title(format!(" {} ", title))
558 .borders(Borders::ALL)
559 .border_style(if focused {
560 theme::focused_border()
561 } else {
562 theme::unfocused_border()
563 });
564
565 let inner = block.inner(area);
566 frame.render_widget(block, area);
567
568 if inner.height == 0 || inner.width == 0 {
569 return;
570 }
571
572 let visible_height = inner.height as usize;
573 let lines = state.lines();
574 let scroll_offset = state.scroll_offset();
575 let line_num_width = 4; let display_lines: Vec<Line> = lines
578 .iter()
579 .skip(scroll_offset)
580 .take(visible_height)
581 .map(|line| render_diff_line(line, line_num_width, inner.width as usize))
582 .collect();
583
584 let paragraph = Paragraph::new(display_lines);
585 frame.render_widget(paragraph, inner);
586
587 if lines.len() > visible_height {
589 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
590 .begin_symbol(None)
591 .end_symbol(None);
592
593 let mut scrollbar_state = ScrollbarState::new(lines.len()).position(scroll_offset);
594
595 frame.render_stateful_widget(
596 scrollbar,
597 area.inner(ratatui::layout::Margin {
598 vertical: 1,
599 horizontal: 0,
600 }),
601 &mut scrollbar_state,
602 );
603 }
604}
605
606fn render_diff_line(line: &DiffLine, line_num_width: usize, width: usize) -> Line<'static> {
608 let style = line.line_type.style();
609
610 match line.line_type {
611 DiffLineType::FileHeader => {
612 let expanded = expand_tabs(&line.content, 4);
613 let content = format!("━━━ {} ", expanded);
614 let truncated = truncate_width(&content, width);
615 Line::from(vec![Span::styled(truncated, style)])
616 }
617 DiffLineType::HunkHeader => {
618 let expanded = expand_tabs(&line.content, 4);
620 let prefix_width = line_num_width * 2 + 4;
621 let max_content = width.saturating_sub(prefix_width);
622 let truncated = truncate_width(&expanded, max_content);
623 Line::from(vec![
624 Span::styled(
625 format!("{:>width$} ", "", width = line_num_width * 2 + 3),
626 Style::default(),
627 ),
628 Span::styled(truncated, style),
629 ])
630 }
631 DiffLineType::Added | DiffLineType::Removed | DiffLineType::Context => {
632 let old_num = line.old_line_num.map_or_else(
633 || " ".repeat(line_num_width),
634 |n| format!("{:>width$}", n, width = line_num_width),
635 );
636
637 let new_num = line.new_line_num.map_or_else(
638 || " ".repeat(line_num_width),
639 |n| format!("{:>width$}", n, width = line_num_width),
640 );
641
642 let prefix = line.line_type.prefix();
643 let prefix_style = match line.line_type {
644 DiffLineType::Added => Style::default()
645 .fg(theme::success_color())
646 .add_modifier(Modifier::BOLD),
647 DiffLineType::Removed => Style::default()
648 .fg(theme::error_color())
649 .add_modifier(Modifier::BOLD),
650 _ => theme::dimmed(),
651 };
652
653 let expanded_content = expand_tabs(&line.content, 4);
655
656 let fixed_width = line_num_width * 2 + 6; let max_content = width.saturating_sub(fixed_width);
660 let truncated = truncate_width(&expanded_content, max_content);
661
662 Line::from(vec![
663 Span::styled(old_num, theme::dimmed()),
664 Span::styled(" │ ", theme::dimmed()),
665 Span::styled(new_num, theme::dimmed()),
666 Span::raw(" "),
667 Span::styled(prefix, prefix_style),
668 Span::styled(truncated, style),
669 ])
670 }
671 DiffLineType::Empty => Line::from(""),
672 }
673}
674
675#[must_use]
677pub fn render_diff_summary(diff: &FileDiff) -> Line<'static> {
678 let (added, removed) = diff.lines_changed();
679 let path = diff.path.display().to_string();
680
681 let status = if diff.is_new {
682 Span::styled(" new ", Style::default().fg(theme::success_color()))
683 } else if diff.is_deleted {
684 Span::styled(" del ", Style::default().fg(theme::error_color()))
685 } else {
686 Span::raw("")
687 };
688
689 Line::from(vec![
690 Span::styled(path, theme::file_path()),
691 status,
692 Span::styled(
693 format!("+{added}"),
694 Style::default().fg(theme::success_color()),
695 ),
696 Span::raw(" "),
697 Span::styled(
698 format!("-{removed}"),
699 Style::default().fg(theme::error_color()),
700 ),
701 ])
702}