Files
XCTerminal/src/App.tsx
2026-03-19 22:38:54 +08:00

243 lines
7.2 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { TerminalViewport } from './components/TerminalViewport';
import {
createTerminalSession,
connectTerminalStream,
sendTerminalInput,
resizeTerminal,
closeTerminal,
} from './lib/terminalApi';
import { getDefaultTheme, type TerminalTheme } from './lib/terminalTheme';
import { useTerminalStore, type TerminalChunk } from './stores/useTerminalStore';
const DEFAULT_CWD = '/workspace';
const DEFAULT_FONT_SIZE = 14;
const DEFAULT_FONT_FAMILY = 'IBM Plex Mono';
function App() {
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [chunks, setChunks] = useState<TerminalChunk[]>([]);
const [theme] = useState<TerminalTheme>(getDefaultTheme());
const [fontSize] = useState(DEFAULT_FONT_SIZE);
const [fontFamily] = useState(DEFAULT_FONT_FAMILY);
const [cwd, setCwd] = useState(DEFAULT_CWD);
const [inputCwd, setInputCwd] = useState(DEFAULT_CWD);
const sessionIdRef = useRef<string | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const nextChunkIdRef = useRef(1);
const isMountedRef = useRef(true);
const handleInput = useCallback(async (data: string) => {
const sessionId = sessionIdRef.current;
if (!sessionId) return;
try {
await sendTerminalInput(sessionId, data);
} catch (err) {
console.error('Failed to send input:', err);
}
}, []);
const handleResize = useCallback(async (cols: number, rows: number) => {
const sessionId = sessionIdRef.current;
if (!sessionId) return;
try {
await resizeTerminal(sessionId, cols, rows);
} catch (err) {
console.error('Failed to resize terminal:', err);
}
}, []);
const connectToTerminal = useCallback(async (targetCwd: string) => {
// Cleanup previous session
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (sessionIdRef.current) {
try {
await closeTerminal(sessionIdRef.current);
} catch {
// Ignore cleanup errors
}
sessionIdRef.current = null;
}
setIsConnecting(true);
setError(null);
setChunks([]);
nextChunkIdRef.current = 1;
try {
const session = await createTerminalSession({
cwd: targetCwd,
cols: 80,
rows: 24,
});
if (!isMountedRef.current) return;
sessionIdRef.current = session.sessionId;
cleanupRef.current = connectTerminalStream(
session.sessionId,
(event) => {
if (!isMountedRef.current) return;
if (event.type === 'connected') {
setIsConnected(true);
setIsConnecting(false);
} else if (event.type === 'data' && event.data) {
setChunks((prev) => {
const newChunk: TerminalChunk = {
id: nextChunkIdRef.current++,
data: event.data!,
};
return [...prev, newChunk];
});
} else if (event.type === 'exit') {
setIsConnected(false);
if (event.exitCode !== 0) {
setError(`Terminal exited with code ${event.exitCode}`);
}
} else if (event.type === 'reconnecting') {
setIsConnecting(true);
}
},
(err, fatal) => {
console.error('Terminal error:', err);
setIsConnected(false);
if (fatal) {
setError(err.message);
}
setIsConnecting(false);
}
);
} catch (err) {
if (!isMountedRef.current) return;
setIsConnecting(false);
setError(err instanceof Error ? err.message : 'Failed to create terminal');
}
}, []);
const handleConnect = useCallback(() => {
if (inputCwd.trim()) {
setCwd(inputCwd.trim());
connectToTerminal(inputCwd.trim());
}
}, [inputCwd, connectToTerminal]);
const handleDisconnect = useCallback(async () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (sessionIdRef.current) {
try {
await closeTerminal(sessionIdRef.current);
} catch {
// Ignore cleanup errors
}
sessionIdRef.current = null;
}
setIsConnected(false);
setChunks([]);
}, []);
useEffect(() => {
isMountedRef.current = true;
connectToTerminal(DEFAULT_CWD);
return () => {
isMountedRef.current = false;
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, [connectToTerminal]);
return (
<div className="h-screen w-screen flex flex-col bg-[#1e1e1e]">
{/* Header / Toolbar */}
<div className="flex items-center gap-4 p-3 bg-[#252526] border-b border-[#3c3c3c]">
<span className="text-sm font-medium text-[#cccccc]">Terminal</span>
<div className="flex items-center gap-2">
<input
type="text"
value={inputCwd}
onChange={(e) => setInputCwd(e.target.value)}
placeholder="Working directory..."
className="px-3 py-1.5 text-sm bg-[#3c3c3c] text-[#d4d4d4] border border-[#3c3c3c] rounded focus:outline-none focus:border-[#007acc] w-64"
/>
<button
onClick={handleConnect}
disabled={isConnecting}
className="px-3 py-1.5 text-sm bg-[#0e639c] text-white rounded hover:bg-[#1177bb] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isConnecting ? 'Connecting...' : 'Connect'}
</button>
</div>
<div className="flex items-center gap-2 ml-auto">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-[#4ec9b0]' : 'bg-[#f14c4c]'}`} />
<span className="text-xs text-[#808080]">
{isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Disconnected'}
</span>
</div>
{isConnected && (
<button
onClick={handleDisconnect}
className="px-3 py-1.5 text-sm bg-[#3c3c3c] text-[#d4d4d4] rounded hover:bg-[#4c4c4c]"
>
Disconnect
</button>
)}
</div>
{/* Error message */}
{error && (
<div className="px-4 py-2 bg-[#3c3c3c] text-[#f14c4c] text-sm">
{error}
</div>
)}
{/* Terminal viewport */}
<div className="flex-1 min-h-0">
{isConnected ? (
<TerminalViewport
sessionKey={`${cwd}-${sessionIdRef.current}`}
chunks={chunks}
onInput={handleInput}
onResize={handleResize}
theme={theme}
fontFamily={fontFamily}
fontSize={fontSize}
autoFocus
/>
) : (
<div className="h-full flex items-center justify-center text-[#808080]">
{isConnecting ? 'Connecting to terminal...' : 'Click "Connect" to start a terminal session'}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-1 bg-[#007acc] text-white text-xs flex items-center justify-between">
<span>{cwd}</span>
<span>{isConnected ? 'Connected' : 'Not connected'}</span>
</div>
</div>
);
}
export default App;