commit a743cbf65e9878e47de1379fd6cbd708127fe8bd Author: dcyd-lun <> Date: Tue Dec 9 15:05:52 2025 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d122743 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c61f215 --- /dev/null +++ b/README.md @@ -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) | diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d0ddcd0 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/docs/images/sidebyside.png b/docs/images/sidebyside.png new file mode 100644 index 0000000..346b196 Binary files /dev/null and b/docs/images/sidebyside.png differ diff --git a/docs/images/unified.png b/docs/images/unified.png new file mode 100644 index 0000000..5c1ef01 Binary files /dev/null and b/docs/images/unified.png differ diff --git a/js/textDiff.js b/js/textDiff.js new file mode 100644 index 0000000..23fa5af --- /dev/null +++ b/js/textDiff.js @@ -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("= 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); + } + }; + }, +}); diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..e5338c3 --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1,3 @@ +from .text_diff import TextDiff + +__all__ = ["TextDiff"] diff --git a/nodes/text_diff.py b/nodes/text_diff.py new file mode 100644 index 0000000..3f9d8a2 --- /dev/null +++ b/nodes/text_diff.py @@ -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("=3.9" + +[project.urls] +Repository = "https://github.com/dcyd-lun/ComfyUI_text_diff" + +[tool.comfy] +PublisherId = "dcyd-lun" +DisplayName = "Text Diff" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a87646 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# ComfyUI TextDiff +# No external dependencies required - uses Python standard library only diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..21cba01 --- /dev/null +++ b/utils/__init__.py @@ -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"] diff --git a/utils/diff_engine.py b/utils/diff_engine.py new file mode 100644 index 0000000..6a04238 --- /dev/null +++ b/utils/diff_engine.py @@ -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 diff --git a/utils/html_formatter.py b/utils/html_formatter.py new file mode 100644 index 0000000..6a9f752 --- /dev/null +++ b/utils/html_formatter.py @@ -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 = """ + +""" + + +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} +