Spaces:
Running
Running
| """ | |
| core/diff_utils.py — Visual Before/After diff generator (v4.7). | |
| Purpose | |
| ======= | |
| Before the user hits **Download ZIP**, we want to show them *exactly* what | |
| the AI changed. This module: | |
| * Captures a **before snapshot** of every file the AI is about to touch, | |
| keyed by ``relative_path -> str``. The pipeline calls this **before** | |
| ``apply_ai_changes`` writes anything to disk. | |
| * After the patch is applied, compares before/after and produces: | |
| * A **unified diff** (Python's ``difflib.unified_diff``) — the raw text | |
| the UI can drop into a monospaced viewer. | |
| * An **HTML side-by-side diff** styled with the studio's pink/purple | |
| palette (drop into ``gr.HTML``). | |
| * A **summary** dict with added / removed / modified line counts so the | |
| Confidence Badge can factor it in later. | |
| We deliberately avoid installing the ``diff-match-patch`` package here — | |
| Python's ``difflib`` gives us character/line/word-level diffs already, | |
| and skipping the extra native dep keeps the Hugging Face Space build fast. | |
| The output shape (``{"diffs": [...], "summary": {...}}``) is 100% compatible | |
| with what a diff-match-patch viewer expects, so a UI upgrade later is | |
| still a drop-in change. | |
| """ | |
| from __future__ import annotations | |
| import difflib | |
| import html | |
| import os | |
| from typing import Any, Dict, List, Optional, Sequence, Tuple | |
| MAX_BEFORE_CHARS = 400_000 # per-file cap on snapshot text (safety guard) | |
| MAX_DIFF_LINES = 4_000 # per-file cap on rendered diff lines | |
| MAX_FILES_IN_HTML = 40 # cap the HTML view so the UI stays fast | |
| # --------------------------------------------------------------------------- | |
| # Snapshot helpers | |
| # --------------------------------------------------------------------------- | |
| def _read_text(path: str, max_chars: int = MAX_BEFORE_CHARS) -> str: | |
| try: | |
| with open(path, "r", encoding="utf-8", errors="replace") as f: | |
| return f.read(max_chars) | |
| except FileNotFoundError: | |
| return "" | |
| except Exception: | |
| return "" | |
| def capture_before_snapshot( | |
| project_root: str, | |
| candidate_files: Sequence[str], | |
| ) -> Dict[str, str]: | |
| """ | |
| Read every ``candidate_files`` (relative paths) *before* patching so we | |
| can compute a diff after. Missing files are recorded as empty strings, | |
| which naturally shows up in the diff as pure additions. | |
| """ | |
| if not project_root or not candidate_files: | |
| return {} | |
| snapshot: Dict[str, str] = {} | |
| for rel in candidate_files: | |
| rel = str(rel or "").strip().lstrip("/") | |
| if not rel: | |
| continue | |
| abs_path = os.path.join(project_root, rel) | |
| snapshot[rel] = _read_text(abs_path) | |
| return snapshot | |
| def compute_after_snapshot( | |
| project_root: str, | |
| files: Sequence[str], | |
| ) -> Dict[str, str]: | |
| """Same as ``capture_before_snapshot`` but for the post-patch state.""" | |
| return capture_before_snapshot(project_root, files) | |
| # --------------------------------------------------------------------------- | |
| # Diff computation | |
| # --------------------------------------------------------------------------- | |
| def _unified_lines(before: str, after: str, path: str) -> List[str]: | |
| """Wrap ``difflib.unified_diff`` with a bounded line cap.""" | |
| a = (before or "").splitlines(keepends=False) | |
| b = (after or "").splitlines(keepends=False) | |
| diff_iter = difflib.unified_diff( | |
| a, | |
| b, | |
| fromfile=f"a/{path}", | |
| tofile=f"b/{path}", | |
| lineterm="", | |
| n=3, | |
| ) | |
| out: List[str] = [] | |
| for i, line in enumerate(diff_iter): | |
| if i >= MAX_DIFF_LINES: | |
| out.append(f"... (diff truncated at {MAX_DIFF_LINES} lines)") | |
| break | |
| out.append(line) | |
| return out | |
| def _summarise_diff(diff_lines: Sequence[str]) -> Dict[str, int]: | |
| added = removed = hunks = 0 | |
| for line in diff_lines: | |
| if not line: | |
| continue | |
| if line.startswith("+++") or line.startswith("---"): | |
| continue | |
| if line.startswith("+"): | |
| added += 1 | |
| elif line.startswith("-"): | |
| removed += 1 | |
| elif line.startswith("@@"): | |
| hunks += 1 | |
| return {"added_lines": added, "removed_lines": removed, "hunk_count": hunks} | |
| def build_diffs( | |
| before: Dict[str, str], | |
| after: Dict[str, str], | |
| ) -> Dict[str, Any]: | |
| """ | |
| Produce a per-file list of diffs + an aggregate summary. | |
| Returns:: | |
| { | |
| "diffs": [ | |
| { | |
| "path": "core/foo.py", | |
| "status": "modified" | "added" | "deleted" | "unchanged", | |
| "unified_diff": "..." (str), | |
| "added_lines": 12, "removed_lines": 3, "hunk_count": 2, | |
| }, | |
| ... | |
| ], | |
| "summary": { | |
| "file_count": 5, | |
| "modified": 3, "added": 1, "deleted": 0, "unchanged": 1, | |
| "total_added_lines": 42, | |
| "total_removed_lines": 11, | |
| }, | |
| } | |
| """ | |
| diffs: List[Dict[str, Any]] = [] | |
| paths = sorted(set(before.keys()) | set(after.keys())) | |
| totals = { | |
| "modified": 0, "added": 0, "deleted": 0, "unchanged": 0, | |
| "total_added_lines": 0, "total_removed_lines": 0, | |
| } | |
| for path in paths: | |
| b_txt = before.get(path, "") | |
| a_txt = after.get(path, "") | |
| if b_txt == a_txt: | |
| status = "unchanged" | |
| totals["unchanged"] += 1 | |
| diffs.append({ | |
| "path": path, | |
| "status": status, | |
| "unified_diff": "", | |
| "added_lines": 0, | |
| "removed_lines": 0, | |
| "hunk_count": 0, | |
| }) | |
| continue | |
| if not b_txt and a_txt: | |
| status = "added" | |
| totals["added"] += 1 | |
| elif b_txt and not a_txt: | |
| status = "deleted" | |
| totals["deleted"] += 1 | |
| else: | |
| status = "modified" | |
| totals["modified"] += 1 | |
| lines = _unified_lines(b_txt, a_txt, path) | |
| summary = _summarise_diff(lines) | |
| totals["total_added_lines"] += summary["added_lines"] | |
| totals["total_removed_lines"] += summary["removed_lines"] | |
| diffs.append({ | |
| "path": path, | |
| "status": status, | |
| "unified_diff": "\n".join(lines), | |
| "added_lines": summary["added_lines"], | |
| "removed_lines": summary["removed_lines"], | |
| "hunk_count": summary["hunk_count"], | |
| }) | |
| summary = { | |
| "file_count": len(paths), | |
| **totals, | |
| } | |
| return {"diffs": diffs, "summary": summary} | |
| # --------------------------------------------------------------------------- | |
| # HTML rendering (pink + purple, matches Studio theme) | |
| # --------------------------------------------------------------------------- | |
| def _render_line_html(line: str) -> str: | |
| """Style a single unified-diff line as coloured HTML.""" | |
| esc = html.escape(line) | |
| if line.startswith("+++") or line.startswith("---"): | |
| return f'<div class="devai-diff-file-line">{esc}</div>' | |
| if line.startswith("@@"): | |
| return f'<div class="devai-diff-hunk">{esc}</div>' | |
| if line.startswith("+"): | |
| return f'<div class="devai-diff-add">{esc}</div>' | |
| if line.startswith("-"): | |
| return f'<div class="devai-diff-del">{esc}</div>' | |
| return f'<div class="devai-diff-ctx">{esc}</div>' | |
| DIFF_CSS = """ | |
| <style> | |
| .devai-diff-root { | |
| font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 12.5px; | |
| color: #4a3a5c; | |
| line-height: 1.5; | |
| } | |
| .devai-diff-summary { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| margin-bottom: 14px; | |
| } | |
| .devai-diff-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| background: linear-gradient(135deg, #ffd1e8 0%, #e6d4ff 100%); | |
| color: #8a5cf0; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| border: 1px solid rgba(197, 168, 255, 0.4); | |
| font-family: 'Inter', system-ui, sans-serif; | |
| } | |
| .devai-diff-file-card { | |
| background: rgba(255, 255, 255, 0.85); | |
| border: 1px solid rgba(197, 168, 255, 0.35); | |
| border-radius: 14px; | |
| box-shadow: 0 8px 20px rgba(169, 124, 255, 0.12); | |
| margin-bottom: 16px; | |
| overflow: hidden; | |
| } | |
| .devai-diff-file-header { | |
| background: linear-gradient(135deg, #ff8fc4 0%, #b28cff 100%); | |
| color: white; | |
| padding: 10px 16px; | |
| font-weight: 600; | |
| font-family: 'Inter', system-ui, sans-serif; | |
| font-size: 0.95rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .devai-diff-file-status { | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.6px; | |
| background: rgba(255, 255, 255, 0.25); | |
| padding: 3px 10px; | |
| border-radius: 999px; | |
| } | |
| .devai-diff-body { | |
| padding: 6px 0; | |
| max-height: 520px; | |
| overflow: auto; | |
| background: #fff5fa; | |
| } | |
| .devai-diff-body > div { | |
| padding: 2px 16px; | |
| white-space: pre; | |
| } | |
| .devai-diff-add { background: rgba(150, 220, 170, 0.20); color: #1f6b3b; } | |
| .devai-diff-del { background: rgba(255, 160, 190, 0.25); color: #9a2145; } | |
| .devai-diff-hunk { background: #ece1ff; color: #7541d9; font-weight: 600; } | |
| .devai-diff-file-line { color: #8a5cf0; font-weight: 600; } | |
| .devai-diff-ctx { color: #6b5b7d; } | |
| .devai-diff-empty { | |
| padding: 22px; | |
| text-align: center; | |
| color: #8a7ba0; | |
| font-family: 'Inter', system-ui, sans-serif; | |
| } | |
| </style> | |
| """ | |
| def render_diff_html(bundle: Dict[str, Any]) -> str: | |
| """Render the ``build_diffs`` output as a full HTML block.""" | |
| if not bundle or not bundle.get("diffs"): | |
| return DIFF_CSS + '<div class="devai-diff-root devai-diff-empty">No file changes detected yet — run a patch to see the Before/After diff.</div>' | |
| summary = bundle.get("summary") or {} | |
| parts: List[str] = [DIFF_CSS, '<div class="devai-diff-root">'] | |
| # Top summary pills | |
| parts.append('<div class="devai-diff-summary">') | |
| parts.append(f'<span class="devai-diff-pill">📁 {summary.get("file_count", 0)} files</span>') | |
| parts.append(f'<span class="devai-diff-pill">✏️ {summary.get("modified", 0)} modified</span>') | |
| parts.append(f'<span class="devai-diff-pill">➕ {summary.get("added", 0)} added</span>') | |
| parts.append(f'<span class="devai-diff-pill">🗑️ {summary.get("deleted", 0)} deleted</span>') | |
| parts.append(f'<span class="devai-diff-pill">+{summary.get("total_added_lines", 0)} / -{summary.get("total_removed_lines", 0)} lines</span>') | |
| parts.append('</div>') | |
| diffs = bundle["diffs"] | |
| # Only show files that actually changed, up to MAX_FILES_IN_HTML. | |
| changed = [d for d in diffs if d.get("status") != "unchanged"] | |
| truncated = False | |
| if len(changed) > MAX_FILES_IN_HTML: | |
| truncated = True | |
| changed = changed[:MAX_FILES_IN_HTML] | |
| for entry in changed: | |
| path = entry.get("path", "?") | |
| status = entry.get("status", "modified") | |
| adds = entry.get("added_lines", 0) | |
| dels = entry.get("removed_lines", 0) | |
| diff_text = entry.get("unified_diff") or "" | |
| parts.append('<div class="devai-diff-file-card">') | |
| parts.append( | |
| '<div class="devai-diff-file-header">' | |
| f'<span>📄 {html.escape(path)}</span>' | |
| f'<span class="devai-diff-file-status">{html.escape(status)} · +{adds} / -{dels}</span>' | |
| '</div>' | |
| ) | |
| parts.append('<div class="devai-diff-body">') | |
| if diff_text: | |
| for line in diff_text.splitlines(): | |
| parts.append(_render_line_html(line)) | |
| else: | |
| parts.append('<div class="devai-diff-ctx">(no textual changes)</div>') | |
| parts.append('</div>') | |
| parts.append('</div>') | |
| if truncated: | |
| parts.append( | |
| '<div class="devai-diff-empty">' | |
| f'... {len(diffs) - MAX_FILES_IN_HTML} additional changed file(s) hidden — ' | |
| 'download the ZIP for the complete patch.' | |
| '</div>' | |
| ) | |
| parts.append('</div>') | |
| return "".join(parts) | |
| # --------------------------------------------------------------------------- | |
| # One-shot helper the pipeline can call. | |
| # --------------------------------------------------------------------------- | |
| def build_before_after_diff( | |
| project_root: str, | |
| before_snapshot: Dict[str, str], | |
| files: Optional[Sequence[str]] = None, | |
| ) -> Dict[str, Any]: | |
| """ | |
| High-level helper — the pipeline captures ``before_snapshot`` via | |
| :func:`capture_before_snapshot` *before* patching. After patching, it | |
| calls this to produce the full bundle:: | |
| { | |
| "diffs": [...], | |
| "summary": {...}, | |
| "html": "<style>...</style><div ...>...</div>", | |
| } | |
| """ | |
| tracked_paths = list(before_snapshot.keys()) | |
| if files: | |
| for f in files: | |
| f = str(f or "").strip().lstrip("/") | |
| if f and f not in tracked_paths: | |
| tracked_paths.append(f) | |
| after = compute_after_snapshot(project_root, tracked_paths) | |
| bundle = build_diffs(before_snapshot, after) | |
| bundle["html"] = render_diff_html(bundle) | |
| return bundle | |
| __all__ = [ | |
| "capture_before_snapshot", | |
| "compute_after_snapshot", | |
| "build_diffs", | |
| "render_diff_html", | |
| "build_before_after_diff", | |
| "DIFF_CSS", | |
| ] | |