feat: 实现 2x3 终端面板网格布局
- 抽取 TerminalPanel 组件实现多终端支持 - 使用 CSS Grid 实现 2x3 布局 (6个面板) - 添加连接延迟错开同时连接 (0-1000ms) - 后端添加 CORS 支持 - Vite 代理添加 WebSocket 支持 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,17 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
// 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: '*/*' }));
|
||||
|
||||
210
src/App.tsx
210
src/App.tsx
@@ -8,27 +8,26 @@ import {
|
||||
closeTerminal,
|
||||
} from './lib/terminalApi';
|
||||
import { getDefaultTheme, type TerminalTheme } from './lib/terminalTheme';
|
||||
import { useTerminalStore, type TerminalChunk } from './stores/useTerminalStore';
|
||||
import { 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[]>([]);
|
||||
function TerminalPanel({ initialCwd, connectDelay = 0 }: { initialCwd: string; connectDelay?: number }) {
|
||||
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 [chunks, setChunks] = useState<TerminalChunk[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = 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;
|
||||
@@ -52,122 +51,112 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectToTerminal = useCallback(async (targetCwd: string) => {
|
||||
// Cleanup previous session
|
||||
if (cleanupRef.current) {
|
||||
cleanupRef.current();
|
||||
cleanupRef.current = null;
|
||||
}
|
||||
useEffect(() => {
|
||||
// Skip if already connected (e.g., React strict mode double-mount)
|
||||
if (hasConnectedRef.current) return;
|
||||
|
||||
if (sessionIdRef.current) {
|
||||
try {
|
||||
await closeTerminal(sessionIdRef.current);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
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;
|
||||
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
setChunks([]);
|
||||
nextChunkIdRef.current = 1;
|
||||
try {
|
||||
const session = await createTerminalSession({
|
||||
cwd: targetCwd,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await createTerminalSession({
|
||||
cwd: targetCwd,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
sessionIdRef.current = session.sessionId;
|
||||
|
||||
sessionIdRef.current = session.sessionId;
|
||||
cleanupRef.current = connectTerminalStream(
|
||||
session.sessionId,
|
||||
(event) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
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) => {
|
||||
if (event.type === 'connected') {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
} else if (event.type === 'data' && event.data) {
|
||||
const newChunk: TerminalChunk = {
|
||||
id: nextChunkIdRef.current++,
|
||||
data: event.data!,
|
||||
data: event.data,
|
||||
};
|
||||
return [...prev, newChunk];
|
||||
});
|
||||
} else if (event.type === 'exit') {
|
||||
setIsConnected(false);
|
||||
if (event.exitCode !== 0) {
|
||||
setError(`Terminal exited with code ${event.exitCode}`);
|
||||
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);
|
||||
}
|
||||
} else if (event.type === 'reconnecting') {
|
||||
setIsConnecting(true);
|
||||
},
|
||||
(err, fatal) => {
|
||||
console.error('Terminal error:', err);
|
||||
setIsConnected(false);
|
||||
if (fatal) {
|
||||
setError(err.message);
|
||||
}
|
||||
setIsConnecting(false);
|
||||
}
|
||||
},
|
||||
(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
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) return;
|
||||
setIsConnecting(false);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create terminal');
|
||||
}
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
setIsConnected(false);
|
||||
setChunks([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
connectToTerminal(DEFAULT_CWD);
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
}, [connectToTerminal]);
|
||||
}, [initialCwd, connectDelay]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-[#1e1e1e]">
|
||||
<div className="h-full w-full flex flex-col bg-[#1e1e1e] border border-[#3c3c3c]">
|
||||
{/* Panel header */}
|
||||
<div className="px-2 py-1 bg-[#2d2d2d] text-white text-xs flex items-center justify-between border-b border-[#3c3c3c]">
|
||||
<span className="truncate">{initialCwd}</span>
|
||||
<span className={isConnected ? 'text-[#4caf50]' : 'text-[#808080]'}>
|
||||
{isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-[#3c3c3c] text-[#f14c4c] text-sm">
|
||||
<div className="px-2 py-1 bg-[#3c3c3c] text-[#f14c4c] text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +165,7 @@ function App() {
|
||||
<div className="flex-1 min-h-0">
|
||||
{isConnected ? (
|
||||
<TerminalViewport
|
||||
sessionKey={`${cwd}-${sessionIdRef.current}`}
|
||||
sessionKey={`${initialCwd}-${sessionIdRef.current}`}
|
||||
chunks={chunks}
|
||||
onInput={handleInput}
|
||||
onResize={handleResize}
|
||||
@@ -186,17 +175,24 @@ function App() {
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-[#808080]">
|
||||
{isConnecting ? 'Connecting to terminal...' : 'Click "Connect" to start a terminal session'}
|
||||
<div className="h-full flex items-center justify-center text-[#808080] text-sm">
|
||||
{isConnecting ? 'Connecting...' : 'Click to connect'}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={200} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={400} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={600} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={800} />
|
||||
<TerminalPanel initialCwd={DEFAULT_CWD} connectDelay={1000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:3002',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user