"Initial_commit"
This commit is contained in:
151
components/Terminal.tsx
Normal file
151
components/Terminal.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { serialService } from '../services/serialService';
|
||||
import { Terminal as TerminalIcon, Send, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
type: 'tx' | 'rx' | 'info' | 'error';
|
||||
timestamp: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
const Terminal: React.FC = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const MAX_LOGS = 200;
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to serial service logs
|
||||
serialService.setLogCallback((type, data) => {
|
||||
setLogs(prev => {
|
||||
const newLog: LogEntry = {
|
||||
id: Date.now() + Math.random(), // Ensure unique key for fast updates
|
||||
type,
|
||||
timestamp: new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
||||
data
|
||||
};
|
||||
// NEWEST FIRST: Prepend new log to the beginning of the array
|
||||
const newLogs = [newLog, ...prev];
|
||||
|
||||
if (newLogs.length > MAX_LOGS) {
|
||||
return newLogs.slice(0, MAX_LOGS);
|
||||
}
|
||||
return newLogs;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
serialService.setLogCallback(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If auto-scroll is on, force scroll to top when logs change
|
||||
if (autoScroll && logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
// Remove spaces and validate hex
|
||||
const hexStr = input.replace(/\s+/g, '');
|
||||
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
|
||||
// Local error log (prepended)
|
||||
setLogs(prev => [{
|
||||
id: Date.now(),
|
||||
type: 'error',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
data: '유효하지 않은 HEX 포맷입니다.'
|
||||
}, ...prev]);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(hexStr.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
||||
|
||||
try {
|
||||
await serialService.sendRaw(bytes);
|
||||
// readRaw also triggers callback inside service
|
||||
await serialService.readRaw();
|
||||
} catch (e: any) {
|
||||
// Errors from service are logged via callback usually
|
||||
}
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-950/50">
|
||||
<div className="flex justify-between items-center p-3 border-b border-gray-800 bg-gray-900/50">
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<TerminalIcon size={18} className="text-gray-400 shrink-0" />
|
||||
<span className="font-semibold text-gray-300 truncate">터미널 / Logs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
className={`p-1.5 rounded transition-colors ${autoScroll ? 'text-green-400 bg-green-900/20' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
title={autoScroll ? "Auto-scroll: ON (Top)" : "Auto-scroll: OFF"}
|
||||
>
|
||||
{/* Icon indicates scroll direction focus */}
|
||||
<ArrowUp size={16} className={autoScroll ? "" : "opacity-50"} />
|
||||
</button>
|
||||
<button onClick={() => setLogs([])} className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-900/20 rounded transition-colors" title="Clear Logs">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-black/50 overflow-hidden flex flex-col font-mono text-xs shadow-inner">
|
||||
{/* Input Area (Top) or Bottom? Keeping input at bottom is standard, logs flow down from top */}
|
||||
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-2 space-y-1 scroll-smooth"
|
||||
>
|
||||
{logs.length === 0 && <div className="text-gray-700 text-center mt-4 italic">대기 중... (Logs)</div>}
|
||||
|
||||
{logs.map(log => (
|
||||
<div key={log.id} className="flex gap-2 animate-in fade-in slide-in-from-top-1 hover:bg-gray-900/30 p-0.5 rounded border-b border-gray-900/50 last:border-0">
|
||||
<span className="text-gray-600 shrink-0 select-none">[{log.timestamp}]</span>
|
||||
<div className="break-all flex-1">
|
||||
{log.type === 'tx' && <span className="text-blue-400 font-bold mr-1">TX</span>}
|
||||
{log.type === 'rx' && <span className="text-green-400 font-bold mr-1">RX</span>}
|
||||
{log.type === 'error' && <span className="text-red-500 font-bold mr-1">ERR</span>}
|
||||
{log.type === 'info' && <span className="text-gray-400 font-bold mr-1">INF</span>}
|
||||
<span className={`${log.type === 'error' ? 'text-red-400' : 'text-gray-300'}`}>
|
||||
{log.data}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-gray-800 bg-gray-900/30">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder="HEX (e.g. DD A5 03...)"
|
||||
className="flex-1 bg-gray-950 border border-gray-700 rounded px-2 py-1.5 text-gray-200 focus:outline-none focus:border-blue-500 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 rounded flex items-center gap-1 transition-colors text-xs font-bold"
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Terminal;
|
||||
Reference in New Issue
Block a user