use crate::studio::theme;
use crate::studio::utils::{expand_tabs, truncate_width};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineType {
Context,
Added,
Removed,
HunkHeader,
FileHeader,
Empty,
}
impl DiffLineType {
#[must_use]
pub fn style(self) -> Style {
match self {
Self::Context => theme::diff_context(),
Self::Added => theme::diff_added(),
Self::Removed => theme::diff_removed(),
Self::HunkHeader => theme::diff_hunk(),
Self::FileHeader => Style::default()
.fg(theme::text_primary_color())
.add_modifier(Modifier::BOLD),
Self::Empty => Style::default(),
}
}
#[must_use]
pub fn prefix(self) -> &'static str {
match self {
Self::Context => " ",
Self::Added => "+",
Self::Removed => "-",
Self::HunkHeader => "@",
Self::FileHeader => "",
Self::Empty => " ",
}
}
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub line_type: DiffLineType,
pub content: String,
pub old_line_num: Option<usize>,
pub new_line_num: Option<usize>,
}
impl DiffLine {
pub fn context(content: impl Into<String>, old_num: usize, new_num: usize) -> Self {
Self {
line_type: DiffLineType::Context,
content: content.into(),
old_line_num: Some(old_num),
new_line_num: Some(new_num),
}
}
pub fn added(content: impl Into<String>, new_num: usize) -> Self {
Self {
line_type: DiffLineType::Added,
content: content.into(),
old_line_num: None,
new_line_num: Some(new_num),
}
}
pub fn removed(content: impl Into<String>, old_num: usize) -> Self {
Self {
line_type: DiffLineType::Removed,
content: content.into(),
old_line_num: Some(old_num),
new_line_num: None,
}
}
pub fn hunk_header(content: impl Into<String>) -> Self {
Self {
line_type: DiffLineType::HunkHeader,
content: content.into(),
old_line_num: None,
new_line_num: None,
}
}
pub fn file_header(content: impl Into<String>) -> Self {
Self {
line_type: DiffLineType::FileHeader,
content: content.into(),
old_line_num: None,
new_line_num: None,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub header: String,
pub lines: Vec<DiffLine>,
pub old_start: usize,
pub old_count: usize,
pub new_start: usize,
pub new_count: usize,
}
#[derive(Debug, Clone)]
pub struct FileDiff {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub is_new: bool,
pub is_deleted: bool,
pub is_binary: bool,
pub hunks: Vec<DiffHunk>,
}
impl FileDiff {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
old_path: None,
is_new: false,
is_deleted: false,
is_binary: false,
hunks: Vec::new(),
}
}
#[must_use]
pub fn lines_changed(&self) -> (usize, usize) {
let mut added = 0;
let mut removed = 0;
for hunk in &self.hunks {
for line in &hunk.lines {
match line.line_type {
DiffLineType::Added => added += 1,
DiffLineType::Removed => removed += 1,
_ => {}
}
}
}
(added, removed)
}
#[must_use]
pub fn all_lines(&self) -> Vec<DiffLine> {
let mut lines = Vec::new();
let status = if self.is_new {
" (new)"
} else if self.is_deleted {
" (deleted)"
} else {
""
};
lines.push(DiffLine::file_header(format!(
"{}{}",
self.path.display(),
status
)));
if self.is_binary {
lines.push(DiffLine {
line_type: DiffLineType::Empty,
content: "Binary file".to_string(),
old_line_num: None,
new_line_num: None,
});
return lines;
}
for hunk in &self.hunks {
lines.push(DiffLine::hunk_header(&hunk.header));
lines.extend(hunk.lines.clone());
}
lines
}
}
#[derive(Debug, Clone)]
pub struct DiffViewState {
diffs: Vec<FileDiff>,
selected_file: usize,
scroll_offset: usize,
selected_line: usize,
cached_lines: Vec<DiffLine>,
}
impl Default for DiffViewState {
fn default() -> Self {
Self::new()
}
}
impl DiffViewState {
#[must_use]
pub fn new() -> Self {
Self {
diffs: Vec::new(),
selected_file: 0,
scroll_offset: 0,
selected_line: 0,
cached_lines: Vec::new(),
}
}
pub fn set_diffs(&mut self, diffs: Vec<FileDiff>) {
self.diffs = diffs;
self.selected_file = 0;
self.scroll_offset = 0;
self.selected_line = 0;
self.update_cache();
}
fn update_cache(&mut self) {
self.cached_lines = if let Some(diff) = self.diffs.get(self.selected_file) {
diff.all_lines()
} else {
Vec::new()
};
}
#[must_use]
pub fn current_diff(&self) -> Option<&FileDiff> {
self.diffs.get(self.selected_file)
}
#[must_use]
pub fn file_count(&self) -> usize {
self.diffs.len()
}
pub fn next_file(&mut self) {
if self.selected_file + 1 < self.diffs.len() {
self.selected_file += 1;
self.scroll_offset = 0;
self.selected_line = 0;
self.update_cache();
}
}
pub fn prev_file(&mut self) {
if self.selected_file > 0 {
self.selected_file -= 1;
self.scroll_offset = 0;
self.selected_line = 0;
self.update_cache();
}
}
pub fn select_file(&mut self, index: usize) {
if index < self.diffs.len() {
self.selected_file = index;
self.scroll_offset = 0;
self.selected_line = 0;
self.update_cache();
}
}
pub fn scroll_up(&mut self, amount: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
pub fn scroll_down(&mut self, amount: usize) {
let max_offset = self.cached_lines.len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = self.cached_lines.len().saturating_sub(1);
}
pub fn next_hunk(&mut self) {
let lines = &self.cached_lines;
for (i, line) in lines.iter().enumerate().skip(self.scroll_offset + 1) {
if line.line_type == DiffLineType::HunkHeader {
self.scroll_offset = i;
return;
}
}
}
pub fn prev_hunk(&mut self) {
let lines = &self.cached_lines;
for i in (0..self.scroll_offset).rev() {
if lines
.get(i)
.is_some_and(|l| l.line_type == DiffLineType::HunkHeader)
{
self.scroll_offset = i;
return;
}
}
}
#[must_use]
pub fn lines(&self) -> &[DiffLine] {
&self.cached_lines
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
#[must_use]
pub fn selected_file_index(&self) -> usize {
self.selected_file
}
pub fn select_file_by_path(&mut self, path: &std::path::Path) -> bool {
for (i, diff) in self.diffs.iter().enumerate() {
if diff.path == path {
self.select_file(i);
return true;
}
}
false
}
#[must_use]
pub fn file_paths(&self) -> Vec<&std::path::Path> {
self.diffs.iter().map(|d| d.path.as_path()).collect()
}
}
#[must_use]
pub fn parse_diff(diff_text: &str) -> Vec<FileDiff> {
let mut diffs = Vec::new();
let mut current_diff: Option<FileDiff> = None;
let mut current_hunk: Option<DiffHunk> = None;
let mut old_line = 0;
let mut new_line = 0;
for line in diff_text.lines() {
if line.starts_with("diff --git") {
if let Some(mut diff) = current_diff.take() {
if let Some(hunk) = current_hunk.take() {
diff.hunks.push(hunk);
}
diffs.push(diff);
}
let parts: Vec<&str> = line.split(' ').collect();
if parts.len() >= 4 {
let path = parts[3].strip_prefix("b/").unwrap_or(parts[3]);
current_diff = Some(FileDiff::new(path));
}
} else if line.starts_with("new file") {
if let Some(ref mut diff) = current_diff {
diff.is_new = true;
}
} else if line.starts_with("deleted file") {
if let Some(ref mut diff) = current_diff {
diff.is_deleted = true;
}
} else if line.starts_with("Binary files") {
if let Some(ref mut diff) = current_diff {
diff.is_binary = true;
}
} else if line.starts_with("@@") {
if let Some(ref mut diff) = current_diff
&& let Some(hunk) = current_hunk.take()
{
diff.hunks.push(hunk);
}
let mut hunk = DiffHunk {
header: line.to_string(),
lines: Vec::new(),
old_start: 0,
old_count: 0,
new_start: 0,
new_count: 0,
};
if let Some(at_section) = line.strip_prefix("@@ ")
&& let Some(end) = at_section.find(" @@")
{
let range_part = &at_section[..end];
let parts: Vec<&str> = range_part.split(' ').collect();
for part in parts {
if let Some(old) = part.strip_prefix('-') {
let nums: Vec<&str> = old.split(',').collect();
hunk.old_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
hunk.old_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
} else if let Some(new) = part.strip_prefix('+') {
let nums: Vec<&str> = new.split(',').collect();
hunk.new_start = nums.first().and_then(|s| s.parse().ok()).unwrap_or(0);
hunk.new_count = nums.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
}
}
}
old_line = hunk.old_start;
new_line = hunk.new_start;
current_hunk = Some(hunk);
} else if let Some(ref mut hunk) = current_hunk {
let diff_line = if let Some(content) = line.strip_prefix('+') {
let dl = DiffLine::added(content, new_line);
new_line += 1;
dl
} else if let Some(content) = line.strip_prefix('-') {
let dl = DiffLine::removed(content, old_line);
old_line += 1;
dl
} else if let Some(content) = line.strip_prefix(' ') {
let dl = DiffLine::context(content, old_line, new_line);
old_line += 1;
new_line += 1;
dl
} else {
let dl = DiffLine::context(line, old_line, new_line);
old_line += 1;
new_line += 1;
dl
};
hunk.lines.push(diff_line);
}
}
if let Some(mut diff) = current_diff {
if let Some(hunk) = current_hunk {
diff.hunks.push(hunk);
}
diffs.push(diff);
}
diffs
}
pub fn render_diff_view(
frame: &mut Frame,
area: Rect,
state: &DiffViewState,
title: &str,
focused: bool,
) {
let block = Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(if focused {
theme::focused_border()
} else {
theme::unfocused_border()
});
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let visible_height = inner.height as usize;
let lines = state.lines();
let scroll_offset = state.scroll_offset();
let line_num_width = 4;
let display_lines: Vec<Line> = lines
.iter()
.skip(scroll_offset)
.take(visible_height)
.map(|line| render_diff_line(line, line_num_width, inner.width as usize))
.collect();
let paragraph = Paragraph::new(display_lines);
frame.render_widget(paragraph, inner);
if lines.len() > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
let mut scrollbar_state = ScrollbarState::new(lines.len()).position(scroll_offset);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn render_diff_line(line: &DiffLine, line_num_width: usize, width: usize) -> Line<'static> {
let style = line.line_type.style();
match line.line_type {
DiffLineType::FileHeader => {
let expanded = expand_tabs(&line.content, 4);
let content = format!("━━━ {} ", expanded);
let truncated = truncate_width(&content, width);
Line::from(vec![Span::styled(truncated, style)])
}
DiffLineType::HunkHeader => {
let expanded = expand_tabs(&line.content, 4);
let prefix_width = line_num_width * 2 + 4;
let max_content = width.saturating_sub(prefix_width);
let truncated = truncate_width(&expanded, max_content);
Line::from(vec![
Span::styled(
format!("{:>width$} ", "", width = line_num_width * 2 + 3),
Style::default(),
),
Span::styled(truncated, style),
])
}
DiffLineType::Added | DiffLineType::Removed | DiffLineType::Context => {
let old_num = line.old_line_num.map_or_else(
|| " ".repeat(line_num_width),
|n| format!("{:>width$}", n, width = line_num_width),
);
let new_num = line.new_line_num.map_or_else(
|| " ".repeat(line_num_width),
|n| format!("{:>width$}", n, width = line_num_width),
);
let prefix = line.line_type.prefix();
let prefix_style = match line.line_type {
DiffLineType::Added => Style::default()
.fg(theme::success_color())
.add_modifier(Modifier::BOLD),
DiffLineType::Removed => Style::default()
.fg(theme::error_color())
.add_modifier(Modifier::BOLD),
_ => theme::dimmed(),
};
let expanded_content = expand_tabs(&line.content, 4);
let fixed_width = line_num_width * 2 + 6; let max_content = width.saturating_sub(fixed_width);
let truncated = truncate_width(&expanded_content, max_content);
Line::from(vec![
Span::styled(old_num, theme::dimmed()),
Span::styled(" │ ", theme::dimmed()),
Span::styled(new_num, theme::dimmed()),
Span::raw(" "),
Span::styled(prefix, prefix_style),
Span::styled(truncated, style),
])
}
DiffLineType::Empty => Line::from(""),
}
}
#[must_use]
pub fn render_diff_summary(diff: &FileDiff) -> Line<'static> {
let (added, removed) = diff.lines_changed();
let path = diff.path.display().to_string();
let status = if diff.is_new {
Span::styled(" new ", Style::default().fg(theme::success_color()))
} else if diff.is_deleted {
Span::styled(" del ", Style::default().fg(theme::error_color()))
} else {
Span::raw("")
};
Line::from(vec![
Span::styled(path, theme::file_path()),
status,
Span::styled(
format!("+{added}"),
Style::default().fg(theme::success_color()),
),
Span::raw(" "),
Span::styled(
format!("-{removed}"),
Style::default().fg(theme::error_color()),
),
])
}