329 lines
8.7 KiB
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,
|
|
),
|
|
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|