243 lines
7.2 KiB
TypeScript
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;
|