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