feat(tui): implement diff visualization with colors and indicators
Add diff_viewer.rs with enhanced diff display: - DiffColors with GitHub-inspired color scheme (#2ea043, #f85149) - DiffViewer widget with configurable options - Line number columns (old/new) - Gutter indicators (+/-) - File headers with path display - Multiple color schemes (default, minimal, high-contrast) - Helper functions render_diff() and render_diff_minimal() - 11 unit tests Closes #16 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e24069ca79
commit
9f869f93fa
2 changed files with 424 additions and 0 deletions
422
crates/miyabi-tui/src/diff_viewer.rs
Normal file
422
crates/miyabi-tui/src/diff_viewer.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
//! Diff Viewer Widget
|
||||
//!
|
||||
//! This module provides an enhanced diff visualization with proper colors,
|
||||
//! line numbers, and indicators for a professional git diff display.
|
||||
|
||||
use crate::diff_render::{DiffRender, DiffLine, DiffLineType};
|
||||
use ratatui::{
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
};
|
||||
|
||||
/// Color scheme for diff visualization
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffColors {
|
||||
/// Addition text color
|
||||
pub addition_fg: Color,
|
||||
/// Addition background color
|
||||
pub addition_bg: Option<Color>,
|
||||
/// Deletion text color
|
||||
pub deletion_fg: Color,
|
||||
/// Deletion background color
|
||||
pub deletion_bg: Option<Color>,
|
||||
/// Context text color
|
||||
pub context_fg: Color,
|
||||
/// Hunk header color
|
||||
pub hunk_header_fg: Color,
|
||||
/// Line number color
|
||||
pub line_number_fg: Color,
|
||||
/// Gutter indicator color for additions
|
||||
pub gutter_add_fg: Color,
|
||||
/// Gutter indicator color for deletions
|
||||
pub gutter_del_fg: Color,
|
||||
}
|
||||
|
||||
impl Default for DiffColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// GitHub-inspired colors
|
||||
addition_fg: Color::Rgb(0x2e, 0xa0, 0x43), // #2ea043
|
||||
addition_bg: Some(Color::Rgb(0x0d, 0x11, 0x17)),
|
||||
deletion_fg: Color::Rgb(0xf8, 0x51, 0x49), // #f85149
|
||||
deletion_bg: Some(Color::Rgb(0x11, 0x0d, 0x0d)),
|
||||
context_fg: Color::Gray,
|
||||
hunk_header_fg: Color::Cyan,
|
||||
line_number_fg: Color::DarkGray,
|
||||
gutter_add_fg: Color::Green,
|
||||
gutter_del_fg: Color::Red,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffColors {
|
||||
/// Create a minimal color scheme (no backgrounds)
|
||||
pub fn minimal() -> Self {
|
||||
Self {
|
||||
addition_bg: None,
|
||||
deletion_bg: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a high contrast color scheme
|
||||
pub fn high_contrast() -> Self {
|
||||
Self {
|
||||
addition_fg: Color::LightGreen,
|
||||
deletion_fg: Color::LightRed,
|
||||
context_fg: Color::White,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for diff viewer
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffViewerOptions {
|
||||
/// Color scheme to use
|
||||
pub colors: DiffColors,
|
||||
/// Show line numbers
|
||||
pub show_line_numbers: bool,
|
||||
/// Line number width (characters)
|
||||
pub line_number_width: usize,
|
||||
/// Show gutter indicators (+/-)
|
||||
pub show_gutter: bool,
|
||||
/// Show file headers
|
||||
pub show_file_headers: bool,
|
||||
}
|
||||
|
||||
impl Default for DiffViewerOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
colors: DiffColors::default(),
|
||||
show_line_numbers: true,
|
||||
line_number_width: 4,
|
||||
show_gutter: true,
|
||||
show_file_headers: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff viewer that renders parsed diffs with enhanced visualization
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffViewer {
|
||||
/// Parsed diff data
|
||||
diff: DiffRender,
|
||||
/// Viewer options
|
||||
options: DiffViewerOptions,
|
||||
}
|
||||
|
||||
impl Default for DiffViewer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffViewer {
|
||||
/// Create a new diff viewer
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
diff: DiffRender::new(),
|
||||
options: DiffViewerOptions::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom options
|
||||
pub fn with_options(options: DiffViewerOptions) -> Self {
|
||||
Self {
|
||||
diff: DiffRender::new(),
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the diff data
|
||||
pub fn set_diff(&mut self, diff: DiffRender) {
|
||||
self.diff = diff;
|
||||
}
|
||||
|
||||
/// Parse diff text
|
||||
pub fn parse(&mut self, diff_text: &str) -> &mut Self {
|
||||
self.diff.parse(diff_text);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get line count
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.diff.line_count()
|
||||
}
|
||||
|
||||
/// Check if empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.diff.is_empty()
|
||||
}
|
||||
|
||||
/// Render to styled lines
|
||||
pub fn render(&self) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if self.diff.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No changes",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
return lines;
|
||||
}
|
||||
|
||||
for file in &self.diff.files {
|
||||
// File header
|
||||
if self.options.show_file_headers {
|
||||
lines.push(self.render_file_header(&file.old_path, &file.new_path));
|
||||
}
|
||||
|
||||
for hunk in &file.hunks {
|
||||
for diff_line in &hunk.lines {
|
||||
lines.push(self.render_line(diff_line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Render file header
|
||||
fn render_file_header(&self, old_path: &str, new_path: &str) -> Line<'static> {
|
||||
let content = if old_path == new_path {
|
||||
format!(" {} ", new_path)
|
||||
} else {
|
||||
format!(" {} → {} ", old_path, new_path)
|
||||
};
|
||||
|
||||
Line::from(Span::styled(
|
||||
content,
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
}
|
||||
|
||||
/// Render a single diff line
|
||||
fn render_line(&self, line: &DiffLine) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Line numbers
|
||||
if self.options.show_line_numbers {
|
||||
let (old_num, new_num) = self.format_line_numbers(line);
|
||||
spans.push(Span::styled(
|
||||
old_num,
|
||||
Style::default().fg(self.options.colors.line_number_fg),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
new_num,
|
||||
Style::default().fg(self.options.colors.line_number_fg),
|
||||
));
|
||||
}
|
||||
|
||||
// Gutter indicator
|
||||
if self.options.show_gutter {
|
||||
let (indicator, color) = match line.line_type {
|
||||
DiffLineType::Addition => ("+", self.options.colors.gutter_add_fg),
|
||||
DiffLineType::Deletion => ("-", self.options.colors.gutter_del_fg),
|
||||
DiffLineType::Context => (" ", self.options.colors.context_fg),
|
||||
DiffLineType::HunkHeader => ("@", self.options.colors.hunk_header_fg),
|
||||
DiffLineType::FileHeader => (" ", Color::White),
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!("{} ", indicator),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
}
|
||||
|
||||
// Content
|
||||
let (content, style) = match line.line_type {
|
||||
DiffLineType::Addition => {
|
||||
let mut style = Style::default().fg(self.options.colors.addition_fg);
|
||||
if let Some(bg) = self.options.colors.addition_bg {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
(line.content.clone(), style)
|
||||
}
|
||||
DiffLineType::Deletion => {
|
||||
let mut style = Style::default().fg(self.options.colors.deletion_fg);
|
||||
if let Some(bg) = self.options.colors.deletion_bg {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
(line.content.clone(), style)
|
||||
}
|
||||
DiffLineType::Context => (
|
||||
line.content.clone(),
|
||||
Style::default().fg(self.options.colors.context_fg),
|
||||
),
|
||||
DiffLineType::HunkHeader => (
|
||||
line.content.clone(),
|
||||
Style::default()
|
||||
.fg(self.options.colors.hunk_header_fg)
|
||||
.add_modifier(Modifier::DIM),
|
||||
),
|
||||
DiffLineType::FileHeader => (
|
||||
line.content.clone(),
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
};
|
||||
|
||||
spans.push(Span::styled(content, style));
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// Format line numbers
|
||||
fn format_line_numbers(&self, line: &DiffLine) -> (String, String) {
|
||||
let width = self.options.line_number_width;
|
||||
|
||||
let old = match line.old_line_num {
|
||||
Some(n) => format!("{:>width$} ", n, width = width),
|
||||
None => format!("{:>width$} ", "", width = width),
|
||||
};
|
||||
|
||||
let new = match line.new_line_num {
|
||||
Some(n) => format!("{:>width$} ", n, width = width),
|
||||
None => format!("{:>width$} ", "", width = width),
|
||||
};
|
||||
|
||||
(old, new)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a diff string to styled lines with default options
|
||||
pub fn render_diff(diff_text: &str) -> Vec<Line<'static>> {
|
||||
let mut viewer = DiffViewer::new();
|
||||
viewer.parse(diff_text);
|
||||
viewer.render()
|
||||
}
|
||||
|
||||
/// Render a diff string with minimal options (no backgrounds)
|
||||
pub fn render_diff_minimal(diff_text: &str) -> Vec<Line<'static>> {
|
||||
let options = DiffViewerOptions {
|
||||
colors: DiffColors::minimal(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut viewer = DiffViewer::with_options(options);
|
||||
viewer.parse(diff_text);
|
||||
viewer.render()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_DIFF: &str = r#"diff --git a/src/main.rs b/src/main.rs
|
||||
--- a/src/main.rs
|
||||
+++ b/src/main.rs
|
||||
@@ -1,4 +1,5 @@
|
||||
fn main() {
|
||||
- println!("Hello");
|
||||
+ println!("Hello, World!");
|
||||
+ println!("Goodbye");
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_viewer_creation() {
|
||||
let viewer = DiffViewer::new();
|
||||
assert!(viewer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_and_render() {
|
||||
let mut viewer = DiffViewer::new();
|
||||
viewer.parse(SAMPLE_DIFF);
|
||||
|
||||
assert!(!viewer.is_empty());
|
||||
let lines = viewer.render();
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_colors() {
|
||||
let colors = DiffColors {
|
||||
addition_fg: Color::LightGreen,
|
||||
deletion_fg: Color::LightRed,
|
||||
..Default::default()
|
||||
};
|
||||
let options = DiffViewerOptions {
|
||||
colors,
|
||||
..Default::default()
|
||||
};
|
||||
let mut viewer = DiffViewer::with_options(options);
|
||||
viewer.parse(SAMPLE_DIFF);
|
||||
|
||||
let lines = viewer.render();
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_colors() {
|
||||
let colors = DiffColors::minimal();
|
||||
assert!(colors.addition_bg.is_none());
|
||||
assert!(colors.deletion_bg.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_high_contrast() {
|
||||
let colors = DiffColors::high_contrast();
|
||||
assert_eq!(colors.addition_fg, Color::LightGreen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_diff_helper() {
|
||||
let lines = render_diff(SAMPLE_DIFF);
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_diff_minimal_helper() {
|
||||
let lines = render_diff_minimal(SAMPLE_DIFF);
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_diff() {
|
||||
let viewer = DiffViewer::new();
|
||||
let lines = viewer.render();
|
||||
|
||||
assert_eq!(lines.len(), 1);
|
||||
// Should show "No changes"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_options_default() {
|
||||
let options = DiffViewerOptions::default();
|
||||
assert!(options.show_line_numbers);
|
||||
assert!(options.show_gutter);
|
||||
assert!(options.show_file_headers);
|
||||
assert_eq!(options.line_number_width, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_line_numbers() {
|
||||
let options = DiffViewerOptions {
|
||||
show_line_numbers: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut viewer = DiffViewer::with_options(options);
|
||||
viewer.parse(SAMPLE_DIFF);
|
||||
|
||||
let lines = viewer.render();
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_gutter() {
|
||||
let options = DiffViewerOptions {
|
||||
show_gutter: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut viewer = DiffViewer::with_options(options);
|
||||
viewer.parse(SAMPLE_DIFF);
|
||||
|
||||
let lines = viewer.render();
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub mod history_cell;
|
|||
pub mod markdown_render;
|
||||
pub mod markdown_stream;
|
||||
pub mod diff_render;
|
||||
pub mod diff_viewer;
|
||||
pub mod markdown_parser;
|
||||
|
||||
pub use app::App;
|
||||
|
|
@ -19,4 +20,5 @@ pub use history_cell::{HistoryCell, UserMessageCell, AssistantMessageCell, ToolR
|
|||
pub use markdown_render::{MarkdownRenderer, MarkdownStyles};
|
||||
pub use markdown_stream::{MarkdownStream, StreamState, StreamBuffer};
|
||||
pub use diff_render::{DiffRender, DiffLine, DiffLineType, DiffHunk, FileDiff};
|
||||
pub use diff_viewer::{DiffViewer, DiffViewerOptions, DiffColors, render_diff, render_diff_minimal};
|
||||
pub use markdown_parser::MarkdownParser;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue