chore: 项目分割线
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -1 +0,0 @@
|
||||
3002
|
||||
72
README.md
72
README.md
@@ -1,72 +0,0 @@
|
||||
# XCTerminal
|
||||
|
||||
基于 Electron 的无头终端应用,通过浏览器访问,支持 3x2 网格布局。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 网格布局终端面板(3 列 x 2 行)
|
||||
- 实时终端输出,支持 PowerShell
|
||||
- 支持 WebSocket 和 HTTP 输入
|
||||
- 自动选择空闲端口
|
||||
- 跨平台(Windows、macOS、Linux)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
npm run electron:build
|
||||
```
|
||||
|
||||
构建产物位于 `release/XCTerminal.exe`。
|
||||
|
||||
### 使用
|
||||
|
||||
```bash
|
||||
# 使用默认端口(3002)
|
||||
./release/XCTerminal.exe
|
||||
|
||||
# 指定端口
|
||||
./release/XCTerminal.exe --port=8080
|
||||
|
||||
# 或使用环境变量
|
||||
PORT=8080 ./release/XCTerminal.exe
|
||||
```
|
||||
|
||||
启动后访问 `http://localhost:3002`(或你指定的端口)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
XCTerminal/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.js # 入口文件
|
||||
│ └── preload.js # 预加载脚本
|
||||
├── src/ # React 前端
|
||||
│ ├── App.tsx # 主组件
|
||||
│ └── components/ # UI 组件
|
||||
├── server/ # 独立服务器(可选)
|
||||
│ └── index.js # 服务器入口
|
||||
└── release/ # 构建产物
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron(无头模式)
|
||||
- React 19
|
||||
- Vite
|
||||
- Ghostty Web(终端模拟)
|
||||
- node-pty(PTY 管理)
|
||||
- Express(HTTP 服务器)
|
||||
- WebSocket(实时 I/O)
|
||||
|
||||
## 许可证
|
||||
|
||||
私有项目 - 保留所有权利。
|
||||
@@ -1,59 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT_FILE = '.terminal-server-port';
|
||||
|
||||
async function waitForPortFile(timeout = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (fs.existsSync(PORT_FILE)) {
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Clean up any existing port file
|
||||
if (fs.existsSync(PORT_FILE)) {
|
||||
fs.unlinkSync(PORT_FILE);
|
||||
}
|
||||
|
||||
// Start server
|
||||
console.log('Starting terminal server...');
|
||||
const server = spawn('node', ['server/index.js'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForPortFile();
|
||||
const port = fs.readFileSync(PORT_FILE, 'utf-8').trim();
|
||||
console.log(`Terminal server ready on port ${port}`);
|
||||
|
||||
// Set env for vite
|
||||
process.env.TERMINAL_API_URL = `http://localhost:${port}`;
|
||||
|
||||
// Start vite
|
||||
console.log('Starting Vite dev server...');
|
||||
const vite = spawn('npx', ['vite'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: { ...process.env, TERMINAL_API_URL: `http://localhost:${port}` },
|
||||
});
|
||||
|
||||
// Cleanup on exit
|
||||
process.on('exit', () => {
|
||||
server.kill();
|
||||
vite.kill();
|
||||
if (fs.existsSync(PORT_FILE)) {
|
||||
fs.unlinkSync(PORT_FILE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
408
electron/main.js
408
electron/main.js
@@ -1,408 +0,0 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'node:http';
|
||||
import os from 'os';
|
||||
import pty from 'node-pty';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const PORT_FILE = '.terminal-server-port';
|
||||
const isDev = process.env.NODE_ENV !== 'production' && !app.isPackaged;
|
||||
|
||||
let server;
|
||||
let wss;
|
||||
|
||||
const terminalSessions = new Map();
|
||||
let sessionIdCounter = 1;
|
||||
|
||||
function getShell() {
|
||||
if (process.platform === 'win32') {
|
||||
return 'powershell.exe';
|
||||
}
|
||||
return process.env.SHELL || '/bin/bash';
|
||||
}
|
||||
|
||||
function generateSessionId() {
|
||||
return `term-${Date.now()}-${sessionIdCounter++}`;
|
||||
}
|
||||
|
||||
function createTerminalSession(cwd, cols = 80, rows = 24) {
|
||||
const shell = getShell();
|
||||
let workingDir = cwd || os.homedir();
|
||||
try {
|
||||
const fs = 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;
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort) {
|
||||
const net = await import('net');
|
||||
return new Promise((resolve) => {
|
||||
const testServer = net.createServer();
|
||||
testServer.listen(startPort, () => {
|
||||
const port = testServer.address().port;
|
||||
testServer.close(() => resolve(port));
|
||||
});
|
||||
testServer.on('error', () => {
|
||||
resolve(findAvailablePort(startPort + 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupTerminalAPI(app) {
|
||||
app.post('/api/terminal/create', async (req, res) => {
|
||||
try {
|
||||
const { cwd, cols, rows } = req.body;
|
||||
const sessionId = generateSessionId();
|
||||
const ptyProcess = 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);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
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);
|
||||
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
||||
|
||||
req.on('close', () => {
|
||||
session.pty.removeListener('data', dataHandler);
|
||||
session.pty.removeListener('exit', exitHandler);
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
session.pty.kill();
|
||||
terminalSessions.delete(sessionId);
|
||||
|
||||
const newSessionId = generateSessionId();
|
||||
const ptyProcess = 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(() => {
|
||||
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: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
wss = new WebSocketServer({ server, path: '/api/terminal/input-ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('WebSocket connected for terminal input');
|
||||
|
||||
let boundSessionId = null;
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = new Uint8Array(message);
|
||||
if (data[0] === 0x01) {
|
||||
const jsonStr = new TextDecoder().decode(data.slice(1));
|
||||
const payload = JSON.parse(jsonStr);
|
||||
|
||||
if (payload.t === 'b' && payload.s) {
|
||||
boundSessionId = payload.s;
|
||||
console.log(`WebSocket bound to session: ${boundSessionId}`);
|
||||
}
|
||||
|
||||
if (payload.t === 'p') {
|
||||
ws.send(Buffer.from([0x01, ...new TextEncoder().encode(JSON.stringify({ t: 'po' }))]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPortFromArgs() {
|
||||
const portArg = process.argv.find(arg => arg.startsWith('--port='));
|
||||
if (portArg) {
|
||||
const port = parseInt(portArg.split('=')[1], 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const fs = await import('fs');
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).send();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.text({ type: '*/*' }));
|
||||
|
||||
setupTerminalAPI(app);
|
||||
|
||||
if (isDev) {
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
} else {
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
}
|
||||
|
||||
app.use((req, res) => {
|
||||
if (isDev) {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
} else {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
});
|
||||
|
||||
server = createServer(app);
|
||||
|
||||
setupWebSocket();
|
||||
|
||||
const startPort = getPortFromArgs() || parseInt(process.env.PORT || '3002', 10);
|
||||
const availablePort = await findAvailablePort(startPort);
|
||||
|
||||
server.listen(availablePort, () => {
|
||||
console.log(`Terminal server running on http://localhost:${availablePort}`);
|
||||
fs.writeFileSync(PORT_FILE, String(availablePort));
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.unlinkSync(PORT_FILE);
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
startServer();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
startServer();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
for (const [id, session] of terminalSessions) {
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch {}
|
||||
}
|
||||
terminalSessions.clear();
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
platform: process.platform,
|
||||
versions: {
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome,
|
||||
electron: process.versions.electron,
|
||||
},
|
||||
});
|
||||
13
index.html
13
index.html
@@ -1,13 +0,0 @@
|
||||
<!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>XCTerminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8133
package-lock.json
generated
8133
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
81
package.json
81
package.json
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"name": "xcterminal",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "node dev-runner.js",
|
||||
"dev:server": "node server/index.js",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "npm run build && electron .",
|
||||
"electron:build": "npm run build && electron-builder",
|
||||
"electron:start": "electron ."
|
||||
},
|
||||
"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": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^24.3.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",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^41.0.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.xcterminal",
|
||||
"productName": "XCTerminal",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "XCTerminal.exe"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
},
|
||||
"npmRebuild": false,
|
||||
"nodeGypRebuild": false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
379
server/index.js
379
server/index.js
@@ -1,379 +0,0 @@
|
||||
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 = parseInt(process.env.PORT || '3002', 10);
|
||||
const PORT_FILE = '.terminal-server-port';
|
||||
|
||||
// CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept');
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.status(204).send();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 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.removeListener('data', dataHandler);
|
||||
session.pty.removeListener('exit', 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');
|
||||
});
|
||||
});
|
||||
|
||||
async function findAvailablePort(startPort) {
|
||||
const net = await import('net');
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.listen(startPort, () => {
|
||||
const port = server.address().port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', () => {
|
||||
resolve(findAvailablePort(startPort + 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const fs = await import('fs');
|
||||
const availablePort = await findAvailablePort(PORT);
|
||||
|
||||
server.listen(availablePort, () => {
|
||||
console.log(`Terminal server running on http://localhost:${availablePort}`);
|
||||
fs.writeFileSync(PORT_FILE, String(availablePort));
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
try {
|
||||
fs.unlinkSync(PORT_FILE);
|
||||
} catch {}
|
||||
});
|
||||
})();
|
||||
210
src/App.tsx
210
src/App.tsx
@@ -1,210 +0,0 @@
|
||||
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 { type TerminalChunk } from './stores/useTerminalStore';
|
||||
import { extractPowerShellPromptPath } from './lib/utils';
|
||||
|
||||
const DEFAULT_CWD = '/workspace';
|
||||
const DEFAULT_FONT_SIZE = 14;
|
||||
const DEFAULT_FONT_FAMILY = 'IBM Plex Mono';
|
||||
|
||||
function TerminalPanel({ initialCwd, connectDelay = 0, autoFocus = false }: { initialCwd: string; connectDelay?: number; autoFocus?: boolean }) {
|
||||
const [theme] = useState<TerminalTheme>(getDefaultTheme());
|
||||
const [fontSize] = useState(DEFAULT_FONT_SIZE);
|
||||
const [fontFamily] = useState(DEFAULT_FONT_FAMILY);
|
||||
const [chunks, setChunks] = useState<TerminalChunk[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState(initialCwd);
|
||||
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const nextChunkIdRef = useRef(1);
|
||||
const isMountedRef = useRef(true);
|
||||
const hasConnectedRef = useRef(false);
|
||||
|
||||
const handleInput = useCallback(async (data: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendTerminalInput(sessionId, data);
|
||||
} catch (err) {
|
||||
console.error('[handleInput] Failed to send input:', err);
|
||||
}
|
||||
}, [initialCwd]);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if already connected (e.g., React strict mode double-mount)
|
||||
if (hasConnectedRef.current) return;
|
||||
|
||||
isMountedRef.current = true;
|
||||
|
||||
const connectToTerminal = 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
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
setChunks([]);
|
||||
sessionIdRef.current = null;
|
||||
nextChunkIdRef.current = 1;
|
||||
|
||||
try {
|
||||
const session = await createTerminalSession({
|
||||
cwd: targetCwd,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
sessionIdRef.current = session.sessionId;
|
||||
setSessionId(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) {
|
||||
const osc7Path = extractPowerShellPromptPath(event.data);
|
||||
if (osc7Path) {
|
||||
setCurrentPath(osc7Path);
|
||||
}
|
||||
const newChunk: TerminalChunk = {
|
||||
id: nextChunkIdRef.current++,
|
||||
data: event.data,
|
||||
};
|
||||
setChunks((prev) => [...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');
|
||||
}
|
||||
};
|
||||
|
||||
// Apply connection delay to stagger connections
|
||||
const delay = connectDelay > 0 ? connectDelay : 0;
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasConnectedRef.current = true;
|
||||
connectToTerminal(initialCwd);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
isMountedRef.current = false;
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
}
|
||||
};
|
||||
}, [initialCwd, connectDelay]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-[#1e1e1e] border border-[#3c3c3c]">
|
||||
{/* Panel header */}
|
||||
<div className="px-2 py-1 bg-[#2d2d2d] border-b border-[#3c3c3c] flex items-center justify-between">
|
||||
<span className="truncate text-white text-xs">{currentPath}</span>
|
||||
<span className={isConnected ? 'text-[#4caf50]' : 'text-[#808080]'}>
|
||||
{isConnected ? '●' : isConnecting ? '○' : '○'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="px-2 py-1 bg-[#3c3c3c] text-[#f14c4c] text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal viewport */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isConnected ? (
|
||||
<TerminalViewport
|
||||
sessionKey={`${initialCwd}-${sessionId}`}
|
||||
chunks={chunks}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize}
|
||||
theme={theme}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={fontSize}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-[#808080] text-sm">
|
||||
{isConnecting ? 'Connecting...' : 'Click to connect'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 grid-rows-2 gap-1 h-screen w-screen bg-[#1e1e1e]">
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={0} autoFocus={true} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={500} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1000} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1500} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={2000} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={2500} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,775 +0,0 @@
|
||||
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 = 200;
|
||||
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();
|
||||
const manager = globalState.manager;
|
||||
|
||||
if (manager && manager.isConnectedOrConnecting()) {
|
||||
const sent = await manager.sendInput(sessionId, data);
|
||||
if (sent) {
|
||||
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();
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const PS_PROMPT_REGEX = /\(([^)]+)\)\s*PS\s+([^\n>]+)>|PS\s+([^\n>]+)>/g;
|
||||
|
||||
export function extractPowerShellPromptPath(data: string): string | null {
|
||||
let lastMatch: RegExpMatchArray | null = null;
|
||||
const regex = new RegExp(PS_PROMPT_REGEX);
|
||||
let match;
|
||||
while ((match = regex.exec(data)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
if (!lastMatch) return null;
|
||||
const path = lastMatch[3] || lastMatch[2];
|
||||
return path.trim() || null;
|
||||
}
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -1,547 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PORT_FILE = '.terminal-server-port'
|
||||
|
||||
function getTerminalApiUrl() {
|
||||
if (process.env.TERMINAL_API_URL) {
|
||||
return process.env.TERMINAL_API_URL
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(PORT_FILE)) {
|
||||
const port = fs.readFileSync(PORT_FILE, 'utf-8').trim()
|
||||
return `http://localhost:${port}`
|
||||
}
|
||||
} catch {}
|
||||
return 'http://localhost:3002'
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
global: 'globalThis',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: getTerminalApiUrl(),
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user