Initial commit

This commit is contained in:
dcyd-lun
2025-12-09 15:30:56 +01:00
commit a743cbf65e
13 changed files with 1367 additions and 0 deletions
+29
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
# ComfyUI Text Diff
Compare two text strings with GitHub/GitLab-style diff highlighting in ComfyUI.
![Side-by-Side View](docs/images/sidebyside.png)
![Unified View](docs/images/unified.png)
## 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
View File
@@ -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
View File
@@ -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);
}
};
},
});
+3
View File
@@ -0,0 +1,3 @@
from .text_diff import TextDiff
__all__ = ["TextDiff"]
+125
View File
@@ -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")
+15
View File
@@ -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 = ""
+2
View File
@@ -0,0 +1,2 @@
# ComfyUI TextDiff
# No external dependencies required - uses Python standard library only
+4
View File
@@ -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"]
+273
View File
@@ -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
+510
View File
@@ -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">&#10003;</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">&#10003;</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>
&nbsp;
<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">&nbsp;</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)