chore: 项目分割线
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user