mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
0cb5fae947
Install motion + animate-ui, overhaul design tokens with brand cyan accent, and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip with spring-physics and blur-fade transitions via animate-ui Radix primitives.
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { Terminal } from '@xterm/xterm';
|
|
import { FitAddon } from '@xterm/addon-fit';
|
|
import { Terminal as TerminalIcon, X } from 'lucide-react';
|
|
import { Button } from './ui/button';
|
|
import '@xterm/xterm/css/xterm.css';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
|
|
interface HostConsoleProps {
|
|
stackName?: string | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
|
const { activeNode } = useNodes();
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const xtermRef = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
|
|
const cleanup = useCallback(() => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
wsRef.current = null;
|
|
}
|
|
if (xtermRef.current) {
|
|
xtermRef.current.dispose();
|
|
xtermRef.current = null;
|
|
}
|
|
fitAddonRef.current = null;
|
|
setIsConnected(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const container = terminalRef.current;
|
|
if (!container) return;
|
|
|
|
let mounted = true;
|
|
|
|
const term = new Terminal({
|
|
theme: {
|
|
background: '#1e1e1e',
|
|
foreground: '#d4d4d4',
|
|
cursor: '#ffffff',
|
|
cursorAccent: '#000000',
|
|
selectionBackground: 'rgba(255, 255, 255, 0.3)',
|
|
},
|
|
fontFamily: 'Consolas, "Courier New", monospace',
|
|
fontSize: 14,
|
|
cursorBlink: true,
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.open(container);
|
|
|
|
xtermRef.current = term;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
if (mounted) fitAddon.fit();
|
|
} catch {
|
|
// Ignore fit errors during initial render
|
|
}
|
|
});
|
|
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
|
const nodeParam = activeNodeId ? `nodeId=${activeNodeId}` : '';
|
|
const stackParam = stackName ? `stack=${encodeURIComponent(stackName)}` : '';
|
|
const queryString = [nodeParam, stackParam].filter(Boolean).join('&');
|
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/system/host-console${queryString ? `?${queryString}` : ''}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
if (!mounted) return;
|
|
setIsConnected(true);
|
|
term.focus();
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
if (mounted) fitAddon.fit();
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) {
|
|
ws.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
}));
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!mounted) return;
|
|
const text = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
term.write(text);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!mounted) return;
|
|
term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
|
|
setIsConnected(false);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (!mounted) return;
|
|
term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
|
|
setIsConnected(false);
|
|
};
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'input',
|
|
payload: data,
|
|
}));
|
|
}
|
|
});
|
|
|
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
if (!mounted || !fitAddonRef.current || !wsRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch {
|
|
return;
|
|
}
|
|
if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) {
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
}));
|
|
}
|
|
}, 50);
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
|
|
return () => {
|
|
mounted = false;
|
|
resizeObserver.disconnect();
|
|
clearTimeout(resizeTimeout);
|
|
cleanup();
|
|
};
|
|
}, [stackName, cleanup]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full w-full bg-background border rounded-lg overflow-hidden shadow-sm">
|
|
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<TerminalIcon className="w-4 h-4 text-muted-foreground" />
|
|
<span>Host Console</span>
|
|
{activeNode && (
|
|
<span className="text-muted-foreground font-normal text-sm">
|
|
- {activeNode.name}
|
|
</span>
|
|
)}
|
|
{stackName && (
|
|
<span className="text-muted-foreground font-normal text-sm">
|
|
({stackName})
|
|
</span>
|
|
)}
|
|
{isConnected && (
|
|
<span className="ml-2 text-xs bg-green-500/10 text-green-500 px-2 py-0.5 rounded-full border border-green-500/20">
|
|
Connected
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 gap-1.5 text-muted-foreground hover:text-foreground">
|
|
<X className="w-4 h-4" />
|
|
Close Console
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 bg-[#1e1e1e] p-2 min-h-0 relative" style={{ overflow: 'hidden' }}>
|
|
<div ref={terminalRef} style={{ width: '100%', height: '100%' }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|