403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { Check, FileCode, FileDiff, FileText, GitBranch, GitCommit, Search } from 'lucide-react';
|
|
|
|
/* Fake diff lines for the mini-terminal */
|
|
const diffLines = [
|
|
{ type: '+', text: 'const bundle = readTaskLedger(taskId)' },
|
|
{ type: ' ', text: ' const files = bundle.files' },
|
|
{ type: '+', text: ' const state = resolveFileState(file)' },
|
|
{ type: ' ', text: ' if (state.textAvailable) {' },
|
|
{ type: '+', text: ' renderExactDiff(state.before, state.after)' },
|
|
{ type: ' ', text: ' }' },
|
|
{ type: '+', text: ' markManualOnly(metadataOnly)' },
|
|
{ type: '+', text: 'interface LedgerState { sha256: string }' },
|
|
{ type: ' ', text: ' for (const event of journal) {' },
|
|
{ type: '+', text: ' attachWorktreeMeta(event)' },
|
|
{ type: ' ', text: ' const relation = detectRename(file)' },
|
|
{ type: '+', text: ' verifyExpectedHash(file)' },
|
|
{ type: ' ', text: ' return reviewModel' },
|
|
{ type: '+', text: ' const diff = computeLineDiff(a, b)' },
|
|
];
|
|
|
|
/* Phases */
|
|
const phases = [
|
|
{ icon: Search, label: 'Reading task ledger...', accent: 'rgba(147,197,253,0.7)' },
|
|
{ icon: FileDiff, label: 'Resolving file states...', accent: 'rgba(253,186,116,0.7)' },
|
|
{ icon: GitBranch, label: 'Checking worktree context...', accent: 'rgba(167,139,250,0.7)' },
|
|
{ icon: FileCode, label: 'Preparing review diffs...', accent: 'rgba(110,231,183,0.7)' },
|
|
];
|
|
|
|
/* Orbiting icons */
|
|
const orbitItems = [
|
|
{ Icon: FileText, angle: 0, r: 76, size: 13, speed: 18 },
|
|
{ Icon: FileDiff, angle: 60, r: 76, size: 14, speed: 18 },
|
|
{ Icon: FileCode, angle: 120, r: 76, size: 13, speed: 18 },
|
|
{ Icon: GitCommit, angle: 180, r: 76, size: 12, speed: 18 },
|
|
{ Icon: GitBranch, angle: 240, r: 76, size: 13, speed: 18 },
|
|
{ Icon: Check, angle: 300, r: 76, size: 12, speed: 18 },
|
|
];
|
|
|
|
/* Spark particles */
|
|
const SPARK_COUNT = 12;
|
|
|
|
const useSparks = () => {
|
|
const [sparks, setSparks] = useState<{ id: number; x: number; y: number; size: number }[]>([]);
|
|
const nextId = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const dist = 30 + Math.random() * 50;
|
|
setSparks((prev) => {
|
|
const next = [
|
|
...prev.slice(-(SPARK_COUNT - 1)),
|
|
{
|
|
id: nextId.current++,
|
|
x: Math.cos(angle) * dist,
|
|
y: Math.sin(angle) * dist,
|
|
size: 1.5 + Math.random() * 2,
|
|
},
|
|
];
|
|
return next;
|
|
});
|
|
}, 400);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
return sparks;
|
|
};
|
|
|
|
/* Fake file counter */
|
|
const useFileCounter = () => {
|
|
const [count, setCount] = useState(0);
|
|
useEffect(() => {
|
|
const interval = setInterval(
|
|
() => {
|
|
setCount((prev) => {
|
|
const step = Math.floor(Math.random() * 3) + 1;
|
|
return prev + step;
|
|
});
|
|
},
|
|
600 + Math.random() * 400
|
|
);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
return count;
|
|
};
|
|
|
|
/* Component */
|
|
export const ChangesLoadingAnimation = (): React.JSX.Element => {
|
|
const [phaseIdx, setPhaseIdx] = useState(0);
|
|
const [phaseFading, setPhaseFading] = useState(false);
|
|
const [visibleLines, setVisibleLines] = useState<number[]>([]);
|
|
const linePointer = useRef(0);
|
|
const sparks = useSparks();
|
|
const fileCount = useFileCounter();
|
|
|
|
/* Phase rotation */
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setPhaseFading(true);
|
|
setTimeout(() => {
|
|
setPhaseIdx((prev) => (prev + 1) % phases.length);
|
|
setPhaseFading(false);
|
|
}, 400);
|
|
}, 3500);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
/* Diff lines streaming */
|
|
const addLine = useCallback(() => {
|
|
setVisibleLines((prev) => {
|
|
const next = [...prev, linePointer.current % diffLines.length];
|
|
linePointer.current++;
|
|
return next.length > 5 ? next.slice(-5) : next;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(addLine, 900);
|
|
return () => clearInterval(interval);
|
|
}, [addLine]);
|
|
|
|
const phase = phases[phaseIdx];
|
|
const PhaseIcon = phase.icon;
|
|
|
|
return (
|
|
<div className="flex w-full flex-col items-center justify-center gap-4">
|
|
{/* Main scene */}
|
|
<div className="relative flex h-44 w-64 items-center justify-center">
|
|
{/* Faint radial grid */}
|
|
<svg className="pointer-events-none absolute inset-0 opacity-[0.04]" viewBox="0 0 256 176">
|
|
{[40, 60, 80].map((r) => (
|
|
<circle
|
|
key={r}
|
|
cx="128"
|
|
cy="88"
|
|
r={r}
|
|
fill="none"
|
|
stroke="var(--color-text)"
|
|
strokeWidth="0.5"
|
|
/>
|
|
))}
|
|
{[0, 45, 90, 135].map((deg) => (
|
|
<line
|
|
key={deg}
|
|
x1="128"
|
|
y1="88"
|
|
x2={128 + Math.cos((deg * Math.PI) / 180) * 80}
|
|
y2={88 + Math.sin((deg * Math.PI) / 180) * 80}
|
|
stroke="var(--color-text)"
|
|
strokeWidth="0.3"
|
|
/>
|
|
))}
|
|
</svg>
|
|
|
|
{/* Rotating dashed orbit ring */}
|
|
<svg className="clda-orbit-ring pointer-events-none absolute size-40" viewBox="0 0 160 160">
|
|
<circle
|
|
cx="80"
|
|
cy="80"
|
|
r="76"
|
|
fill="none"
|
|
stroke="var(--color-border-emphasis)"
|
|
strokeWidth="0.8"
|
|
strokeDasharray="4 7"
|
|
opacity="0.4"
|
|
/>
|
|
</svg>
|
|
|
|
{/* Orbiting icons */}
|
|
{orbitItems.map(({ Icon, angle, r, size, speed }, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute left-1/2 top-1/2"
|
|
style={
|
|
{
|
|
animation: `cldaOrbit ${speed}s linear infinite`,
|
|
'--o-r': `${r}px`,
|
|
'--o-a': `${angle}deg`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<div
|
|
className="text-[var(--color-text-muted)] opacity-40"
|
|
style={
|
|
{
|
|
animation: `cldaOrbitCounter ${speed}s linear infinite`,
|
|
'--o-a': `${angle}deg`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<Icon size={size} strokeWidth={1.5} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Spark particles */}
|
|
{sparks.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="clda-spark absolute left-1/2 top-1/2 rounded-full"
|
|
style={
|
|
{
|
|
width: s.size,
|
|
height: s.size,
|
|
background: phase.accent,
|
|
'--sx': `${s.x}px`,
|
|
'--sy': `${s.y}px`,
|
|
} as React.CSSProperties
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{/* Glow behind center */}
|
|
<div
|
|
className="clda-glow absolute size-20 rounded-3xl"
|
|
style={{ background: phase.accent, opacity: 0.05 }}
|
|
/>
|
|
|
|
{/* Center card: mini diff terminal */}
|
|
<div className="clda-center-card relative z-10 flex w-44 flex-col overflow-hidden rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] shadow-2xl">
|
|
{/* Title bar */}
|
|
<div className="flex items-center gap-1.5 border-b border-[var(--color-border)] px-2.5 py-1.5">
|
|
<div className="flex gap-1">
|
|
<span className="block size-1.5 rounded-full bg-red-500/50" />
|
|
<span className="block size-1.5 rounded-full bg-yellow-500/50" />
|
|
<span className="block size-1.5 rounded-full bg-green-500/50" />
|
|
</div>
|
|
<span className="ml-1 text-[8px] font-medium tracking-wider text-[var(--color-text-muted)] opacity-60">
|
|
DIFF
|
|
</span>
|
|
</div>
|
|
|
|
{/* Diff lines */}
|
|
<div className="flex flex-col gap-px px-2 py-1.5 font-mono text-[9px] leading-[14px]">
|
|
{visibleLines.map((lineIdx, i) => {
|
|
const line = diffLines[lineIdx];
|
|
const isNew = i === visibleLines.length - 1;
|
|
return (
|
|
<div
|
|
key={`${lineIdx}-${i}`}
|
|
className={`flex gap-1.5 rounded-sm px-1 ${isNew ? 'clda-line-in' : ''} ${
|
|
line.type === '+'
|
|
? 'bg-emerald-500/8 text-emerald-400/80'
|
|
: line.type === '-'
|
|
? 'bg-red-500/8 text-red-400/80'
|
|
: 'text-[var(--color-text-muted)] opacity-50'
|
|
}`}
|
|
>
|
|
<span className="w-2 shrink-0 select-none opacity-60">{line.type}</span>
|
|
<span className="truncate">{line.text}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* Blinking cursor line */}
|
|
<div className="flex items-center gap-1.5 px-1 text-[var(--color-text-muted)] opacity-30">
|
|
<span className="w-2 shrink-0"> </span>
|
|
<span className="clda-cursor inline-block h-2.5 w-px bg-[var(--color-text-secondary)]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scanning beam (horizontal) */}
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
|
|
<div
|
|
className="clda-scan-h absolute left-0 h-px w-full"
|
|
style={{
|
|
background: `linear-gradient(to right, transparent, ${phase.accent}, transparent)`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Phase indicator */}
|
|
<div className="flex items-center gap-3">
|
|
{phases.map((p, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex size-6 items-center justify-center rounded-full border transition-all duration-500 ${
|
|
i === phaseIdx
|
|
? 'scale-110 border-[var(--color-text-secondary)] bg-[var(--color-surface-raised)]'
|
|
: i < phaseIdx
|
|
? 'border-transparent bg-[var(--color-text-muted)] opacity-20'
|
|
: 'border-[var(--color-border)] opacity-20'
|
|
}`}
|
|
>
|
|
<p.icon
|
|
size={12}
|
|
strokeWidth={1.5}
|
|
className={
|
|
i === phaseIdx ? 'text-[var(--color-text)]' : 'text-[var(--color-text-muted)]'
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Bottom text */}
|
|
<div className="flex flex-col items-center gap-1">
|
|
<p
|
|
className="duration-400 text-xs font-medium tracking-wide text-[var(--color-text-secondary)] transition-all"
|
|
style={{
|
|
opacity: phaseFading ? 0 : 1,
|
|
transform: phaseFading ? 'translateY(4px)' : 'none',
|
|
}}
|
|
>
|
|
<PhaseIcon size={12} className="mr-1.5 inline-block align-[-2px] opacity-60" />
|
|
{phase.label}
|
|
</p>
|
|
<p className="text-[10px] tabular-nums text-[var(--color-text-muted)] opacity-50">
|
|
{fileCount} ledger objects processed
|
|
</p>
|
|
</div>
|
|
|
|
{/* Keyframes */}
|
|
<style>{`
|
|
.clda-orbit-ring {
|
|
animation: cldaRingSpin 25s linear infinite;
|
|
}
|
|
.clda-glow {
|
|
animation: cldaGlow 3s ease-in-out infinite;
|
|
}
|
|
.clda-center-card {
|
|
animation: cldaCardFloat 5s ease-in-out infinite;
|
|
}
|
|
.clda-cursor {
|
|
animation: cldaBlink 1s step-end infinite;
|
|
}
|
|
|
|
.clda-spark {
|
|
animation: cldaSparkLife 1.8s ease-out forwards;
|
|
}
|
|
|
|
.clda-scan-h {
|
|
animation: cldaScanH 4s ease-in-out infinite;
|
|
}
|
|
|
|
.clda-line-in {
|
|
animation: cldaLineIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes cldaRingSpin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@keyframes cldaOrbit {
|
|
from {
|
|
transform: translate(-50%,-50%) rotate(var(--o-a)) translateX(var(--o-r)) rotate(calc(-1 * var(--o-a)));
|
|
}
|
|
to {
|
|
transform: translate(-50%,-50%) rotate(calc(var(--o-a) + 360deg)) translateX(var(--o-r)) rotate(calc(-1 * var(--o-a) - 360deg));
|
|
}
|
|
}
|
|
|
|
@keyframes cldaOrbitCounter {
|
|
from { transform: rotate(var(--o-a)); }
|
|
to { transform: rotate(calc(var(--o-a) + 360deg)); }
|
|
}
|
|
|
|
@keyframes cldaGlow {
|
|
0%, 100% { transform: scale(1); opacity: 0.05; }
|
|
50% { transform: scale(1.3); opacity: 0.1; }
|
|
}
|
|
|
|
@keyframes cldaCardFloat {
|
|
0%, 100% { transform: translateY(0px); }
|
|
30% { transform: translateY(-3px); }
|
|
70% { transform: translateY(2px); }
|
|
}
|
|
|
|
@keyframes cldaBlink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0; }
|
|
}
|
|
|
|
@keyframes cldaSparkLife {
|
|
0% {
|
|
transform: translate(-50%,-50%) translate(0, 0) scale(1);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: translate(-50%,-50%) translate(var(--sx), var(--sy)) scale(0);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@keyframes cldaScanH {
|
|
0% { top: -2px; opacity: 0; }
|
|
10% { opacity: 0.5; }
|
|
50% { top: 100%; opacity: 0.3; }
|
|
90% { opacity: 0; }
|
|
100% { top: 100%; opacity: 0; }
|
|
}
|
|
|
|
@keyframes cldaLineIn {
|
|
from { opacity: 0; transform: translateX(-8px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|