Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Terminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@xcopencodweb/terminal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"node server/index.js\" \"vite\"",
|
||||
"dev:server": "node server/index.js",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"clsx": "^2.1.1",
|
||||
"express": "^5.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"ws": "^8.18.3",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/ws": "^8.18.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
341
server/index.js
Normal file
341
server/index.js
Normal file
@@ -0,0 +1,341 @@
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.text({ type: '*/*' }));
|
||||
|
||||
// Terminal sessions storage
|
||||
const terminalSessions = new Map();
|
||||
let sessionIdCounter = 1;
|
||||
|
||||
// Get shell based on platform
|
||||
function getShell() {
|
||||
if (process.platform === 'win32') {
|
||||
// Use PowerShell on Windows
|
||||
return 'powershell.exe';
|
||||
}
|
||||
return process.env.SHELL || '/bin/bash';
|
||||
}
|
||||
|
||||
// Create terminal session
|
||||
async function createTerminalSession(cwd, cols = 80, rows = 24) {
|
||||
const pty = await import('node-pty');
|
||||
const shell = getShell();
|
||||
|
||||
// Validate working directory
|
||||
let workingDir = cwd || os.homedir();
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
if (!fs.existsSync(workingDir)) {
|
||||
workingDir = os.homedir();
|
||||
}
|
||||
} catch {
|
||||
workingDir = os.homedir();
|
||||
}
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd: workingDir,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
return ptyProcess;
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId() {
|
||||
return `term-${Date.now()}-${sessionIdCounter++}`;
|
||||
}
|
||||
|
||||
// API: Create terminal session
|
||||
app.post('/api/terminal/create', async (req, res) => {
|
||||
try {
|
||||
const { cwd, cols, rows } = req.body;
|
||||
|
||||
const sessionId = generateSessionId();
|
||||
const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24);
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
pty: ptyProcess,
|
||||
cwd: ptyProcess.cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
terminalSessions.set(sessionId, session);
|
||||
|
||||
// Handle PTY data
|
||||
ptyProcess.onData((data) => {
|
||||
// Data will be sent via SSE or WebSocket
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Terminal exited: session=${sessionId} code=${exitCode} signal=${signal}`);
|
||||
terminalSessions.delete(sessionId);
|
||||
});
|
||||
|
||||
res.json({
|
||||
sessionId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
capabilities: {
|
||||
input: {
|
||||
preferred: 'http',
|
||||
transports: ['ws', 'http'],
|
||||
ws: {
|
||||
path: '/api/terminal/input-ws',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create terminal:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SSE: Terminal output stream
|
||||
app.get('/api/terminal/:sessionId/stream', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = terminalSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Terminal session not found' });
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const clientId = Date.now();
|
||||
const dataHandler = (data) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
|
||||
};
|
||||
|
||||
const exitHandler = ({ exitCode, signal }) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
|
||||
res.end();
|
||||
};
|
||||
|
||||
session.pty.onData(dataHandler);
|
||||
session.pty.onExit(exitHandler);
|
||||
|
||||
// Send initial connection event
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
session.pty.offData(dataHandler);
|
||||
session.pty.offExit(exitHandler);
|
||||
console.log(`Client disconnected: session=${sessionId} client=${clientId}`);
|
||||
});
|
||||
});
|
||||
|
||||
// HTTP: Send terminal input
|
||||
app.post('/api/terminal/:sessionId/input', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = terminalSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Terminal session not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = req.body;
|
||||
session.pty.write(data);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Failed to write to terminal:', error);
|
||||
res.status(500).json({ error: 'Failed to write to terminal' });
|
||||
}
|
||||
});
|
||||
|
||||
// HTTP: Resize terminal
|
||||
app.post('/api/terminal/:sessionId/resize', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = terminalSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Terminal session not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { cols, rows } = req.body;
|
||||
session.pty.resize(cols, rows);
|
||||
session.cols = cols;
|
||||
session.rows = rows;
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Failed to resize terminal:', error);
|
||||
res.status(500).json({ error: 'Failed to resize terminal' });
|
||||
}
|
||||
});
|
||||
|
||||
// HTTP: Close terminal session
|
||||
app.delete('/api/terminal/:sessionId', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = terminalSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Terminal session not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
session.pty.kill();
|
||||
terminalSessions.delete(sessionId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Failed to close terminal:', error);
|
||||
res.status(500).json({ error: 'Failed to close terminal' });
|
||||
}
|
||||
});
|
||||
|
||||
// Restart terminal session
|
||||
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = terminalSessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Terminal session not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { cwd, cols, rows } = req.body;
|
||||
|
||||
// Kill existing session
|
||||
session.pty.kill();
|
||||
terminalSessions.delete(sessionId);
|
||||
|
||||
// Create new session
|
||||
const newSessionId = generateSessionId();
|
||||
const ptyProcess = await createTerminalSession(cwd, cols || 80, rows || 24);
|
||||
|
||||
const newSession = {
|
||||
id: newSessionId,
|
||||
pty: ptyProcess,
|
||||
cwd: ptyProcess.cwd,
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
terminalSessions.set(newSessionId, newSession);
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
terminalSessions.delete(newSessionId);
|
||||
});
|
||||
|
||||
res.json({
|
||||
sessionId: newSessionId,
|
||||
cols: newSession.cols,
|
||||
rows: newSession.rows,
|
||||
capabilities: {
|
||||
input: {
|
||||
preferred: 'http',
|
||||
transports: ['ws', 'http'],
|
||||
ws: {
|
||||
path: '/api/terminal/input-ws',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to restart terminal:', error);
|
||||
res.status(500).json({ error: 'Failed to restart terminal' });
|
||||
}
|
||||
});
|
||||
|
||||
// Force kill terminal
|
||||
app.post('/api/terminal/force-kill', (req, res) => {
|
||||
const { sessionId, cwd } = req.body;
|
||||
|
||||
let killedCount = 0;
|
||||
|
||||
if (sessionId) {
|
||||
const session = terminalSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.pty.kill();
|
||||
terminalSessions.delete(sessionId);
|
||||
killedCount = 1;
|
||||
}
|
||||
} else if (cwd) {
|
||||
for (const [id, session] of terminalSessions) {
|
||||
if (session.cwd === cwd) {
|
||||
session.pty.kill();
|
||||
terminalSessions.delete(id);
|
||||
killedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ killed: killedCount });
|
||||
});
|
||||
|
||||
// WebSocket for terminal input
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('WebSocket connected for terminal input');
|
||||
|
||||
let boundSessionId = null;
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = new Uint8Array(message);
|
||||
// Control frame: JSON
|
||||
if (data[0] === 0x01) {
|
||||
const jsonStr = new TextDecoder().decode(data.slice(1));
|
||||
const payload = JSON.parse(jsonStr);
|
||||
|
||||
// Bind session
|
||||
if (payload.t === 'b' && payload.s) {
|
||||
boundSessionId = payload.s;
|
||||
console.log(`WebSocket bound to session: ${boundSessionId}`);
|
||||
}
|
||||
|
||||
// Ping
|
||||
if (payload.t === 'p') {
|
||||
ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular input data
|
||||
const text = new TextDecoder().decode(message);
|
||||
if (boundSessionId) {
|
||||
const session = terminalSessions.get(boundSessionId);
|
||||
if (session) {
|
||||
session.pty.write(text);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebSocket message error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Terminal server running on http://localhost:${PORT}`);
|
||||
});
|
||||
242
src/App.tsx
Normal file
242
src/App.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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;
|
||||
1566
src/components/TerminalViewport.tsx
Normal file
1566
src/components/TerminalViewport.tsx
Normal file
File diff suppressed because it is too large
Load Diff
28
src/index.css
Normal file
28
src/index.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: 'IBM Plex Sans', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
770
src/lib/terminalApi.ts
Normal file
770
src/lib/terminalApi.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
capabilities?: {
|
||||
input?: TerminalInputCapability;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalInputCapability {
|
||||
preferred?: 'ws' | 'http';
|
||||
transports?: Array<'ws' | 'http'>;
|
||||
ws?: {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalStreamEvent {
|
||||
type: 'connected' | 'data' | 'exit' | 'reconnecting';
|
||||
data?: string;
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
attempt?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface ConnectStreamOptions {
|
||||
maxRetries?: number;
|
||||
initialRetryDelay?: number;
|
||||
maxRetryDelay?: number;
|
||||
connectionTimeout?: number;
|
||||
}
|
||||
|
||||
type TerminalInputControlMessage = {
|
||||
t: string;
|
||||
s?: string;
|
||||
c?: string;
|
||||
f?: boolean;
|
||||
v?: number;
|
||||
};
|
||||
|
||||
const CONTROL_TAG_JSON = 0x01;
|
||||
const WS_READY_STATE_OPEN = 1;
|
||||
const DEFAULT_TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
|
||||
const WS_SEND_WAIT_MS = 1200;
|
||||
const WS_RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||
const WS_RECONNECT_MAX_DELAY_MS = 30000;
|
||||
const WS_RECONNECT_JITTER_MS = 250;
|
||||
const WS_KEEPALIVE_INTERVAL_MS = 20000;
|
||||
const WS_CONNECT_TIMEOUT_MS = 5000;
|
||||
const GLOBAL_TERMINAL_INPUT_STATE_KEY = '__terminalInputWsState';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const normalizeWebSocketPath = (pathValue: string): string => {
|
||||
if (/^wss?:\/\//i.test(pathValue)) {
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(pathValue)) {
|
||||
const url = new URL(pathValue);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedPath = pathValue.startsWith('/') ? pathValue : `/${pathValue}`;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}${normalizedPath}`;
|
||||
};
|
||||
|
||||
const encodeControlFrame = (payload: TerminalInputControlMessage): Uint8Array => {
|
||||
const jsonBytes = textEncoder.encode(JSON.stringify(payload));
|
||||
const bytes = new Uint8Array(jsonBytes.length + 1);
|
||||
bytes[0] = CONTROL_TAG_JSON;
|
||||
bytes.set(jsonBytes, 1);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const isWsInputSupported = (capability: TerminalInputCapability | null): boolean => {
|
||||
if (!capability) return false;
|
||||
const transports = capability.transports ?? [];
|
||||
const supportsTransport = transports.includes('ws') || capability.preferred === 'ws';
|
||||
return supportsTransport && typeof capability.ws?.path === 'string' && capability.ws.path.length > 0;
|
||||
};
|
||||
|
||||
class TerminalInputWsManager {
|
||||
private socket: WebSocket | null = null;
|
||||
private socketUrl = '';
|
||||
private boundSessionId: string | null = null;
|
||||
private openPromise: Promise<WebSocket | null> | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private closed = false;
|
||||
|
||||
configure(socketUrl: string): void {
|
||||
if (!socketUrl) return;
|
||||
|
||||
if (this.socketUrl === socketUrl) {
|
||||
this.closed = false;
|
||||
if (this.isConnectedOrConnecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureConnected();
|
||||
return;
|
||||
}
|
||||
|
||||
this.socketUrl = socketUrl;
|
||||
this.closed = false;
|
||||
this.resetConnection();
|
||||
this.ensureConnected();
|
||||
}
|
||||
|
||||
async sendInput(sessionId: string, data: string): Promise<boolean> {
|
||||
if (!sessionId || !data || this.closed || !this.socketUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const socket = await this.getOpenSocket(WS_SEND_WAIT_MS);
|
||||
if (!socket || socket.readyState !== WS_READY_STATE_OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.boundSessionId !== sessionId) {
|
||||
socket.send(encodeControlFrame({ t: 'b', s: sessionId, v: 1 }));
|
||||
this.boundSessionId = sessionId;
|
||||
}
|
||||
socket.send(data);
|
||||
return true;
|
||||
} catch {
|
||||
this.handleSocketFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
unbindSession(sessionId: string): void {
|
||||
if (!sessionId) return;
|
||||
if (this.boundSessionId === sessionId) {
|
||||
this.boundSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.clearReconnectTimeout();
|
||||
this.resetConnection();
|
||||
this.socketUrl = '';
|
||||
}
|
||||
|
||||
prime(): void {
|
||||
if (this.closed || !this.socketUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isConnectedOrConnecting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureConnected();
|
||||
}
|
||||
|
||||
isConnectedOrConnecting(socketUrl?: string): boolean {
|
||||
if (this.closed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (socketUrl && this.socketUrl !== socketUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.openPromise !== null;
|
||||
}
|
||||
|
||||
private sendControl(payload: TerminalInputControlMessage): boolean {
|
||||
if (!this.socket || this.socket.readyState !== WS_READY_STATE_OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.send(encodeControlFrame(payload));
|
||||
return true;
|
||||
} catch {
|
||||
this.handleSocketFailure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
this.stopKeepalive();
|
||||
this.keepaliveInterval = setInterval(() => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendControl({ t: 'p', v: 1 });
|
||||
}, WS_KEEPALIVE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopKeepalive(): void {
|
||||
if (!this.keepaliveInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.keepaliveInterval);
|
||||
this.keepaliveInterval = null;
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed || !this.socketUrl || this.reconnectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDelay = Math.min(
|
||||
WS_RECONNECT_INITIAL_DELAY_MS * Math.pow(2, this.reconnectAttempt),
|
||||
WS_RECONNECT_MAX_DELAY_MS
|
||||
);
|
||||
const jitter = Math.floor(Math.random() * WS_RECONNECT_JITTER_MS);
|
||||
const delay = baseDelay + jitter;
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.reconnectAttempt += 1;
|
||||
this.ensureConnected();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearReconnectTimeout(): void {
|
||||
if (!this.reconnectTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
private async getOpenSocket(waitMs: number): Promise<WebSocket | null> {
|
||||
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
this.ensureConnected();
|
||||
|
||||
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
const opened = await Promise.race([
|
||||
this.openPromise ?? Promise.resolve(null),
|
||||
new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), waitMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (opened && opened.readyState === WS_READY_STATE_OPEN) {
|
||||
return opened;
|
||||
}
|
||||
|
||||
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private ensureConnected(): void {
|
||||
if (this.closed || !this.socketUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.openPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearReconnectTimeout();
|
||||
|
||||
this.openPromise = new Promise<WebSocket | null>((resolve) => {
|
||||
let settled = false;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const settle = (value: WebSocket | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (connectTimeout) {
|
||||
clearTimeout(connectTimeout);
|
||||
connectTimeout = null;
|
||||
}
|
||||
this.openPromise = null;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
try {
|
||||
const socket = new WebSocket(this.socketUrl);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
socket.onopen = () => {
|
||||
this.socket = socket;
|
||||
this.reconnectAttempt = 0;
|
||||
this.startKeepalive();
|
||||
settle(socket);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
void this.handleSocketMessage(event.data);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
this.boundSessionId = null;
|
||||
this.stopKeepalive();
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
settle(null);
|
||||
};
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
connectTimeout = setTimeout(() => {
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
settle(null);
|
||||
}
|
||||
}, WS_CONNECT_TIMEOUT_MS);
|
||||
} catch {
|
||||
settle(null);
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSocketMessage(messageData: unknown): Promise<void> {
|
||||
const bytes = await this.asUint8Array(messageData);
|
||||
if (!bytes || bytes.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bytes[0] !== CONTROL_TAG_JSON) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(textDecoder.decode(bytes.subarray(1))) as TerminalInputControlMessage;
|
||||
if (payload.t === 'po') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.t === 'e') {
|
||||
if (payload.c === 'NOT_BOUND' || payload.c === 'SESSION_NOT_FOUND') {
|
||||
this.boundSessionId = null;
|
||||
}
|
||||
if (payload.f === true) {
|
||||
this.handleSocketFailure();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.handleSocketFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private async asUint8Array(messageData: unknown): Promise<Uint8Array | null> {
|
||||
if (messageData instanceof ArrayBuffer) {
|
||||
return new Uint8Array(messageData);
|
||||
}
|
||||
|
||||
if (messageData instanceof Uint8Array) {
|
||||
return messageData;
|
||||
}
|
||||
|
||||
if (typeof Blob !== 'undefined' && messageData instanceof Blob) {
|
||||
const buffer = await messageData.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private handleSocketFailure(): void {
|
||||
this.boundSessionId = null;
|
||||
this.resetConnection();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private resetConnection(): void {
|
||||
this.openPromise = null;
|
||||
this.stopKeepalive();
|
||||
if (this.socket) {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
this.boundSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalInputWsGlobalState = {
|
||||
capability: TerminalInputCapability | null;
|
||||
manager: TerminalInputWsManager | null;
|
||||
};
|
||||
|
||||
const getTerminalInputWsGlobalState = (): TerminalInputWsGlobalState => {
|
||||
const globalScope = globalThis as typeof globalThis & {
|
||||
[GLOBAL_TERMINAL_INPUT_STATE_KEY]?: TerminalInputWsGlobalState;
|
||||
};
|
||||
|
||||
if (!globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY]) {
|
||||
globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY] = {
|
||||
capability: null,
|
||||
manager: null,
|
||||
};
|
||||
}
|
||||
|
||||
return globalScope[GLOBAL_TERMINAL_INPUT_STATE_KEY];
|
||||
};
|
||||
|
||||
const applyTerminalInputCapability = (capability: TerminalInputCapability | undefined): void => {
|
||||
const globalState = getTerminalInputWsGlobalState();
|
||||
globalState.capability = capability ?? null;
|
||||
|
||||
if (!isWsInputSupported(globalState.capability)) {
|
||||
globalState.manager?.close();
|
||||
globalState.manager = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const wsPath = globalState.capability?.ws?.path;
|
||||
if (!wsPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketUrl = normalizeWebSocketPath(wsPath);
|
||||
if (!socketUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!globalState.manager) {
|
||||
globalState.manager = new TerminalInputWsManager();
|
||||
}
|
||||
|
||||
globalState.manager.configure(socketUrl);
|
||||
};
|
||||
|
||||
const sendTerminalInputHttp = async (sessionId: string, data: string): Promise<void> => {
|
||||
const response = await fetch(`/api/terminal/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to send input' }));
|
||||
throw new Error(error.error || 'Failed to send terminal input');
|
||||
}
|
||||
};
|
||||
|
||||
export async function createTerminalSession(
|
||||
options: CreateTerminalOptions
|
||||
): Promise<TerminalSession> {
|
||||
const response = await fetch('/api/terminal/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cwd: options.cwd,
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to create terminal' }));
|
||||
throw new Error(error.error || 'Failed to create terminal session');
|
||||
}
|
||||
|
||||
const session = await response.json() as TerminalSession;
|
||||
applyTerminalInputCapability(session.capabilities?.input);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function connectTerminalStream(
|
||||
sessionId: string,
|
||||
onEvent: (event: TerminalStreamEvent) => void,
|
||||
onError?: (error: Error, fatal?: boolean) => void,
|
||||
options: ConnectStreamOptions = {}
|
||||
): () => void {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialRetryDelay = 1000,
|
||||
maxRetryDelay = 8000,
|
||||
connectionTimeout = 10000,
|
||||
} = options;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let isClosed = false;
|
||||
let hasDispatchedOpen = false;
|
||||
let terminalExited = false;
|
||||
|
||||
const clearTimeouts = () => {
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
if (connectionTimeoutId) {
|
||||
clearTimeout(connectionTimeoutId);
|
||||
connectionTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
isClosed = true;
|
||||
clearTimeouts();
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (isClosed || terminalExited) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
|
||||
console.warn('Attempted to create duplicate EventSource, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
hasDispatchedOpen = false;
|
||||
eventSource = new EventSource(`/api/terminal/${sessionId}/stream`);
|
||||
|
||||
connectionTimeoutId = setTimeout(() => {
|
||||
if (!hasDispatchedOpen && eventSource?.readyState !== EventSource.OPEN) {
|
||||
console.error('Terminal connection timeout');
|
||||
eventSource?.close();
|
||||
handleError(new Error('Connection timeout'), false);
|
||||
}
|
||||
}, connectionTimeout);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
if (hasDispatchedOpen) {
|
||||
return;
|
||||
}
|
||||
hasDispatchedOpen = true;
|
||||
retryCount = 0;
|
||||
clearTimeouts();
|
||||
|
||||
onEvent({ type: 'connected' });
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TerminalStreamEvent;
|
||||
|
||||
if (data.type === 'exit') {
|
||||
getTerminalInputWsGlobalState().manager?.unbindSession(sessionId);
|
||||
terminalExited = true;
|
||||
cleanup();
|
||||
}
|
||||
|
||||
onEvent(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse terminal event:', error);
|
||||
onError?.(error as Error, false);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Terminal stream error:', error, 'readyState:', eventSource?.readyState);
|
||||
clearTimeouts();
|
||||
|
||||
const isFatalError = terminalExited || eventSource?.readyState === EventSource.CLOSED;
|
||||
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
|
||||
if (!terminalExited) {
|
||||
handleError(new Error('Terminal stream connection error'), isFatalError);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleError = (error: Error, isFatal: boolean) => {
|
||||
if (isClosed || terminalExited) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryCount < maxRetries && !isFatal) {
|
||||
retryCount++;
|
||||
const delay = Math.min(initialRetryDelay * Math.pow(2, retryCount - 1), maxRetryDelay);
|
||||
|
||||
console.log(`Reconnecting to terminal stream (attempt ${retryCount}/${maxRetries}) in ${delay}ms`);
|
||||
|
||||
onEvent({
|
||||
type: 'reconnecting',
|
||||
attempt: retryCount,
|
||||
maxAttempts: maxRetries,
|
||||
});
|
||||
|
||||
retryTimeout = setTimeout(() => {
|
||||
if (!isClosed && !terminalExited) {
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
|
||||
console.error(`Terminal connection failed after ${retryCount} attempts`);
|
||||
onError?.(error, true);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export async function sendTerminalInput(
|
||||
sessionId: string,
|
||||
data: string
|
||||
): Promise<void> {
|
||||
const globalState = getTerminalInputWsGlobalState();
|
||||
if (globalState.manager && await globalState.manager.sendInput(sessionId, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendTerminalInputHttp(sessionId, data);
|
||||
}
|
||||
|
||||
export async function resizeTerminal(
|
||||
sessionId: string,
|
||||
cols: number,
|
||||
rows: number
|
||||
): Promise<void> {
|
||||
const response = await fetch(`/api/terminal/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to resize terminal' }));
|
||||
throw new Error(error.error || 'Failed to resize terminal');
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTerminal(sessionId: string): Promise<void> {
|
||||
getTerminalInputWsGlobalState().manager?.unbindSession(sessionId);
|
||||
|
||||
const response = await fetch(`/api/terminal/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to close terminal' }));
|
||||
throw new Error(error.error || 'Failed to close terminal');
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartTerminalSession(
|
||||
currentSessionId: string,
|
||||
options: { cwd: string; cols?: number; rows?: number }
|
||||
): Promise<TerminalSession> {
|
||||
getTerminalInputWsGlobalState().manager?.unbindSession(currentSessionId);
|
||||
|
||||
const response = await fetch(`/api/terminal/${currentSessionId}/restart`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cwd: options.cwd,
|
||||
cols: options.cols ?? 80,
|
||||
rows: options.rows ?? 24,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to restart terminal' }));
|
||||
throw new Error(error.error || 'Failed to restart terminal');
|
||||
}
|
||||
|
||||
const session = await response.json() as TerminalSession;
|
||||
applyTerminalInputCapability(session.capabilities?.input);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function forceKillTerminal(options: {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
}): Promise<void> {
|
||||
const response = await fetch('/api/terminal/force-kill', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Failed to force kill terminal' }));
|
||||
throw new Error(error.error || 'Failed to force kill terminal');
|
||||
}
|
||||
|
||||
if (options.sessionId) {
|
||||
getTerminalInputWsGlobalState().manager?.unbindSession(options.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeTerminalInputTransport(): void {
|
||||
const globalState = getTerminalInputWsGlobalState();
|
||||
globalState.manager?.close();
|
||||
globalState.manager = null;
|
||||
globalState.capability = null;
|
||||
}
|
||||
|
||||
export function primeTerminalInputTransport(): void {
|
||||
const globalState = getTerminalInputWsGlobalState();
|
||||
if (globalState.capability && !isWsInputSupported(globalState.capability)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsPath = globalState.capability?.ws?.path ?? DEFAULT_TERMINAL_INPUT_WS_PATH;
|
||||
const socketUrl = normalizeWebSocketPath(wsPath);
|
||||
if (!socketUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!globalState.manager) {
|
||||
globalState.manager = new TerminalInputWsManager();
|
||||
}
|
||||
|
||||
if (globalState.manager.isConnectedOrConnecting(socketUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalState.manager.configure(socketUrl);
|
||||
globalState.manager.prime();
|
||||
}
|
||||
137
src/lib/terminalTheme.ts
Normal file
137
src/lib/terminalTheme.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Ghostty } from 'ghostty-web';
|
||||
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
selectionInactiveBackground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
// Default dark theme
|
||||
const defaultTheme: TerminalTheme = {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selectionBackground: '#264f78',
|
||||
selectionForeground: '#ffffff',
|
||||
selectionInactiveBackground: '#264f7850',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
export function getDefaultTheme(): TerminalTheme {
|
||||
return { ...defaultTheme };
|
||||
}
|
||||
|
||||
export function getTerminalOptions(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
theme: TerminalTheme
|
||||
) {
|
||||
const powerlineFallbacks =
|
||||
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||
|
||||
return {
|
||||
fontFamily: augmentedFontFamily,
|
||||
fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar' as const,
|
||||
theme,
|
||||
allowTransparency: false,
|
||||
scrollback: 10_000,
|
||||
minimumContrastRatio: 1,
|
||||
fastScrollModifier: 'shift' as const,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 3,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terminal options for Ghostty Web terminal
|
||||
*/
|
||||
export function getGhosttyTerminalOptions(
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
theme: TerminalTheme,
|
||||
ghostty: Ghostty,
|
||||
disableStdin = false
|
||||
) {
|
||||
const powerlineFallbacks =
|
||||
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
|
||||
|
||||
return {
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'bar' as const,
|
||||
fontSize,
|
||||
lineHeight: 1.15,
|
||||
fontFamily: augmentedFontFamily,
|
||||
allowTransparency: false,
|
||||
theme: {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
cursor: theme.cursor,
|
||||
cursorAccent: theme.cursorAccent,
|
||||
selectionBackground: theme.selectionBackground,
|
||||
selectionForeground: theme.selectionForeground,
|
||||
black: theme.black,
|
||||
red: theme.red,
|
||||
green: theme.green,
|
||||
yellow: theme.yellow,
|
||||
blue: theme.blue,
|
||||
magenta: theme.magenta,
|
||||
cyan: theme.cyan,
|
||||
white: theme.white,
|
||||
brightBlack: theme.brightBlack,
|
||||
brightRed: theme.brightRed,
|
||||
brightGreen: theme.brightGreen,
|
||||
brightYellow: theme.brightYellow,
|
||||
brightBlue: theme.brightBlue,
|
||||
brightMagenta: theme.brightMagenta,
|
||||
brightCyan: theme.brightCyan,
|
||||
brightWhite: theme.brightWhite,
|
||||
},
|
||||
scrollback: 10_000,
|
||||
ghostty,
|
||||
disableStdin,
|
||||
};
|
||||
}
|
||||
59
src/lib/utils.ts
Normal file
59
src/lib/utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fallback to older method
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for older browsers or when clipboard API fails
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSafeSessionStorage(): Storage | null {
|
||||
try {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const testKey = '__session_storage_test__';
|
||||
sessionStorage.setItem(testKey, testKey);
|
||||
sessionStorage.removeItem(testKey);
|
||||
return sessionStorage;
|
||||
}
|
||||
} catch {
|
||||
// Session storage not available
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isMobileDeviceViaCSS(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Alternative check using media queries
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia('(pointer: coarse)').matches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
547
src/stores/useTerminalStore.ts
Normal file
547
src/stores/useTerminalStore.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
|
||||
import { closeTerminal } from '../lib/terminalApi';
|
||||
|
||||
export interface TerminalChunk {
|
||||
id: number;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type TerminalTab = {
|
||||
id: string;
|
||||
terminalSessionId: string | null;
|
||||
label: string;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
isConnecting: boolean;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type DirectoryTerminalState = {
|
||||
tabs: TerminalTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
interface TerminalStore {
|
||||
sessions: Map<string, DirectoryTerminalState>;
|
||||
nextChunkId: number;
|
||||
nextTabId: number;
|
||||
hasHydrated: boolean;
|
||||
|
||||
ensureDirectory: (directory: string) => void;
|
||||
getDirectoryState: (directory: string) => DirectoryTerminalState | undefined;
|
||||
getActiveTab: (directory: string) => TerminalTab | undefined;
|
||||
|
||||
createTab: (directory: string) => string;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
|
||||
removeDirectory: (directory: string) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
type PersistedTerminalStoreState = {
|
||||
sessions: Array<[string, PersistedDirectoryTerminalState]>;
|
||||
nextTabId: number;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const tabIdNumber = (tabId: string): number | null => {
|
||||
const match = /^tab-(\d+)$/.exec(tabId);
|
||||
if (!match) return null;
|
||||
const num = Number(match[1]);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
function normalizeDirectory(dir: string): string {
|
||||
let normalized = dir.trim();
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
label,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({
|
||||
tabs: [firstTab],
|
||||
activeTabId: firstTab.id,
|
||||
});
|
||||
|
||||
const findTabIndex = (state: DirectoryTerminalState, tabId: string): number =>
|
||||
state.tabs.findIndex((t) => t.id === tabId);
|
||||
|
||||
export const useTerminalStore = create<TerminalStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sessions: new Map(),
|
||||
nextChunkId: 1,
|
||||
nextTabId: 1,
|
||||
hasHydrated: typeof window === 'undefined',
|
||||
|
||||
ensureDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key) return;
|
||||
|
||||
set((state) => {
|
||||
if (state.sessions.has(key)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newSessions = new Map(state.sessions);
|
||||
const tabId = `tab-${state.nextTabId}`;
|
||||
const firstTab = createEmptyTab(tabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(firstTab));
|
||||
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
getDirectoryState: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
return get().sessions.get(key);
|
||||
},
|
||||
|
||||
getActiveTab: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
if (!entry) return undefined;
|
||||
const activeId = entry.activeTabId;
|
||||
if (!activeId) return entry.tabs[0];
|
||||
return entry.tabs.find((t) => t.id === activeId) ?? entry.tabs[0];
|
||||
},
|
||||
|
||||
createTab: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key) {
|
||||
return 'tab-invalid';
|
||||
}
|
||||
|
||||
const tabId = `tab-${get().nextTabId}`;
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
|
||||
const nextTabId = state.nextTabId + 1;
|
||||
const labelIndex = (existing?.tabs.length ?? 0) + 1;
|
||||
const label = `Terminal ${labelIndex}`;
|
||||
const tab = createEmptyTab(tabId, label);
|
||||
|
||||
if (!existing) {
|
||||
newSessions.set(key, createEmptyDirectoryState(tab));
|
||||
} else {
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: [...existing.tabs, tab],
|
||||
});
|
||||
}
|
||||
|
||||
return { sessions: newSessions, nextTabId };
|
||||
});
|
||||
|
||||
return tabId;
|
||||
},
|
||||
|
||||
setActiveTab: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
if (existing.activeTabId === tabId) {
|
||||
return state;
|
||||
}
|
||||
if (findTabIndex(existing, tabId) < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
newSessions.set(key, { ...existing, activeTabId: tabId });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const normalizedLabel = label.trim();
|
||||
if (!normalizedLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (existing.tabs[idx]?.label === normalizedLabel) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
label: normalizedLabel,
|
||||
};
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
});
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: async (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const entry = get().sessions.get(key);
|
||||
const tab = entry?.tabs.find((t) => t.id === tabId);
|
||||
const sessionId = tab?.terminalSessionId ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await closeTerminal(sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
if (nextTabs.length === 0) {
|
||||
const newTabId = `tab-${state.nextTabId}`;
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return { sessions: newSessions, nextTabId: state.nextTabId + 1 };
|
||||
}
|
||||
|
||||
let nextActive = existing.activeTabId;
|
||||
if (existing.activeTabId === tabId) {
|
||||
const fallback = nextTabs[Math.min(idx, nextTabs.length - 1)];
|
||||
nextActive = fallback?.id ?? nextTabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
newSessions.set(key, {
|
||||
...existing,
|
||||
tabs: nextTabs,
|
||||
activeTabId: nextActive,
|
||||
});
|
||||
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextTab: TerminalTab = {
|
||||
...tab,
|
||||
terminalSessionId: sessionId,
|
||||
isConnecting: false,
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
|
||||
};
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = nextTab;
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], isConnecting };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
const chunkId = state.nextChunkId;
|
||||
const chunkEntry: TerminalChunk = { id: chunkId, data: chunk };
|
||||
|
||||
const bufferChunks = [...tab.bufferChunks, chunkEntry];
|
||||
let bufferLength = tab.bufferLength + chunk.length;
|
||||
|
||||
while (bufferLength > TERMINAL_BUFFER_LIMIT && bufferChunks.length > 1) {
|
||||
const removed = bufferChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
bufferLength -= removed.data.length;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...tab,
|
||||
bufferChunks,
|
||||
bufferLength,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
|
||||
return { sessions: newSessions, nextChunkId: chunkId + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
clearBuffer: (directory: string, tabId: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx],
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
};
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
removeDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.delete(key);
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ sessions: new Map(), nextChunkId: 1, nextTabId: 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: TERMINAL_STORE_NAME,
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state): PersistedTerminalStoreState => ({
|
||||
sessions: Array.from(state.sessions.entries()).map(([directory, dirState]) => [
|
||||
directory,
|
||||
{
|
||||
activeTabId: dirState.activeTabId,
|
||||
tabs: dirState.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
createdAt: tab.createdAt,
|
||||
})),
|
||||
},
|
||||
]),
|
||||
nextTabId: state.nextTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
if (!isRecord(persistedState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const rawSessions = Array.isArray(persistedState.sessions)
|
||||
? (persistedState.sessions as PersistedTerminalStoreState['sessions'])
|
||||
: [];
|
||||
|
||||
const sessions = new Map<string, DirectoryTerminalState>();
|
||||
let maxTabNum = 0;
|
||||
|
||||
for (const entry of rawSessions) {
|
||||
if (!Array.isArray(entry) || entry.length !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [directory, rawState] = entry as [unknown, unknown];
|
||||
if (typeof directory !== 'string' || !isRecord(rawState)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : [];
|
||||
const tabs: TerminalTab[] = [];
|
||||
|
||||
for (const rawTab of rawTabs) {
|
||||
if (!isRecord(rawTab)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof rawTab.id === 'string' ? rawTab.id : null;
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const num = tabIdNumber(id);
|
||||
if (num !== null) {
|
||||
maxTabNum = Math.max(maxTabNum, num);
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
terminalSessionId:
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
isConnecting: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeTabId =
|
||||
typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null;
|
||||
const activeExists = activeTabId ? tabs.some((t) => t.id === activeTabId) : false;
|
||||
|
||||
sessions.set(directory, {
|
||||
tabs,
|
||||
activeTabId: activeExists ? activeTabId : tabs[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
const persistedNextTabId =
|
||||
typeof persistedState.nextTabId === 'number' && Number.isFinite(persistedState.nextTabId)
|
||||
? (persistedState.nextTabId as number)
|
||||
: 1;
|
||||
|
||||
const nextTabId = Math.max(currentState.nextTabId, persistedNextTabId, maxTabNum + 1);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
sessions,
|
||||
nextChunkId: 1,
|
||||
nextTabId,
|
||||
hasHydrated: true,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Ensure hydration completes even when no persisted state exists.
|
||||
if (typeof window !== 'undefined') {
|
||||
const persistApi = (
|
||||
useTerminalStore as unknown as {
|
||||
persist?: {
|
||||
hasHydrated?: () => boolean;
|
||||
onFinishHydration?: (cb: () => void) => (() => void) | void;
|
||||
};
|
||||
}
|
||||
).persist;
|
||||
|
||||
const markHydrated = () => {
|
||||
if (!useTerminalStore.getState().hasHydrated) {
|
||||
useTerminalStore.setState({ hasHydrated: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (persistApi?.hasHydrated?.()) {
|
||||
markHydrated();
|
||||
} else if (persistApi?.onFinishHydration) {
|
||||
persistApi.onFinishHydration(markHydrated);
|
||||
} else {
|
||||
markHydrated();
|
||||
}
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
27
vite.config.ts
Normal file
27
vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
global: 'globalThis',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user