The-Agency/lib/ui/widgets/chat/diff_view.dart

329 lines
8.7 KiB
Dart

import "package:diff_match_patch/diff_match_patch.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const _contextLines = 3;
class DiffView extends StatelessWidget {
const DiffView({
super.key,
this.oldString,
this.newString,
this.content,
}) : assert(
content != null || (oldString != null && newString != null),
"Provide either content (view-only) or oldString+newString (diff)",
);
final String? oldString;
final String? newString;
// view-only mode — show content as plain code, no diff colors
final String? content;
@override
Widget build(BuildContext context) {
if (content != null) {
final lines = content!.split("\n");
final viewLines = [
for (int i = 0; i < lines.length; i++)
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
];
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
return _HunkView(hunk: hunk);
}
final hunks = _computeHunks(oldString!, newString!);
if (hunks.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final hunk in hunks) ...[
// is first
if (hunk != hunks.first) ...[
Divider(),
Gap(1),
Divider(),
],
_HunkView(hunk: hunk)
],
],
);
}
}
// ─── data model ───────────────────────────────────────────────────────────────
enum _LineKind { context, added, removed }
class _DiffLine {
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
final _LineKind kind;
final String text;
final int? oldLine;
final int? newLine;
}
class _Hunk {
_Hunk({
required this.oldStart,
required this.newStart,
required this.lines,
});
final int oldStart;
final int newStart;
final List<_DiffLine> lines;
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
}
// ─── diff computation ─────────────────────────────────────────────────────────
List<_Hunk> _computeHunks(String oldStr, String newStr) {
final dmp = DiffMatchPatch();
final oldLines = oldStr.split("\n");
final newLines = newStr.split("\n");
// encode lines → single chars so dmp does line-level diff
final enc = _encodeLines(oldLines, newLines);
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
dmp.diffCleanupSemantic(diffs);
// expand diffs back to line sequences
final rawLines = <_DiffLine>[];
int oldIdx = 0;
int newIdx = 0;
for (final d in diffs) {
final count = d.text.length; // each char == one line
switch (d.operation) {
case DIFF_EQUAL:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.context,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
newLine: newIdx + 1,
));
oldIdx++;
newIdx++;
}
break;
case DIFF_DELETE:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.removed,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
));
oldIdx++;
}
break;
case DIFF_INSERT:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.added,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
newLine: newIdx + 1,
));
newIdx++;
}
break;
}
}
return _groupIntoHunks(rawLines);
}
// keep only context lines that are within _contextLines of a change
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
final n = rawLines.length;
// mark which context lines to keep
final keep = List<bool>.filled(n, false);
for (int i = 0; i < n; i++) {
if (rawLines[i].kind != _LineKind.context) {
for (int j = (i - _contextLines).clamp(0, n - 1);
j <= (i + _contextLines).clamp(0, n - 1);
j++) {
keep[j] = true;
}
}
}
final hunks = <_Hunk>[];
int i = 0;
while (i < n) {
if (!keep[i]) {
i++;
continue;
}
// start of a new hunk
final hunkLines = <_DiffLine>[];
int oldStart = rawLines[i].oldLine ?? 1;
int newStart = rawLines[i].newLine ?? 1;
while (i < n && keep[i]) {
hunkLines.add(rawLines[i]);
i++;
}
hunks.add(_Hunk(
oldStart: oldStart,
newStart: newStart,
lines: hunkLines,
));
}
return hunks;
}
// line encoding — maps unique lines to single unicode chars starting at U+E000
class _LineEncoding {
final List<String> lines; // index → line text
final String oldEncoded;
final String newEncoded;
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
}
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
final lineIndex = <String, int>{};
final lines = <String>[];
String encode(List<String> src) {
final buf = StringBuffer();
for (final line in src) {
if (!lineIndex.containsKey(line)) {
lineIndex[line] = lines.length;
lines.add(line);
}
buf.writeCharCode(0xE000 + lineIndex[line]!);
}
return buf.toString();
}
final oldEncoded = encode(oldLines);
final newEncoded = encode(newLines);
return _LineEncoding(lines, oldEncoded, newEncoded);
}
// ─── widgets ──────────────────────────────────────────────────────────────────
String _hunkSummary(_Hunk hunk) {
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
final parts = <String>[];
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
return parts.join(", ");
}
class _HunkView extends StatelessWidget {
const _HunkView({required this.hunk});
final _Hunk hunk;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// hunk header
Container(
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
_hunkSummary(hunk),
style: TextStyle(color: theme.colorScheme.mutedForeground),
).xSmall.mono,
),
Divider(),
for (final line in hunk.lines) _LineView(line: line),
],
);
}
}
class _LineView extends StatelessWidget {
const _LineView({required this.line});
final _DiffLine line;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color bg;
final Color fg;
final String prefix;
switch (line.kind) {
case _LineKind.added:
bg = const Color(0xFF166534).withValues(alpha: 0.2);
fg = const Color(0xFF4ADE80);
prefix = "+";
break;
case _LineKind.removed:
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
fg = const Color(0xFFF87171);
prefix = "-";
break;
case _LineKind.context:
bg = Colors.transparent;
fg = theme.colorScheme.mutedForeground;
prefix = " ";
break;
}
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Text(
lineNum != null ? "$lineNum" : "",
textAlign: TextAlign.right,
style: TextStyle(color: numColor),
).mono.xSmall,
),
const SizedBox(width: 8),
SizedBox(
width: 10,
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
),
const SizedBox(width: 4),
Expanded(
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
),
],
),
);
}
}