feat: add file edit diff progress view

Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
@@ -1,5 +1,3 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
@@ -31,83 +29,7 @@ function DiffValue({ sign, value, className }: { sign: string; value: number; cl
>
<span className="inline-flex items-baseline leading-none" aria-hidden>
{sign}
<AnimatedNumber value={safeValue} />
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
function AnimatedNumber({ value }: { value: number }) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
const [display, setDisplay] = useState(0);
const displayRef = useRef(0);
const setAnimatedDisplay = useCallback((next: number) => {
displayRef.current = next;
setDisplay(next);
}, []);
useEffect(() => {
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
setAnimatedDisplay(safeValue);
return;
}
const start = displayRef.current;
const delta = safeValue - start;
if (delta === 0) {
setAnimatedDisplay(safeValue);
return;
}
const duration = 260;
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedDisplay(Math.round(start + delta * eased));
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
displayRef.current = safeValue;
};
frame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(frame);
}, [safeValue, setAnimatedDisplay]);
return <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
{safeValue}
</span>
</span>
);