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.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 lines; // index → line text final String oldEncoded; final String newEncoded; const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded); } _LineEncoding _encodeLines(List oldLines, List newLines) { final lineIndex = {}; final lines = []; String encode(List 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 = []; 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, ), ], ), ); } }