feat: terminal
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import useUserSettings from '@/hooks/use-user-settings';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { createStyles } from 'antd-style';
|
||||
import _ from 'lodash';
|
||||
import qs from 'query-string';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
wrap: css`
|
||||
position: relative;
|
||||
text-align: left;
|
||||
.xterm {
|
||||
height: 100%;
|
||||
.xterm-viewport {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
`,
|
||||
terminal: css`
|
||||
height: 100%;
|
||||
padding: 5px;
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-logs-bg);
|
||||
border-radius: 0 0 8px 8px;
|
||||
`
|
||||
}));
|
||||
|
||||
const terminalEnvList = ['bash', 'sh', 'powershell', 'pwsh', 'cmd', 'bash'];
|
||||
|
||||
const RECONNECT_MSG = '--- press Y to reconnect! ---';
|
||||
|
||||
const colorBg2 = 'rgb(33, 36, 39)';
|
||||
|
||||
interface XTerminalProps {
|
||||
height: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const XTerminal: React.FC<XTerminalProps> = forwardRef((props, ref) => {
|
||||
const { height, url } = props;
|
||||
const { styles } = useStyles();
|
||||
const { userSettings } = useUserSettings();
|
||||
const terminalRef = React.useRef<HTMLDivElement>(null);
|
||||
const WrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const terminalInstance = React.useRef<any>(null);
|
||||
const wssURL = React.useRef<string>('');
|
||||
const terminalEnvIndex = React.useRef(0);
|
||||
const terminalSocket = React.useRef<WebSocket | null>(null);
|
||||
const retryCount = React.useRef(5);
|
||||
const totalRetry = React.useRef(5);
|
||||
const first = React.useRef(true);
|
||||
const loading = React.useRef(false);
|
||||
const timer = React.useRef<any>(null);
|
||||
const toRetry = React.useRef(false);
|
||||
const conReadyState = React.useRef<number>(0);
|
||||
const [statusCode, setStatusCode] = React.useState(0);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
|
||||
const setWssUrl = (flag?: boolean) => {
|
||||
if (terminalEnvIndex.current >= terminalEnvList.length - 1) return;
|
||||
if (flag) {
|
||||
terminalEnvIndex.current = 0;
|
||||
} else {
|
||||
terminalEnvIndex.current += 1;
|
||||
}
|
||||
const params = qs.stringify({
|
||||
shell: terminalEnvList[terminalEnvIndex.current]
|
||||
});
|
||||
wssURL.current = `${url}&${params}`;
|
||||
};
|
||||
|
||||
// readyState: 0 1 2 3
|
||||
const isWsOpen = () => {
|
||||
const readyState =
|
||||
terminalSocket.current && terminalSocket.current.readyState;
|
||||
return readyState === 1;
|
||||
};
|
||||
|
||||
const resizeRemoteTerminal = () => {
|
||||
const { cols, rows } = terminalInstance.current || {};
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.current?.send(`#{"width":${cols},"height":${rows}}#`);
|
||||
}
|
||||
};
|
||||
|
||||
const fitTerm = () => {
|
||||
fitAddon.fit();
|
||||
resizeRemoteTerminal();
|
||||
};
|
||||
|
||||
const onResize = _.throttle(() => fitTerm(), 100);
|
||||
|
||||
const removeResizeListener = () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
|
||||
const destoryedTerm = () => {
|
||||
removeResizeListener();
|
||||
terminalSocket.current?.close?.();
|
||||
};
|
||||
|
||||
const setData = (data: string) => {
|
||||
return `${data}\x1B[1;3;31m\x1B[0m`;
|
||||
};
|
||||
|
||||
const setErrorData = (data: string) => {
|
||||
return `\x1b[31m${data}\x1b[m`;
|
||||
};
|
||||
|
||||
const runRealTerminal = () => {
|
||||
terminalInstance.current?.clear?.();
|
||||
loading.current = false;
|
||||
toRetry.current = false;
|
||||
};
|
||||
|
||||
const onWSReceive = (message: MessageEvent) => {
|
||||
if (first.current === true) {
|
||||
first.current = false;
|
||||
resizeRemoteTerminal();
|
||||
}
|
||||
|
||||
const data = { Data: message.data };
|
||||
conReadyState.current = terminalSocket.current?.readyState || 0;
|
||||
|
||||
// const data = JSON.parse(message.data) || '';
|
||||
if (terminalInstance.current?.element) terminalInstance.current.focus();
|
||||
const output = data.Data;
|
||||
terminalInstance.current?.write?.(setData(output));
|
||||
};
|
||||
|
||||
const closeRealTerminal = (data: any) => {
|
||||
setStatusCode(_.get(data, 'code'));
|
||||
conReadyState.current = terminalSocket.current?.readyState || 0;
|
||||
if ([1011, 1006, 1000].includes(statusCode)) {
|
||||
toRetry.current = true;
|
||||
if (first.current) {
|
||||
terminalInstance.current?.reset?.();
|
||||
}
|
||||
if (!loading.current) {
|
||||
terminalInstance.current?.write?.(
|
||||
setData(`(${statusCode})${data.reason}\r\n`)
|
||||
);
|
||||
terminalInstance.current?.write?.(setErrorData(`\r${RECONNECT_MSG}`));
|
||||
}
|
||||
first.current = true;
|
||||
} else if (data.reason) {
|
||||
terminalInstance.current?.write?.(
|
||||
setData(`(${statusCode})${data.reason}\r\n`)
|
||||
);
|
||||
}
|
||||
loading.current = false;
|
||||
};
|
||||
|
||||
const errorRealTerminal = (ex: any) => {
|
||||
let { message } = ex;
|
||||
if (!message) {
|
||||
message = 'disconnected!';
|
||||
toRetry.current = true;
|
||||
first.current = true;
|
||||
loading.current = false;
|
||||
}
|
||||
conReadyState.current = terminalSocket.current!.readyState;
|
||||
terminalInstance.current?.write?.(setErrorData(`\r${message}`));
|
||||
};
|
||||
|
||||
const createWS = () => {
|
||||
if (!props.url) return;
|
||||
terminalInstance.current?.write?.('');
|
||||
setStatusCode(0);
|
||||
terminalSocket.current = new WebSocket(wssURL.current);
|
||||
terminalSocket.current.onopen = runRealTerminal;
|
||||
terminalSocket.current.onmessage = onWSReceive;
|
||||
terminalSocket.current.onclose = closeRealTerminal;
|
||||
terminalSocket.current.onerror = errorRealTerminal;
|
||||
};
|
||||
|
||||
const initWS = () => {
|
||||
if (!terminalSocket.current) {
|
||||
createWS();
|
||||
return;
|
||||
}
|
||||
terminalSocket.current?.close?.();
|
||||
createWS();
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
loading.current = true;
|
||||
terminalInstance.current?.reset?.();
|
||||
initWS();
|
||||
};
|
||||
|
||||
const registerTermHandler = () => {
|
||||
terminalInstance.current?.onData((data: string) => {
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.current?.send(data);
|
||||
}
|
||||
});
|
||||
terminalInstance.current?.onKey((e: any) => {
|
||||
if (toRetry.current && e.domEvent.code === 'KeyY') {
|
||||
retry();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onTerminalResize = () => {
|
||||
window.addEventListener('resize', onResize);
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
terminalInstance.current?.dispose?.();
|
||||
terminalInstance.current = new Terminal({
|
||||
lineHeight: 1.2,
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
"monospace,Menlo,Courier,'Courier New',Consolas,Monaco, 'Liberation Mono'",
|
||||
theme: {
|
||||
background: userSettings.theme === 'realDark' ? colorBg2 : '#181d28',
|
||||
foreground:
|
||||
userSettings.theme === 'realDark'
|
||||
? 'rgba(255, 255, 255, 0.7)'
|
||||
: '#fff'
|
||||
},
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
scrollback: 100,
|
||||
tabStopWidth: 4
|
||||
});
|
||||
terminalInstance.current?.open?.(terminalRef.current!);
|
||||
terminalInstance.current?.loadAddon?.(fitAddon);
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
setWssUrl();
|
||||
initWS();
|
||||
initTerm();
|
||||
registerTermHandler();
|
||||
onTerminalResize();
|
||||
};
|
||||
|
||||
const debounceCall = _.debounce(() => {
|
||||
first.current = true;
|
||||
loading.current = true;
|
||||
setWssUrl(true);
|
||||
initWS();
|
||||
}, 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
terminalInstance.current?.reset?.();
|
||||
terminalSocket.current?.close?.();
|
||||
terminalSocket.current = null;
|
||||
} else {
|
||||
// reset retry count
|
||||
retryCount.current = totalRetry.current;
|
||||
clearTimeout(timer.current);
|
||||
|
||||
terminalInstance.current?.reset?.();
|
||||
debounceCall();
|
||||
}
|
||||
return () => {
|
||||
destoryedTerm();
|
||||
removeResizeListener();
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fit: () => {
|
||||
fitAddon.fit();
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={WrapperRef}
|
||||
className={styles.wrap}
|
||||
style={{
|
||||
height: height
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={styles.terminal}
|
||||
style={{ height: '100%' }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default XTerminal;
|
||||
Reference in New Issue
Block a user