app / src /components /debug /DebugConsole.tsx
AZILS's picture
Upload 323 files
a21c316 verified
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { Terminal, X, Trash2, Search, ArrowDownToLine, Pause, Play, Bug, Info, AlertTriangle, AlertOctagon } from 'lucide-react';
import { useDebugConsole, LogEntry, LogLevel } from '../../stores/useDebugConsole';
import { cn } from '../../utils/cn';
const LEVEL_CONFIG: Record<LogLevel, { color: string, icon: React.ReactNode, label: string }> = {
'ERROR': { color: 'text-red-500', icon: <AlertOctagon size={12} />, label: 'Error' },
'WARN': { color: 'text-amber-500', icon: <AlertTriangle size={12} />, label: 'Warn' },
'INFO': { color: 'text-blue-500', icon: <Info size={12} />, label: 'Info' },
'DEBUG': { color: 'text-zinc-400', icon: <Bug size={12} />, label: 'Debug' },
'TRACE': { color: 'text-zinc-600', icon: <Terminal size={12} />, label: 'Trace' },
};
const LogRow = React.memo(({ log }: { log: LogEntry }) => {
const [expanded, setExpanded] = useState(false);
const date = new Date(log.timestamp);
const timeStr = date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + date.getMilliseconds().toString().padStart(3, '0');
const hasFields = Object.keys(log.fields).length > 0;
return (
<div className="border-b border-zinc-100 dark:border-white/5 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<div
className={cn("flex gap-2 px-2 py-1 items-start cursor-default text-[11px]", hasFields && "cursor-pointer")}
onClick={() => hasFields && setExpanded(!expanded)}
>
<span className="text-zinc-400 dark:text-zinc-500 shrink-0 select-none min-w-[85px]">{timeStr}</span>
<span className={cn("shrink-0 min-w-[50px] font-bold uppercase flex items-center gap-1", LEVEL_CONFIG[log.level as LogLevel].color)}>
{LEVEL_CONFIG[log.level as LogLevel].icon}
{log.level}
</span>
<span className="text-zinc-500 dark:text-zinc-400 shrink-0 min-w-[120px] max-w-[120px] truncate font-medium" title={log.target}>
{log.target.split('::').slice(-2).join('::')}
</span>
<span className={cn("flex-1 break-words whitespace-pre-wrap font-medium",
// Adapt text color based on level slightly, or keep standard
"text-zinc-700 dark:text-zinc-300"
)}>
{log.message}
</span>
</div>
{expanded && hasFields && (
<div className="px-4 py-2 bg-zinc-50 dark:bg-black/20 text-zinc-600 dark:text-zinc-400 border-t border-zinc-100 dark:border-white/5 text-[11px]">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
{Object.entries(log.fields).map(([key, value]) => (
<React.Fragment key={key}>
<span className="text-zinc-400 dark:text-zinc-500 text-right">{key}:</span>
<span className="text-zinc-800 dark:text-zinc-300 break-all select-text font-medium">{value}</span>
</React.Fragment>
))}
</div>
</div>
)}
</div>
);
});
interface DebugConsoleProps {
embedded?: boolean;
}
const DebugConsole: React.FC<DebugConsoleProps> = ({ embedded = false }) => {
const { t } = useTranslation();
const {
isOpen, close, logs, clearLogs,
filter, setFilter,
searchTerm, setSearchTerm,
autoScroll, setAutoScroll,
checkEnabled
} = useDebugConsole();
const scrollRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(320);
// Initial check
useEffect(() => {
checkEnabled();
}, []);
// Auto scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs, autoScroll, isOpen]);
// Handle resize
const startResizing = (e: React.MouseEvent) => {
e.preventDefault();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResizing);
};
const handleMouseMove = (e: MouseEvent) => {
const newHeight = window.innerHeight - e.clientY;
if (newHeight > 100 && newHeight < window.innerHeight - 100) {
setHeight(newHeight);
}
};
const stopResizing = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResizing);
};
const toggleLevel = (level: LogLevel) => {
if (filter.includes(level)) {
setFilter(filter.filter(l => l !== level));
} else {
setFilter([...filter, level]);
}
};
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const element = e.currentTarget;
const isAtBottom = Math.abs(element.scrollHeight - element.scrollTop - element.clientHeight) < 20;
if (!isAtBottom && autoScroll) {
setAutoScroll(false);
} else if (isAtBottom && !autoScroll) {
setAutoScroll(true);
}
};
const filteredLogs = logs.filter(log => {
if (!filter.includes(log.level as LogLevel)) return false;
if (searchTerm && !log.message.toLowerCase().includes(searchTerm.toLowerCase()) &&
!log.target.toLowerCase().includes(searchTerm.toLowerCase())) return false;
return true;
});
const content = (
<div
className={cn(
"flex flex-col font-sans transition-colors duration-200",
"bg-white dark:bg-[#1e1e1e]",
"text-zinc-700 dark:text-zinc-300",
embedded
? "h-full w-full rounded-xl border border-zinc-200 dark:border-white/10 shadow-sm overflow-hidden"
: "fixed bottom-0 left-0 right-0 border-t border-zinc-200 dark:border-zinc-800 shadow-2xl z-[9999]"
)}
style={embedded ? undefined : { height }}
>
{/* Resize Handle (only for non-embedded) */}
{!embedded && (
<div
className="h-1 bg-zinc-200 dark:bg-zinc-800 hover:bg-blue-500 cursor-ns-resize transition-colors w-full"
onMouseDown={startResizing}
/>
)}
{/* Toolbar */}
<div className={cn(
"flex items-center justify-between px-3 py-2 select-none border-b",
"bg-zinc-50 dark:bg-[#252526]",
"border-zinc-200 dark:border-black/20",
embedded && "rounded-t-xl"
)}>
<div className="flex items-center gap-3">
<span className="flex items-center gap-2 font-medium text-xs tracking-wide text-zinc-500 dark:text-zinc-400">
<Terminal size={14} className="opacity-70" />
CONSOLE
</span>
<div className="h-4 w-px bg-zinc-200 dark:bg-white/10 mx-1" />
{/* Filter Toggles */}
<div className="flex rounded-md p-0.5 border bg-white dark:bg-black/20 border-zinc-200 dark:border-white/5">
{(Object.keys(LEVEL_CONFIG) as LogLevel[]).map(level => (
<button
key={level}
onClick={() => toggleLevel(level)}
className={cn(
"px-2.5 py-0.5 text-[10px] uppercase font-bold rounded-[3px] transition-all",
filter.includes(level)
? LEVEL_CONFIG[level].color + " bg-zinc-100 dark:bg-white/10 shadow-sm"
: "text-zinc-400 dark:text-zinc-600 hover:text-zinc-600 dark:hover:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-white/5"
)}
>
{level}
</button>
))}
</div>
{/* Search */}
<div className="relative group ml-2">
<Search size={13} className="absolute left-2.5 top-1.5 text-zinc-400 dark:text-zinc-500 group-focus-within:text-zinc-600 dark:group-focus-within:text-zinc-300 transition-colors" />
<input
type="text"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder="Filter logs..."
className={cn(
"border border-transparent rounded-md pl-8 pr-3 py-1 text-xs w-40 focus:w-64 transition-all focus:outline-none placeholder:text-zinc-400",
"bg-zinc-100 dark:bg-black/20",
"text-zinc-800 dark:text-zinc-300",
"focus:bg-white dark:focus:bg-black/40",
"focus:border-zinc-200 dark:focus:border-white/10"
)}
/>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setAutoScroll(!autoScroll)}
className={cn(
"p-1.5 rounded-md transition-all",
autoScroll
? "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-500/10 hover:bg-green-200 dark:hover:bg-green-500/20"
: "text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/5"
)}
title={autoScroll ? t('debug_console.pause_scroll', { defaultValue: 'Pause scroll' }) : t('debug_console.resume_scroll', { defaultValue: 'Resume scroll' })}
>
{autoScroll ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={() => {
console.log('Clear button clicked');
clearLogs();
}}
className="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-all"
title={t('debug_console.clear', { defaultValue: 'Clear' })}
>
<Trash2 size={14} />
</button>
{!embedded && (
<button
onClick={close}
className="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5 transition-all ml-1"
>
<X size={14} />
</button>
)}
</div>
</div>
{/* Log content */}
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"flex-1 overflow-y-auto overflow-x-hidden font-mono text-xs",
"bg-white dark:bg-[#1e1e1e]",
// Custom scrollbar styling - Light/Dark
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-zinc-300 dark:[&::-webkit-scrollbar-thumb]:bg-[#424242]",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:border-2",
"[&::-webkit-scrollbar-thumb]:border-white dark:[&::-webkit-scrollbar-thumb]:border-[#1e1e1e]",
"hover:[&::-webkit-scrollbar-thumb]:bg-zinc-400 dark:hover:[&::-webkit-scrollbar-thumb]:bg-[#4f4f4f]",
embedded && "rounded-b-none"
)}
>
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full select-none text-zinc-400 dark:text-zinc-600">
<Terminal size={48} className="mb-4 opacity-20" />
<p className="text-sm font-medium opacity-50">{t('debug_console.no_logs', { defaultValue: 'No logs to display' })}</p>
<p className="text-xs mt-1 opacity-30">{t('debug_console.no_logs_hint', { defaultValue: 'Logs will appear here in real-time' })}</p>
</div>
) : (
<div className="py-1">
{filteredLogs.map(log => <LogRow key={log.id} log={log} />)}
</div>
)}
</div>
{/* Footer */}
<div className={cn(
"flex items-center justify-between px-3 py-1.5 border-t text-white text-[10px]",
"bg-[#007acc] border-[#007acc]", // Keep VS Code blue for brand recognition/consistency, or adapt? Let's keep blue for now as it looks good in both.
embedded && "rounded-b-lg"
)}>
<div className="flex items-center gap-4">
{/* Level stats */}
{(Object.keys(LEVEL_CONFIG) as LogLevel[]).map(level => {
const count = logs.filter(l => l.level === level).length;
if (count === 0) return null;
const icon = LEVEL_CONFIG[level].icon; // FIX: Don't clone, just render new one or use generic
// Since I cannot easily clone with correct types without casting, and the icon is simple
// I will just use a mapping or switch, OR just render the icon node as is if it fits,
// BUT I want to force size and color.
// Simplest fix: Just allow the icon to be rendered, and assume it inherits size? No, Lucide icons have explicit size.
// Let's just redefine a small helper to get the icon component type if possible, or just ignore the size override since 12 is small enough.
// Actually, the LEVEL_CONFIG defines size=12. So I can just render it.
return (
<span
key={level}
className="font-medium flex items-center gap-1.5 select-none opacity-90"
>
{icon}
{count}
</span>
);
})}
</div>
{/* Auto-scroll indicator & Status */}
<div className="flex items-center gap-3">
{!autoScroll && (
<button
onClick={scrollToBottom}
className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-black/20 hover:bg-black/30 font-medium transition-colors"
>
<ArrowDownToLine size={10} />
{t('debug_console.scroll_to_bottom', { defaultValue: 'Scroll' })}
</button>
)}
<span className="opacity-80 flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-white animate-pulse"></div>
Live
</span>
</div>
</div>
</div>
);
if (embedded) {
return content;
}
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/10 z-[9998]"
onClick={close}
/>
{/* Animated Panel */}
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed inset-x-0 bottom-0 z-[9999]"
>
{content}
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default DebugConsole;