Initial commit
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Project-specific
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
nul
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
@@ -0,0 +1,43 @@
|
||||
# ComfyUI Text Diff
|
||||
|
||||
Compare two text strings with GitHub/GitLab-style diff highlighting in ComfyUI.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Line-by-line comparison with line numbers
|
||||
- Character-level highlighting within changed lines
|
||||
- Two display modes: **Unified** (single column) and **Side-by-Side** (two columns)
|
||||
- Instant view mode switching without re-running the workflow
|
||||
- Context lines control to focus on changes
|
||||
- No external dependencies (Python standard library only)
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/dcyd-lun/ComfyUI_text_diff.git
|
||||
```
|
||||
|
||||
Restart ComfyUI after installation.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add the **Text Diff** node from `utils/text` category
|
||||
2. Connect two text inputs (`text_a` and `text_b`)
|
||||
3. Run the workflow to see the diff
|
||||
4. Switch between view modes instantly using the dropdown
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `text_a` | STRING | Original text |
|
||||
| `text_b` | STRING | Modified text |
|
||||
| `view_mode` | DROPDOWN | `side_by_side` (default) or `unified` |
|
||||
| `context_lines` | INT | Lines of context around changes (-1 = show all) |
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
ComfyUI TextDiff - A custom node for comparing text with GitHub-style highlighting.
|
||||
|
||||
This package provides a ComfyUI node that compares two text strings and displays
|
||||
the differences with line-level and character-level highlighting, similar to
|
||||
GitHub/GitLab diff views.
|
||||
"""
|
||||
|
||||
from .nodes import TextDiff
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"TextDiff": TextDiff,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"TextDiff": "Text Diff",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
+342
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* TextDiff ComfyUI Extension
|
||||
*
|
||||
* Provides a rich diff display widget for the TextDiff node using an iframe
|
||||
* to render GitHub-style HTML diffs.
|
||||
*/
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// Layout constants
|
||||
const NODE_HEADER_HEIGHT = 100; // Height reserved for node header and widgets
|
||||
const INITIAL_NODE_WIDTH = 300; // Initial node width before content
|
||||
const INITIAL_NODE_HEIGHT = 150; // Initial node height before content
|
||||
const MIN_CONTAINER_HEIGHT = 50; // Minimum height for diff container
|
||||
const DEFAULT_CONTAINER_HEIGHT = 200; // Default height when content exists
|
||||
const MAX_AUTO_WIDTH = 600; // Maximum width for auto-resize
|
||||
const MAX_AUTO_HEIGHT = 500; // Maximum height for auto-resize
|
||||
const MIN_CONTENT_WIDTH = 300; // Minimum content width
|
||||
const MIN_CONTENT_HEIGHT = 100; // Minimum content height
|
||||
|
||||
app.registerExtension({
|
||||
name: "textdiff.display",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "TextDiff") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original methods
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
const onResize = nodeType.prototype.onResize;
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
|
||||
/**
|
||||
* Called when the node is created - sets up the diff display widget
|
||||
*/
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.apply(this, arguments);
|
||||
}
|
||||
this.createDiffWidget();
|
||||
|
||||
// Attach view_mode widget callback for instant switching
|
||||
const self = this;
|
||||
requestAnimationFrame(() => {
|
||||
const viewModeWidget = self.widgets?.find(w => w.name === "view_mode");
|
||||
if (viewModeWidget) {
|
||||
const originalCallback = viewModeWidget.callback;
|
||||
viewModeWidget.callback = function(newValue) {
|
||||
if (originalCallback) {
|
||||
originalCallback.call(self, newValue);
|
||||
}
|
||||
// Instant switch if we have both HTML versions
|
||||
if (self._htmlUnified && self._htmlSideBySide) {
|
||||
const html = newValue === "unified" ? self._htmlUnified : self._htmlSideBySide;
|
||||
self.updateDiffDisplay(html, newValue, true); // skipResize=true
|
||||
if (app.graph) {
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the node execution completes - updates the diff display
|
||||
*/
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
if (onExecuted) {
|
||||
onExecuted.apply(this, arguments);
|
||||
}
|
||||
|
||||
if (message && message.diff_html_unified && message.diff_html_side_by_side) {
|
||||
// Store both HTML versions for instant view mode switching
|
||||
this._htmlUnified = message.diff_html_unified[0];
|
||||
this._htmlSideBySide = message.diff_html_side_by_side[0];
|
||||
|
||||
const viewMode = message.view_mode ? message.view_mode[0] : "side_by_side";
|
||||
const htmlContent = viewMode === "unified" ? this._htmlUnified : this._htmlSideBySide;
|
||||
this.updateDiffDisplay(htmlContent, viewMode);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when loading a workflow - restores the diff display
|
||||
*/
|
||||
nodeType.prototype.onConfigure = function(config) {
|
||||
if (onConfigure) {
|
||||
onConfigure.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Find saved HTML entries in widgets_values (unified first, then side_by_side)
|
||||
const htmlEntries = [];
|
||||
if (this.widgets_values) {
|
||||
for (const val of this.widgets_values) {
|
||||
if (typeof val === "string" && val.startsWith("<!DOCTYPE")) {
|
||||
htmlEntries.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore both HTML versions if available
|
||||
if (htmlEntries.length >= 2) {
|
||||
this._htmlUnified = htmlEntries[0];
|
||||
this._htmlSideBySide = htmlEntries[1];
|
||||
|
||||
// Get current view mode from widget
|
||||
const viewModeWidget = this.widgets?.find(w => w.name === "view_mode");
|
||||
const viewMode = viewModeWidget?.value || "side_by_side";
|
||||
const htmlContent = viewMode === "unified" ? this._htmlUnified : this._htmlSideBySide;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._hasContent = true;
|
||||
this._userResized = true; // Preserve user's size from saved workflow
|
||||
if (this.diffContainer) {
|
||||
this.diffContainer.style.display = "block";
|
||||
const availableHeight = Math.max(this.size[1] - NODE_HEADER_HEIGHT, MIN_CONTAINER_HEIGHT);
|
||||
this.diffContainer.style.height = `${availableHeight}px`;
|
||||
}
|
||||
this.updateDiffDisplay(htmlContent, viewMode, true); // skipResize=true
|
||||
});
|
||||
} else if (htmlEntries.length === 1) {
|
||||
// Fallback for old saved workflows with single HTML
|
||||
const savedHtml = htmlEntries[0];
|
||||
requestAnimationFrame(() => {
|
||||
this._hasContent = true;
|
||||
this._userResized = true;
|
||||
if (this.diffContainer) {
|
||||
this.diffContainer.style.display = "block";
|
||||
const availableHeight = Math.max(this.size[1] - NODE_HEADER_HEIGHT, MIN_CONTAINER_HEIGHT);
|
||||
this.diffContainer.style.height = `${availableHeight}px`;
|
||||
}
|
||||
this.updateDiffDisplay(savedHtml, null, true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when user resizes the node - sync container to fill space
|
||||
*/
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
if (onResize) {
|
||||
onResize.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Sync container height to fill available space
|
||||
if (this._hasContent && this.diffContainer) {
|
||||
// Calculate available height (node height minus header/widgets)
|
||||
const availableHeight = Math.max(size[1] - NODE_HEADER_HEIGHT, MIN_CONTAINER_HEIGHT);
|
||||
this.diffContainer.style.height = `${availableHeight}px`;
|
||||
|
||||
// Mark as user-resized (skip auto-resize on future runs)
|
||||
// Only set if this wasn't triggered by our own setSize
|
||||
if (!this._isAutoResizing) {
|
||||
this._userResized = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the node is removed - cleanup to prevent memory leaks
|
||||
*/
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
if (onRemoved) {
|
||||
onRemoved.apply(this, arguments);
|
||||
}
|
||||
|
||||
// Clean up iframe to prevent memory leaks
|
||||
if (this.diffIframe) {
|
||||
const doc = this.getIframeDocument();
|
||||
if (doc) {
|
||||
try {
|
||||
doc.open();
|
||||
doc.write("");
|
||||
doc.close();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
this.diffIframe = null;
|
||||
}
|
||||
this.diffContainer = null;
|
||||
this.diffWidget = null;
|
||||
this._htmlUnified = null;
|
||||
this._htmlSideBySide = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the iframe's document object for reading/writing content.
|
||||
* @returns {Document|null} The iframe document or null if unavailable
|
||||
*/
|
||||
nodeType.prototype.getIframeDocument = function() {
|
||||
if (!this.diffIframe) return null;
|
||||
try {
|
||||
return this.diffIframe.contentDocument ||
|
||||
this.diffIframe.contentWindow?.document || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the iframe widget for displaying diffs
|
||||
*/
|
||||
nodeType.prototype.createDiffWidget = function() {
|
||||
// Create container div - starts hidden until content arrives
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = `
|
||||
width: 100%;
|
||||
height: 0;
|
||||
overflow: auto;
|
||||
background: #1e1e1e;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3c3c3c;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
// Create iframe for isolated HTML rendering
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #1e1e1e;
|
||||
`;
|
||||
// Security: no script execution allowed
|
||||
iframe.sandbox = "allow-same-origin";
|
||||
container.appendChild(iframe);
|
||||
|
||||
// Store references
|
||||
this.diffIframe = iframe;
|
||||
this.diffContainer = container;
|
||||
this._hasContent = false;
|
||||
this._userResized = false;
|
||||
this._isAutoResizing = false;
|
||||
|
||||
// Add as DOM widget that fills available space
|
||||
const self = this;
|
||||
const widget = this.addDOMWidget("diff_display", "custom", container, {
|
||||
hideOnZoom: false,
|
||||
getHeight: () => {
|
||||
if (!self._hasContent) return 0;
|
||||
// Return current container height
|
||||
return parseInt(container.style.height) || DEFAULT_CONTAINER_HEIGHT;
|
||||
},
|
||||
getMinHeight: () => 0,
|
||||
});
|
||||
|
||||
// Store widget reference
|
||||
this.diffWidget = widget;
|
||||
|
||||
// Set compact initial size (will expand when content arrives)
|
||||
this.setSize([INITIAL_NODE_WIDTH, INITIAL_NODE_HEIGHT]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the diff display with new HTML content
|
||||
*/
|
||||
nodeType.prototype.updateDiffDisplay = function(htmlContent, viewMode, skipResize = false) {
|
||||
if (!this.diffIframe || !htmlContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the container
|
||||
const wasEmpty = !this._hasContent;
|
||||
this._hasContent = true;
|
||||
this.diffContainer.style.display = "block";
|
||||
|
||||
this.writeToIframe(htmlContent);
|
||||
|
||||
// Only auto-resize on first content or if user hasn't manually resized
|
||||
if (!skipResize && (wasEmpty || !this._userResized)) {
|
||||
requestAnimationFrame(() => {
|
||||
this.resizeToContent();
|
||||
});
|
||||
} else {
|
||||
// Just sync container to current node size
|
||||
const availableHeight = Math.max(this.size[1] - NODE_HEADER_HEIGHT, MIN_CONTAINER_HEIGHT);
|
||||
this.diffContainer.style.height = `${availableHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes HTML content to the iframe
|
||||
* @param {string} htmlContent - The HTML content to write
|
||||
*/
|
||||
nodeType.prototype.writeToIframe = function(htmlContent) {
|
||||
const doc = this.getIframeDocument();
|
||||
if (!doc) return;
|
||||
|
||||
try {
|
||||
doc.open();
|
||||
doc.write(htmlContent);
|
||||
doc.close();
|
||||
} catch (e) {
|
||||
console.error("TextDiff: Failed to write to iframe:", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resizes the node to fit content (initial auto-size)
|
||||
*/
|
||||
nodeType.prototype.resizeToContent = function() {
|
||||
if (!this.diffContainer) return;
|
||||
|
||||
const doc = this.getIframeDocument();
|
||||
if (!doc || !doc.body) return;
|
||||
|
||||
try {
|
||||
const contentHeight = doc.body.scrollHeight || MIN_CONTENT_HEIGHT;
|
||||
const contentWidth = doc.body.scrollWidth || MIN_CONTENT_WIDTH;
|
||||
|
||||
// Set initial size based on content (reasonable defaults, user can resize)
|
||||
// Cap initial auto-size, but user can resize larger
|
||||
const initialHeight = Math.min(Math.max(contentHeight + 10, MIN_CONTENT_HEIGHT), MAX_AUTO_HEIGHT);
|
||||
const initialWidth = Math.min(Math.max(contentWidth + 20, MIN_CONTENT_WIDTH), MAX_AUTO_WIDTH);
|
||||
|
||||
this.diffContainer.style.height = `${initialHeight}px`;
|
||||
|
||||
// Calculate node size (add padding for node chrome)
|
||||
const nodeWidth = initialWidth + 40;
|
||||
const nodeHeight = initialHeight + NODE_HEADER_HEIGHT;
|
||||
|
||||
// Mark as auto-resizing so onResize doesn't think it's user action
|
||||
this._isAutoResizing = true;
|
||||
this.setSize([nodeWidth, nodeHeight]);
|
||||
this._isAutoResizing = false;
|
||||
|
||||
// Mark canvas as dirty to trigger redraw
|
||||
if (app.graph) {
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
}
|
||||
} catch (e) {
|
||||
this._isAutoResizing = false;
|
||||
console.error("TextDiff: Failed to resize:", e);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
from .text_diff import TextDiff
|
||||
|
||||
__all__ = ["TextDiff"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""TextDiff node for comparing two text strings with GitHub-style highlighting."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils import compute_text_diff, format_diff_html
|
||||
|
||||
|
||||
class TextDiff:
|
||||
"""
|
||||
ComfyUI node that compares two text strings and displays differences
|
||||
with GitHub/GitLab-style highlighting.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text_a": ("STRING", {"forceInput": True, "multiline": True}),
|
||||
"text_b": ("STRING", {"forceInput": True, "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"view_mode": (["side_by_side", "unified"], {"default": "side_by_side"}),
|
||||
"context_lines": (
|
||||
"INT",
|
||||
{"default": -1, "min": -1, "max": 50, "step": 1,
|
||||
"tooltip": "Lines of context around changes (-1 = show all)"},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "compute_diff"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "utils/text"
|
||||
|
||||
def compute_diff(
|
||||
self,
|
||||
text_a: str,
|
||||
text_b: str,
|
||||
view_mode: str = "side_by_side",
|
||||
context_lines: int = -1,
|
||||
unique_id: Optional[str] = None,
|
||||
extra_pnginfo: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compute the diff between two text strings and return HTML for display.
|
||||
|
||||
Args:
|
||||
text_a: The first (original) text string
|
||||
text_b: The second (modified) text string
|
||||
view_mode: Display mode - 'unified' or 'side_by_side'
|
||||
context_lines: Number of context lines around changes (-1 = show all)
|
||||
unique_id: ComfyUI node unique identifier
|
||||
extra_pnginfo: ComfyUI workflow metadata
|
||||
|
||||
Returns:
|
||||
Dict with 'ui' key containing:
|
||||
- diff_html_unified: List with unified view HTML
|
||||
- diff_html_side_by_side: List with side-by-side view HTML
|
||||
- view_mode: List with selected view mode
|
||||
"""
|
||||
# Compute the diff
|
||||
diff_result = compute_text_diff(text_a, text_b, context_lines)
|
||||
|
||||
# Generate BOTH HTML versions for instant view mode switching
|
||||
html_unified = format_diff_html(diff_result, "unified")
|
||||
html_side_by_side = format_diff_html(diff_result, "side_by_side")
|
||||
|
||||
# Persist to workflow for reload (silently handle errors to avoid breaking execution)
|
||||
if unique_id is not None and extra_pnginfo is not None:
|
||||
try:
|
||||
self._persist_to_workflow(unique_id, extra_pnginfo, html_unified, html_side_by_side)
|
||||
except Exception:
|
||||
pass # Persistence is optional - don't break execution if it fails
|
||||
|
||||
return {
|
||||
"ui": {
|
||||
"diff_html_unified": [html_unified],
|
||||
"diff_html_side_by_side": [html_side_by_side],
|
||||
"view_mode": [view_mode],
|
||||
}
|
||||
}
|
||||
|
||||
def _persist_to_workflow(self, unique_id, extra_pnginfo, html_unified, html_side_by_side):
|
||||
"""Store both diff HTML versions in workflow metadata for persistence on reload."""
|
||||
if extra_pnginfo is None:
|
||||
return
|
||||
|
||||
if isinstance(extra_pnginfo, list) and len(extra_pnginfo) > 0:
|
||||
extra_pnginfo = extra_pnginfo[0]
|
||||
|
||||
if not isinstance(extra_pnginfo, dict):
|
||||
return
|
||||
|
||||
workflow = extra_pnginfo.get("workflow")
|
||||
if workflow is None:
|
||||
return
|
||||
|
||||
nodes = workflow.get("nodes")
|
||||
if nodes is None:
|
||||
return
|
||||
|
||||
for node in nodes:
|
||||
if str(node.get("id")) == str(unique_id):
|
||||
widgets_values = node.get("widgets_values", [])
|
||||
if isinstance(widgets_values, list):
|
||||
# Remove any existing HTML entries
|
||||
widgets_values = [
|
||||
v for v in widgets_values
|
||||
if not (isinstance(v, str) and v.startswith("<!DOCTYPE"))
|
||||
]
|
||||
# Append both HTML versions (unified first, then side_by_side)
|
||||
widgets_values.append(html_unified)
|
||||
widgets_values.append(html_side_by_side)
|
||||
node["widgets_values"] = widgets_values
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution when inputs change."""
|
||||
return float("nan")
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "comfyui-text-diff"
|
||||
description = "Compare two texts with GitHub/GitLab-style diff highlighting in ComfyUI"
|
||||
version = "1.0.0"
|
||||
license = {text = "MIT License"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/dcyd-lun/ComfyUI_text_diff"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "dcyd-lun"
|
||||
DisplayName = "Text Diff"
|
||||
Icon = ""
|
||||
@@ -0,0 +1,2 @@
|
||||
# ComfyUI TextDiff
|
||||
# No external dependencies required - uses Python standard library only
|
||||
@@ -0,0 +1,4 @@
|
||||
from .diff_engine import compute_text_diff, DiffResult, DiffLine
|
||||
from .html_formatter import format_diff_html
|
||||
|
||||
__all__ = ["compute_text_diff", "DiffResult", "DiffLine", "format_diff_html"]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Diff engine for computing line-level and character-level text differences.
|
||||
|
||||
Uses Python's built-in difflib module for robust diff computation.
|
||||
Line numbers are 1-based in all outputs.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# Regex pattern to split text into words and whitespace tokens
|
||||
WHITESPACE_SPLIT_PATTERN = r'(\s+)'
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffLine:
|
||||
"""Represents a single line in the diff output."""
|
||||
|
||||
line_num_a: Optional[int] # Line number in text_a (None if addition)
|
||||
line_num_b: Optional[int] # Line number in text_b (None if deletion)
|
||||
content: str
|
||||
change_type: str # 'unchanged', 'added', 'deleted'
|
||||
char_diffs: Optional[List[Tuple[str, str]]] = None # (change_type, text) pairs
|
||||
is_paired: bool = False # True only for lines from replace opcode (paired with another line)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffResult:
|
||||
"""Contains the complete diff result with lines and statistics."""
|
||||
|
||||
lines: List[DiffLine] = field(default_factory=list)
|
||||
stats: dict = field(default_factory=lambda: {"additions": 0, "deletions": 0, "unchanged": 0})
|
||||
|
||||
|
||||
def compute_text_diff(
|
||||
text_a: str, text_b: str, context_lines: int = 3
|
||||
) -> DiffResult:
|
||||
"""
|
||||
Compute line-by-line diff with character-level highlighting for changed lines.
|
||||
|
||||
Uses difflib.SequenceMatcher for line-level comparison, then applies
|
||||
character-level diff for modified (replaced) lines.
|
||||
|
||||
Args:
|
||||
text_a: The original text
|
||||
text_b: The modified text
|
||||
context_lines: Number of unchanged lines to show around changes (-1 = show all)
|
||||
|
||||
Returns:
|
||||
DiffResult containing all diff lines and statistics
|
||||
"""
|
||||
# Handle edge cases
|
||||
if text_a == text_b:
|
||||
lines = text_a.splitlines(keepends=True)
|
||||
diff_lines = [
|
||||
DiffLine(
|
||||
line_num_a=i + 1,
|
||||
line_num_b=i + 1,
|
||||
content=line,
|
||||
change_type="unchanged",
|
||||
char_diffs=None,
|
||||
)
|
||||
for i, line in enumerate(lines)
|
||||
]
|
||||
return DiffResult(
|
||||
lines=diff_lines,
|
||||
stats={"additions": 0, "deletions": 0, "unchanged": len(lines)},
|
||||
)
|
||||
|
||||
lines_a = text_a.splitlines(keepends=True)
|
||||
lines_b = text_b.splitlines(keepends=True)
|
||||
|
||||
# Handle empty inputs
|
||||
if not lines_a and not text_a:
|
||||
lines_a = []
|
||||
elif not lines_a:
|
||||
lines_a = [text_a]
|
||||
|
||||
if not lines_b and not text_b:
|
||||
lines_b = []
|
||||
elif not lines_b:
|
||||
lines_b = [text_b]
|
||||
|
||||
# Use SequenceMatcher for matching blocks
|
||||
# autojunk=False ensures accurate diffs (no heuristic skipping of "junk" elements)
|
||||
matcher = difflib.SequenceMatcher(None, lines_a, lines_b, autojunk=False)
|
||||
diff_lines: List[DiffLine] = []
|
||||
|
||||
stats = {"additions": 0, "deletions": 0, "unchanged": 0}
|
||||
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
# Unchanged lines
|
||||
for idx, line in enumerate(lines_a[i1:i2]):
|
||||
diff_lines.append(
|
||||
DiffLine(
|
||||
line_num_a=i1 + idx + 1,
|
||||
line_num_b=j1 + idx + 1,
|
||||
content=line,
|
||||
change_type="unchanged",
|
||||
char_diffs=None,
|
||||
)
|
||||
)
|
||||
stats["unchanged"] += 1
|
||||
|
||||
elif tag == "replace":
|
||||
# Modified lines - compute character-level diffs
|
||||
old_lines = lines_a[i1:i2]
|
||||
new_lines = lines_b[j1:j2]
|
||||
|
||||
# Pair up old and new lines for character diff
|
||||
max_len = max(len(old_lines), len(new_lines))
|
||||
for idx in range(max_len):
|
||||
old_line = old_lines[idx] if idx < len(old_lines) else ""
|
||||
new_line = new_lines[idx] if idx < len(new_lines) else ""
|
||||
|
||||
# Compute character-level diff for this pair
|
||||
char_diffs = compute_char_diff(old_line, new_line)
|
||||
|
||||
if old_line:
|
||||
diff_lines.append(
|
||||
DiffLine(
|
||||
line_num_a=i1 + idx + 1 if idx < len(old_lines) else None,
|
||||
line_num_b=None,
|
||||
content=old_line,
|
||||
change_type="deleted",
|
||||
char_diffs=char_diffs,
|
||||
is_paired=True,
|
||||
)
|
||||
)
|
||||
stats["deletions"] += 1
|
||||
|
||||
if new_line:
|
||||
diff_lines.append(
|
||||
DiffLine(
|
||||
line_num_a=None,
|
||||
line_num_b=j1 + idx + 1 if idx < len(new_lines) else None,
|
||||
content=new_line,
|
||||
change_type="added",
|
||||
char_diffs=char_diffs,
|
||||
is_paired=True,
|
||||
)
|
||||
)
|
||||
stats["additions"] += 1
|
||||
|
||||
elif tag == "delete":
|
||||
# Lines only in text_a
|
||||
for idx, line in enumerate(lines_a[i1:i2]):
|
||||
diff_lines.append(
|
||||
DiffLine(
|
||||
line_num_a=i1 + idx + 1,
|
||||
line_num_b=None,
|
||||
content=line,
|
||||
change_type="deleted",
|
||||
char_diffs=None,
|
||||
)
|
||||
)
|
||||
stats["deletions"] += 1
|
||||
|
||||
elif tag == "insert":
|
||||
# Lines only in text_b
|
||||
for idx, line in enumerate(lines_b[j1:j2]):
|
||||
diff_lines.append(
|
||||
DiffLine(
|
||||
line_num_a=None,
|
||||
line_num_b=j1 + idx + 1,
|
||||
content=line,
|
||||
change_type="added",
|
||||
char_diffs=None,
|
||||
)
|
||||
)
|
||||
stats["additions"] += 1
|
||||
|
||||
# Apply context line filtering
|
||||
if context_lines >= 0:
|
||||
diff_lines = _filter_context_lines(diff_lines, context_lines)
|
||||
# Recalculate stats after filtering
|
||||
stats = {"additions": 0, "deletions": 0, "unchanged": 0}
|
||||
for line in diff_lines:
|
||||
if line.change_type == "added":
|
||||
stats["additions"] += 1
|
||||
elif line.change_type == "deleted":
|
||||
stats["deletions"] += 1
|
||||
else:
|
||||
stats["unchanged"] += 1
|
||||
|
||||
return DiffResult(lines=diff_lines, stats=stats)
|
||||
|
||||
|
||||
def _filter_context_lines(diff_lines: List[DiffLine], context_lines: int) -> List[DiffLine]:
|
||||
"""
|
||||
Filter diff lines to only show changed lines and surrounding context.
|
||||
|
||||
Args:
|
||||
diff_lines: Full list of diff lines
|
||||
context_lines: Number of unchanged lines to keep around changes
|
||||
|
||||
Returns:
|
||||
Filtered list with only relevant lines
|
||||
"""
|
||||
if not diff_lines:
|
||||
return diff_lines
|
||||
|
||||
# Find indices of all changed lines
|
||||
changed_indices = {
|
||||
i for i, line in enumerate(diff_lines)
|
||||
if line.change_type != "unchanged"
|
||||
}
|
||||
|
||||
if not changed_indices:
|
||||
return diff_lines # No changes, return all
|
||||
|
||||
# Include lines within context_lines distance of any change
|
||||
included = set()
|
||||
for idx in changed_indices:
|
||||
for offset in range(-context_lines, context_lines + 1):
|
||||
target = idx + offset
|
||||
if 0 <= target < len(diff_lines):
|
||||
included.add(target)
|
||||
|
||||
return [line for i, line in enumerate(diff_lines) if i in included]
|
||||
|
||||
|
||||
def compute_char_diff(old_line: str, new_line: str) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Compute word-level differences between two lines.
|
||||
|
||||
Tokenizes by words (preserving whitespace) and compares word-by-word
|
||||
for more readable diffs than character-by-character comparison.
|
||||
|
||||
Args:
|
||||
old_line: The original line
|
||||
new_line: The modified line
|
||||
|
||||
Returns:
|
||||
List of (change_type, text) tuples where change_type is one of:
|
||||
'equal', 'delete', 'insert'
|
||||
"""
|
||||
if not old_line and not new_line:
|
||||
return []
|
||||
|
||||
if not old_line:
|
||||
return [("insert", new_line)]
|
||||
|
||||
if not new_line:
|
||||
return [("delete", old_line)]
|
||||
|
||||
# Split into tokens (words and whitespace separately)
|
||||
old_tokens = re.split(WHITESPACE_SPLIT_PATTERN, old_line)
|
||||
new_tokens = re.split(WHITESPACE_SPLIT_PATTERN, new_line)
|
||||
|
||||
# Filter out empty strings from split
|
||||
old_tokens = [t for t in old_tokens if t]
|
||||
new_tokens = [t for t in new_tokens if t]
|
||||
|
||||
matcher = difflib.SequenceMatcher(None, old_tokens, new_tokens, autojunk=False)
|
||||
result: List[Tuple[str, str]] = []
|
||||
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
result.append(("equal", "".join(old_tokens[i1:i2])))
|
||||
elif tag == "replace":
|
||||
# For replace, show both delete and insert
|
||||
result.append(("delete", "".join(old_tokens[i1:i2])))
|
||||
result.append(("insert", "".join(new_tokens[j1:j2])))
|
||||
elif tag == "delete":
|
||||
result.append(("delete", "".join(old_tokens[i1:i2])))
|
||||
elif tag == "insert":
|
||||
result.append(("insert", "".join(new_tokens[j1:j2])))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
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 = """
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diff-header {
|
||||
padding: 8px 12px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 12px;
|
||||
color: #9d9d9d;
|
||||
}
|
||||
|
||||
.stats .added {
|
||||
color: #4ec9b0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats .deleted {
|
||||
color: #f14c4c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.diff-line:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
padding: 0 8px;
|
||||
color: #6e7681;
|
||||
text-align: right;
|
||||
border-right: 1px solid #3c3c3c;
|
||||
user-select: none;
|
||||
vertical-align: top;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
padding: 0 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.line-added {
|
||||
background-color: #1e3a1e;
|
||||
}
|
||||
|
||||
.line-added .line-num {
|
||||
background-color: #2d4a2d;
|
||||
border-color: #3d5a3d;
|
||||
}
|
||||
|
||||
.line-deleted {
|
||||
background-color: #3a1e1e;
|
||||
}
|
||||
|
||||
.line-deleted .line-num {
|
||||
background-color: #4a2d2d;
|
||||
border-color: #5a3d3d;
|
||||
}
|
||||
|
||||
.char-added {
|
||||
background-color: #2d5a2d;
|
||||
border-radius: 2px;
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.char-deleted {
|
||||
background-color: #5a2d2d;
|
||||
border-radius: 2px;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
/* Side-by-side specific styles */
|
||||
.side-by-side .diff-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.side-by-side .line-num {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.side-by-side .diff-content {
|
||||
width: calc(50% - 40px);
|
||||
}
|
||||
|
||||
.side-by-side .diff-content.left {
|
||||
border-right: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.side-by-side .line-added .diff-content.left {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.side-by-side .line-deleted .diff-content.right {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
.line-unchanged .diff-content {
|
||||
color: #9d9d9d;
|
||||
}
|
||||
|
||||
/* Prefix indicators */
|
||||
.prefix {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.prefix-add {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.prefix-del {
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
/* No changes message */
|
||||
.no-changes {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #9d9d9d;
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
.no-changes-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
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}
|
||||
<div class="no-changes">
|
||||
<div class="no-changes-icon">✓</div>
|
||||
<div>No differences found</div>
|
||||
</div>
|
||||
""",
|
||||
"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"""<tr class="diff-line {row_class}">
|
||||
<td class="line-num">{line_num_a}</td>
|
||||
<td class="line-num">{line_num_b}</td>
|
||||
<td class="diff-content">{prefix}{content}</td>
|
||||
</tr>"""
|
||||
)
|
||||
|
||||
table = f"""
|
||||
<table class="diff-table">
|
||||
<tbody>
|
||||
{''.join(rows)}
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
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}
|
||||
<div class="no-changes">
|
||||
<div class="no-changes-icon">✓</div>
|
||||
<div>No differences found</div>
|
||||
</div>
|
||||
""",
|
||||
"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"""
|
||||
<table class="diff-table">
|
||||
<tbody>
|
||||
{''.join(rows)}
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
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"""<tr class="diff-line {row_class}">
|
||||
<td class="line-num">{left_num}</td>
|
||||
<td class="{left_cell_class}"{left_bg}>{left_content}</td>
|
||||
<td class="line-num">{right_num}</td>
|
||||
<td class="{right_cell_class}"{right_bg}>{right_content}</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
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"""
|
||||
<div class="diff-header">
|
||||
<span class="stats">
|
||||
<span class="added">+{additions}</span>
|
||||
|
||||
<span class="deleted">-{deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
{DIFF_CSS}
|
||||
</head>
|
||||
<body>
|
||||
<div class="diff-container {view_class}">
|
||||
{body_content}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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 '<span class="prefix prefix-add">+</span>'
|
||||
elif change_type == "deleted":
|
||||
return '<span class="prefix prefix-del">-</span>'
|
||||
return '<span class="prefix"> </span>'
|
||||
|
||||
|
||||
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'<span class="char-deleted">{escaped}</span>')
|
||||
elif change_type == "insert" and show_additions:
|
||||
parts.append(f'<span class="char-added">{escaped}</span>')
|
||||
# Skip insert on deleted lines, skip delete on added lines
|
||||
|
||||
return "".join(parts)
|
||||
Reference in New Issue
Block a user