import { useState, useEffect, useRef } from 'react'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Loader2 } from 'lucide-react'; import { terminalSessionApi } from '@/api'; interface ReplayDialogProps { open: boolean; onOpenChange: (open: boolean) => void; sessionId: number | null; } interface AsciicastRow { time: number; eventType: string; data: string; } /** * 终端录制回放 Dialog — asciicast v2 定时回放 (KoKo-compatible) */ export function ReplayDialog({ open, onOpenChange, sessionId }: ReplayDialogProps) { const containerRef = useRef(null); const termRef = useRef(null); const timerRef = useRef | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [progress, setProgress] = useState(0); useEffect(() => { if (!open || !sessionId) return; const term = new XTerm({ cursorBlink: false, fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', disableStdin: true, cols: 80, rows: 24, theme: { background: '#0a0e14', foreground: '#d6d6d6', cursor: '#0a0e14', selectionBackground: 'rgba(255,255,255,0.2)', }, }); const fit = new FitAddon(); term.loadAddon(fit); termRef.current = term; let cancelled = false; setLoading(true); setError(''); setProgress(0); terminalSessionApi .getRecord(sessionId) .then((raw: any) => { if (cancelled) return; const data = typeof raw === 'string' ? raw : (raw?.data || raw?.record_data || ''); if (!data || data.trim() === '') { setError('No recording data available for this session'); setLoading(false); return; } if (containerRef.current) { term.open(containerRef.current); try { fit.fit(); } catch { /* noop */ } } // Parse asciicast v2 format const lines = data.split('\n').filter((l: string) => l.trim()); const rows: AsciicastRow[] = []; for (const line of lines) { try { const parsed = JSON.parse(line); if (Array.isArray(parsed) && parsed.length >= 3) { // asciicast v2 row: [time, "o", "data"] rows.push({ time: parsed[0], eventType: parsed[1], data: parsed[2] }); } // Header is skipped (JSON object, not array) } catch { // Skip lines that can't be parsed } } if (rows.length === 0) { // Fallback: just write raw data (old format) term.write(data); setProgress(100); setLoading(false); return; } // Timed playback setLoading(false); const totalTime = rows[rows.length - 1].time; let rowIndex = 0; let lastTime = 0; const playNext = () => { if (cancelled || rowIndex >= rows.length) { setProgress(100); return; } const row = rows[rowIndex]; const delay = (row.time - lastTime) * 1000; // convert to ms lastTime = row.time; rowIndex++; // Clamp delay to avoid extremely long waits (max 2s between chunks) const clampedDelay = Math.min(delay, 2000); timerRef.current = setTimeout(() => { if (cancelled) return; term.write(row.data); setProgress(Math.round((row.time / totalTime) * 100)); playNext(); }, clampedDelay); }; playNext(); }) .catch((err: any) => { if (!cancelled) { console.error('[Replay] Load failed:', err); setError('Failed to load recording data'); setLoading(false); } }); return () => { cancelled = true; if (timerRef.current) clearTimeout(timerRef.current); try { term.dispose(); } catch { /* noop */ } termRef.current = null; }; }, [open, sessionId]); return ( Terminal Replay #{sessionId ?? ''} {progress > 0 && progress < 100 && ( {progress}% )} Replay recorded terminal output
{loading && (
)} {error && (

{error}

)} {progress > 0 && progress < 100 && (
)}
); }