5a9d20a5c4
Features: - Add MCP server mode (deepseek serve --mcp) - Add file_search tool for codebase exploration - Add PR review tool for GitHub PR reviews - Add session picker for managing chat sessions - Add pager view for reading long content - Add onboarding flow for new users - Add Windows sandbox support (Landlock) - Add diff and markdown rendering Improvements: - Refactor apply_patch tool with multi-file diff support - Extract execpolicy matcher and rules modules - Improve TUI with selection, history, and streaming Bug fixes: - Fix multi-file patch parsing to correctly handle file boundaries 🤖 Generated with [Claude Code](https://claude.com/claude-code)
219 lines
6.3 KiB
Rust
219 lines
6.3 KiB
Rust
//! Diff rendering helpers for TUI previews.
|
|
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::palette;
|
|
|
|
const LINE_NUMBER_WIDTH: usize = 4;
|
|
|
|
pub fn render_diff(diff: &str, width: u16) -> Vec<Line<'static>> {
|
|
let mut lines = Vec::new();
|
|
let mut old_line: Option<usize> = None;
|
|
let mut new_line: Option<usize> = None;
|
|
|
|
for raw in diff.lines() {
|
|
if raw.starts_with("diff --git") || raw.starts_with("index ") {
|
|
lines.extend(render_header_line(raw, width));
|
|
continue;
|
|
}
|
|
|
|
if raw.starts_with("--- ") || raw.starts_with("+++ ") {
|
|
lines.extend(render_header_line(raw, width));
|
|
continue;
|
|
}
|
|
|
|
if raw.starts_with("@@") {
|
|
if let Some((old_start, new_start)) = parse_hunk_header(raw) {
|
|
old_line = Some(old_start);
|
|
new_line = Some(new_start);
|
|
}
|
|
lines.extend(render_hunk_header(raw, width));
|
|
continue;
|
|
}
|
|
|
|
if raw.starts_with('+') && !raw.starts_with("+++") {
|
|
let content = raw.trim_start_matches('+');
|
|
lines.extend(render_diff_line(
|
|
content,
|
|
width,
|
|
old_line,
|
|
new_line,
|
|
Style::default().fg(palette::STATUS_SUCCESS),
|
|
));
|
|
if let Some(line) = new_line.as_mut() {
|
|
*line = line.saturating_add(1);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if raw.starts_with('-') && !raw.starts_with("---") {
|
|
let content = raw.trim_start_matches('-');
|
|
lines.extend(render_diff_line(
|
|
content,
|
|
width,
|
|
old_line,
|
|
new_line,
|
|
Style::default().fg(palette::STATUS_ERROR),
|
|
));
|
|
if let Some(line) = old_line.as_mut() {
|
|
*line = line.saturating_add(1);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if raw.starts_with(' ') {
|
|
let content = raw.trim_start_matches(' ');
|
|
lines.extend(render_diff_line(
|
|
content,
|
|
width,
|
|
old_line,
|
|
new_line,
|
|
Style::default().fg(palette::TEXT_PRIMARY),
|
|
));
|
|
if let Some(line) = old_line.as_mut() {
|
|
*line = line.saturating_add(1);
|
|
}
|
|
if let Some(line) = new_line.as_mut() {
|
|
*line = line.saturating_add(1);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
lines.extend(render_header_line(raw, width));
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() < 3 {
|
|
return None;
|
|
}
|
|
let old = parts[1].trim_start_matches('-');
|
|
let new = parts[2].trim_start_matches('+');
|
|
let old_start = old.split(',').next()?.parse::<usize>().ok()?;
|
|
let new_start = new.split(',').next()?.parse::<usize>().ok()?;
|
|
Some((old_start, new_start))
|
|
}
|
|
|
|
fn render_header_line(line: &str, width: u16) -> Vec<Line<'static>> {
|
|
let style = Style::default()
|
|
.fg(palette::DEEPSEEK_SKY)
|
|
.add_modifier(Modifier::BOLD);
|
|
wrap_with_style(line, style, width)
|
|
}
|
|
|
|
fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
|
|
let style = Style::default().fg(palette::DEEPSEEK_BLUE);
|
|
wrap_with_style(line, style, width)
|
|
}
|
|
|
|
fn render_diff_line(
|
|
content: &str,
|
|
width: u16,
|
|
old_line: Option<usize>,
|
|
new_line: Option<usize>,
|
|
style: Style,
|
|
) -> Vec<Line<'static>> {
|
|
let prefix = format_line_numbers(old_line, new_line);
|
|
let prefix_width = prefix.width();
|
|
let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
|
|
let wrapped = wrap_text(content, available);
|
|
|
|
let mut out = Vec::new();
|
|
for (idx, chunk) in wrapped.into_iter().enumerate() {
|
|
if idx == 0 {
|
|
out.push(Line::from(vec![
|
|
Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)),
|
|
Span::styled(chunk, style),
|
|
]));
|
|
} else {
|
|
out.push(Line::from(vec![
|
|
Span::raw(" ".repeat(prefix_width)),
|
|
Span::styled(chunk, style),
|
|
]));
|
|
}
|
|
}
|
|
|
|
if out.is_empty() {
|
|
out.push(Line::from(vec![Span::styled(
|
|
prefix,
|
|
Style::default().fg(palette::TEXT_MUTED),
|
|
)]));
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>) -> String {
|
|
let old = old_line
|
|
.map(|value| {
|
|
format!(
|
|
"{value:>LINE_NUMBER_WIDTH$}",
|
|
LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH
|
|
)
|
|
})
|
|
.unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
|
|
let new = new_line
|
|
.map(|value| {
|
|
format!(
|
|
"{value:>LINE_NUMBER_WIDTH$}",
|
|
LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH
|
|
)
|
|
})
|
|
.unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
|
|
format!("{old} {new} | ")
|
|
}
|
|
|
|
fn wrap_with_style(text: &str, style: Style, width: u16) -> Vec<Line<'static>> {
|
|
let mut out = Vec::new();
|
|
for part in wrap_text(text, width.max(1) as usize) {
|
|
out.push(Line::from(Span::styled(part, style)));
|
|
}
|
|
if out.is_empty() {
|
|
out.push(Line::from(Span::styled("", style)));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
|
if width == 0 {
|
|
return vec![text.to_string()];
|
|
}
|
|
let mut lines = Vec::new();
|
|
let mut current = String::new();
|
|
let mut current_width = 0;
|
|
|
|
for word in text.split_whitespace() {
|
|
let word_width = word.width();
|
|
let additional = if current.is_empty() {
|
|
word_width
|
|
} else {
|
|
word_width + 1
|
|
};
|
|
if current_width + additional > width && !current.is_empty() {
|
|
lines.push(current);
|
|
current = word.to_string();
|
|
current_width = word_width;
|
|
} else {
|
|
if !current.is_empty() {
|
|
current.push(' ');
|
|
current_width += 1;
|
|
}
|
|
current.push_str(word);
|
|
current_width += word_width;
|
|
}
|
|
}
|
|
|
|
if current.is_empty() {
|
|
lines.push(String::new());
|
|
} else {
|
|
lines.push(current);
|
|
}
|
|
|
|
lines
|
|
}
|