vibe/vibe/cli/textual_ui/widgets/diff_rendering.py
maiengineering ac8f1a09fd
v2.18.4 (#866)
Co-authored-by: Albert Jiang <aj@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-07-01 19:03:09 +02:00

234 lines
7.2 KiB
Python

from __future__ import annotations
from collections.abc import Iterable, Sequence
import difflib
from pathlib import Path
import re
from typing import NamedTuple
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.content import Content
from textual.highlight import (
ANSIDarkHighlightTheme,
ANSILightHighlightTheme,
HighlightTheme,
highlight as highlight_code,
)
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.no_markup_static import (
NoMarkupStatic,
NonSelectableStatic,
)
from vibe.core.utils.io import read_safe_async
from vibe.core.utils.text import line_contexts
_HUNK_HEADER_RE = re.compile(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
_ADDED_STYLE = "$text-success"
_REMOVED_STYLE = "$text-error"
_MUTED_STYLE = "$text-muted"
_DIM_MUTED_STYLE = "dim $text-muted"
_DIFF_CSS_CLASS_BY_PREFIX: dict[str, str] = {
"-": "diff-removed",
"+": "diff-added",
" ": "diff-context",
}
# Row CSS class → ExpandingBorder color for the matching gutter row.
DIFF_BORDER_COLOR_BY_CLASS: dict[str, str] = {
"diff-added": "not dim $success",
"diff-removed": "not dim $error",
}
class DiffOccurrence(NamedTuple):
# old_lines/new_lines are the changed snippet expanded to whole lines so the
# diff shows full lines; start_line is None when the location is unknown.
start_line: int | None
old_lines: str
new_lines: str
def language_for_path(file_path: str) -> str:
return Path(file_path).suffix.lstrip(".") or "text"
async def edit_diff_inputs(
file_path: str, old_string: str, new_string: str, *, replace_all: bool
) -> list[DiffOccurrence]:
"""One whole-line diff occurrence per match, from a single pre-edit read."""
path = Path(file_path)
if not path.is_file():
return [DiffOccurrence(None, old_string, new_string)]
content = (await read_safe_async(path)).text
contexts = line_contexts(content, old_string)
if not replace_all:
contexts = contexts[:1]
if not contexts:
return [DiffOccurrence(None, old_string, new_string)]
return [
DiffOccurrence(
start, prefix + old_string + suffix, prefix + new_string + suffix
)
for start, prefix, suffix in contexts
]
def _pick_theme(*, ansi: bool, dark: bool) -> type[HighlightTheme]:
if not ansi:
return HighlightTheme
return ANSIDarkHighlightTheme if dark else ANSILightHighlightTheme
def _highlight_line(code: str, language: str, theme: type[HighlightTheme]) -> Content:
lines = highlight_code(code, language=language, theme=theme).split()
return lines[0] if lines else Content(code)
def _gutter_styles(prefix_char: str, *, ansi: bool) -> tuple[str, str]:
if prefix_char == "-":
if ansi:
return _REMOVED_STYLE, _REMOVED_STYLE
return _REMOVED_STYLE, _DIM_MUTED_STYLE
if prefix_char == "+":
sign_style = _ADDED_STYLE
return sign_style, _ADDED_STYLE if ansi else _DIM_MUTED_STYLE
return _MUTED_STYLE, _DIM_MUTED_STYLE
def _build_diff_gutter(prefix_char: str, lineno: int | None, *, ansi: bool) -> Content:
sign_style, lineno_style = _gutter_styles(prefix_char, ansi=ansi)
lineno_str = f"{lineno:>4} " if lineno is not None else ""
prefix = f"{prefix_char} "
return Content.styled(lineno_str, lineno_style) + Content.styled(prefix, sign_style)
def _build_diff_body(
code: str,
prefix_char: str,
language: str,
*,
ansi: bool,
theme: type[HighlightTheme],
) -> Content:
body = _highlight_line(code, language, theme)
if prefix_char == "-" and ansi:
body = body.stylize("dim")
return body
def _build_diff_line(
code: str,
prefix_char: str,
lineno: int | None,
language: str,
*,
ansi: bool,
theme: type[HighlightTheme],
) -> Content:
return _build_diff_gutter(prefix_char, lineno, ansi=ansi) + _build_diff_body(
code, prefix_char, language, ansi=ansi, theme=theme
)
class _DiffRow(Horizontal):
def __init__(self, gutter: Content, body: Content, *, classes: str) -> None:
self._gutter = gutter
self._body = body
self.plain = gutter.plain + body.plain
super().__init__(classes=classes)
def compose(self) -> ComposeResult:
yield NonSelectableStatic(self._gutter, classes="diff-gutter")
yield Static(self._body, classes="diff-body")
def render_edit_diff(
occurrences: Sequence[DiffOccurrence], language: str, *, ansi: bool, dark: bool
) -> list[Widget]:
theme = _pick_theme(ansi=ansi, dark=dark)
# Each occurrence carries its own whole-line old/new content, so the diff is
# computed per occurrence and anchored at its line number, with a gap between.
widgets: list[Widget] = []
for index, occurrence in enumerate(occurrences):
if index > 0:
widgets.append(NoMarkupStatic("", classes="diff-gap"))
# rstrip only: a trailing newline yields a phantom next-line element to
# drop, but a leading newline is a real (empty) first line anchored at
# start_line, so stripping it would desync the gutter line numbers.
diff_lines = list(
difflib.unified_diff(
occurrence.old_lines.rstrip("\n").split("\n"),
occurrence.new_lines.rstrip("\n").split("\n"),
lineterm="",
n=2,
)
)[2:]
widgets.extend(
_render_occurrence(
diff_lines, occurrence.start_line, language, ansi=ansi, theme=theme
)
)
return widgets
def _render_occurrence(
diff_lines: list[str],
start_line: int | None,
language: str,
*,
ansi: bool,
theme: type[HighlightTheme],
) -> list[Widget]:
offset = (start_line - 1) if start_line else 0
old_lineno = new_lineno = 0 # overwritten by the first @@ header
widgets: list[Widget] = []
first_hunk = True
for line in diff_lines:
prefix_char = line[0]
code = line[1:]
if prefix_char == "@":
# @@ header dropped (gutter has line numbers); gap marks hunk breaks.
if not first_hunk:
widgets.append(NoMarkupStatic("", classes="diff-gap"))
first_hunk = False
if match := _HUNK_HEADER_RE.match(line):
old_lineno = int(match.group(1)) + offset
new_lineno = int(match.group(2)) + offset
continue
if prefix_char == "-":
lineno = old_lineno
old_lineno += 1
elif prefix_char == "+":
lineno = new_lineno
new_lineno += 1
else:
lineno = new_lineno
old_lineno += 1
new_lineno += 1
lineno_val = lineno if start_line else None
gutter = _build_diff_gutter(prefix_char, lineno_val, ansi=ansi)
body = _build_diff_body(code, prefix_char, language, ansi=ansi, theme=theme)
widgets.append(
_DiffRow(gutter, body, classes=_DIFF_CSS_CLASS_BY_PREFIX[prefix_char])
)
return widgets
def diff_border_colors(rows: Iterable[Widget]) -> dict[int, str]:
return {
i: color
for i, row in enumerate(rows)
for cls, color in DIFF_BORDER_COLOR_BY_CLASS.items()
if cls in row.classes
}