"""
HTML formatter for generating GitHub-style diff output.
Generates complete HTML documents with embedded CSS for both
unified and side-by-side diff views.
"""
import html
from typing import Dict, Optional, Union
from .diff_engine import DiffResult, DiffLine
# Theme color constants (dark theme matching ComfyUI interface)
COLOR_BG_DARK = "#1e1e1e"
COLOR_BG_HEADER = "#252526"
COLOR_BORDER = "#3c3c3c"
COLOR_TEXT = "#d4d4d4"
COLOR_TEXT_MUTED = "#9d9d9d"
COLOR_LINE_NUM = "#6e7681"
COLOR_ADDED = "#4ec9b0"
COLOR_DELETED = "#f14c4c"
COLOR_ADDED_BG = "#1e3a1e"
COLOR_DELETED_BG = "#3a1e1e"
COLOR_CHAR_ADDED_BG = "#2d5a2d"
COLOR_CHAR_DELETED_BG = "#5a2d2d"
# Dark theme CSS for diff display
DIFF_CSS = """
"""
def format_diff_html(diff_result: DiffResult, view_mode: str = "unified") -> str:
"""
Generate complete HTML for diff display.
Args:
diff_result: The computed diff result
view_mode: 'unified' or 'side_by_side'
Returns:
Complete HTML document string
"""
if view_mode == "side_by_side":
return format_side_by_side_view(diff_result)
return format_unified_view(diff_result)
def format_unified_view(diff_result: DiffResult) -> str:
"""
Generate unified diff view (similar to git diff).
Shows all lines in a single column with +/- prefixes.
"""
stats = diff_result.stats
header = _generate_header(stats)
# Check for no changes
if stats["additions"] == 0 and stats["deletions"] == 0:
return _generate_html_document(
f"""
{header}
""",
"unified",
)
rows = []
for line in diff_result.lines:
row_class = _get_row_class(line.change_type)
line_num_a = line.line_num_a if line.line_num_a is not None else ""
line_num_b = line.line_num_b if line.line_num_b is not None else ""
content = _format_content_with_char_diffs(line)
prefix = _get_prefix(line.change_type)
rows.append(
f"""
| {line_num_a} |
{line_num_b} |
{prefix}{content} |
"""
)
table = f"""
"""
return _generate_html_document(f"{header}{table}", "unified")
def format_side_by_side_view(diff_result: DiffResult) -> str:
"""
Generate side-by-side diff view.
Shows original and modified text in two columns.
"""
stats = diff_result.stats
header = _generate_header(stats)
# Check for no changes
if stats["additions"] == 0 and stats["deletions"] == 0:
return _generate_html_document(
f"""
{header}
""",
"side-by-side",
)
# Group lines for side-by-side display
rows = []
i = 0
lines = diff_result.lines
while i < len(lines):
line = lines[i]
if line.change_type == "unchanged":
# Unchanged line - show on both sides
content = _format_content_with_char_diffs(line)
rows.append(_create_side_by_side_row(
line.line_num_a, content,
line.line_num_b, content,
"line-unchanged"
))
i += 1
elif line.change_type == "deleted":
# Check if this is a paired line from a replace opcode AND next line is its pair
if (line.is_paired and
i + 1 < len(lines) and
lines[i + 1].change_type == "added" and
lines[i + 1].is_paired):
# Paired deletion and addition from same replace opcode
del_line = line
add_line = lines[i + 1]
left_content = _format_content_with_char_diffs(del_line, for_deleted=True)
right_content = _format_content_with_char_diffs(add_line, for_added=True)
rows.append(_create_side_by_side_row(
del_line.line_num_a, left_content,
add_line.line_num_b, right_content,
"line-modified"
))
i += 2
else:
# Deletion only
content = _format_content_with_char_diffs(line, for_deleted=True)
rows.append(_create_side_by_side_row(
line.line_num_a, content,
"", "",
"line-deleted"
))
i += 1
elif line.change_type == "added":
# Addition only (no paired deletion)
content = _format_content_with_char_diffs(line, for_added=True)
rows.append(_create_side_by_side_row(
"", "",
line.line_num_b, content,
"line-added"
))
i += 1
else:
i += 1
table = f"""
"""
return _generate_html_document(f"{header}{table}", "side-by-side")
def _create_side_by_side_row(
left_num: Union[int, str],
left_content: str,
right_num: Union[int, str],
right_content: str,
row_class: str
) -> str:
"""
Create a side-by-side table row with left and right columns.
Args:
left_num: Line number for left side (or empty string)
left_content: HTML content for left side
right_num: Line number for right side (or empty string)
right_content: HTML content for right side
row_class: CSS class for the row (line-unchanged, line-modified, etc.)
Returns:
HTML string for the table row
"""
left_cell_class = "diff-content left"
right_cell_class = "diff-content right"
if not left_content and left_num == "":
left_cell_class += " empty-cell"
if not right_content and right_num == "":
right_cell_class += " empty-cell"
# Add background colors for modified rows (dark theme)
left_bg = ""
right_bg = ""
if row_class == "line-deleted" or row_class == "line-modified":
left_bg = ' style="background-color: #3a1e1e;"'
if row_class == "line-added" or row_class == "line-modified":
right_bg = ' style="background-color: #1e3a1e;"'
return f"""
| {left_num} |
{left_content} |
{right_num} |
{right_content} |
"""
def _generate_header(stats: Dict[str, int]) -> str:
"""
Generate the diff header with addition/deletion statistics.
Args:
stats: Dictionary with 'additions' and 'deletions' counts
Returns:
HTML string for the header section
"""
additions = stats.get("additions", 0)
deletions = stats.get("deletions", 0)
return f"""
"""
def _generate_html_document(body_content: str, view_class: str) -> str:
"""
Generate a complete HTML document with embedded CSS.
Args:
body_content: HTML content for the body
view_class: CSS class for the view mode ('unified' or 'side-by-side')
Returns:
Complete HTML document string
"""
return f"""
{DIFF_CSS}
{body_content}
"""
def _get_row_class(change_type: str) -> str:
"""Get the CSS class for a row based on change type."""
return {
"added": "line-added",
"deleted": "line-deleted",
"unchanged": "line-unchanged",
}.get(change_type, "")
def _get_prefix(change_type: str) -> str:
"""Get the prefix indicator for unified view."""
if change_type == "added":
return '+'
elif change_type == "deleted":
return '-'
return ' '
def _format_content_with_char_diffs(
line: DiffLine,
for_deleted: bool = False,
for_added: bool = False
) -> str:
"""
Format line content with character-level highlighting.
Args:
line: The DiffLine to format
for_deleted: If True, format for left side of side-by-side (show deletions only)
for_added: If True, format for right side of side-by-side (show additions only)
Returns:
HTML string with highlighted character differences
"""
content = line.content.rstrip("\n\r")
if not line.char_diffs:
return html.escape(content)
# Determine what to show based on context:
# - Side-by-side mode: for_deleted or for_added will be True
# - Unified mode: both are False, determine from line.change_type
# Each line should only show its own content (deleted lines show deletions, added lines show additions)
show_deletions = for_deleted or (not for_added and line.change_type == "deleted")
show_additions = for_added or (not for_deleted and line.change_type == "added")
parts = []
for change_type, text in line.char_diffs:
escaped = html.escape(text.rstrip("\n\r"))
if change_type == "equal":
parts.append(escaped)
elif change_type == "delete" and show_deletions:
parts.append(f'{escaped}')
elif change_type == "insert" and show_additions:
parts.append(f'{escaped}')
# Skip insert on deleted lines, skip delete on added lines
return "".join(parts)