summaryrefslogtreecommitdiffstats
path: root/html/src/components/terminal/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'html/src/components/terminal/index.tsx')
-rw-r--r--html/src/components/terminal/index.tsx432
1 files changed, 28 insertions, 404 deletions
diff --git a/html/src/components/terminal/index.tsx b/html/src/components/terminal/index.tsx
index af1ab36..a7349fd 100644
--- a/html/src/components/terminal/index.tsx
+++ b/html/src/components/terminal/index.tsx
@@ -1,435 +1,59 @@
import { bind } from 'decko';
import { Component, h } from 'preact';
-import { ITerminalOptions, Terminal } from 'xterm';
-import { CanvasAddon } from 'xterm-addon-canvas';
-import { WebglAddon } from 'xterm-addon-webgl';
-import { FitAddon } from 'xterm-addon-fit';
-import { WebLinksAddon } from 'xterm-addon-web-links';
-import { ImageAddon } from 'xterm-addon-image';
-import { OverlayAddon } from './overlay';
-import { ZmodemAddon, FlowControl } from '../zmodem';
+import { Xterm, XtermOptions } from './xterm';
import 'xterm/css/xterm.css';
-import worker from 'xterm-addon-image/lib/xterm-addon-image-worker';
+import { Modal } from '../modal';
-const imageWorkerUrl = window.URL.createObjectURL(new Blob([worker], { type: 'text/javascript' }));
-
-interface TtydTerminal extends Terminal {
- fit(): void;
-}
-
-declare global {
- interface Window {
- term: TtydTerminal;
- }
-}
-
-const enum Command {
- // server side
- OUTPUT = '0',
- SET_WINDOW_TITLE = '1',
- SET_PREFERENCES = '2',
-
- // client side
- INPUT = '0',
- RESIZE_TERMINAL = '1',
- PAUSE = '2',
- RESUME = '3',
-}
-
-export type RendererType = 'dom' | 'canvas' | 'webgl';
-
-export interface ClientOptions {
- rendererType: RendererType;
- disableLeaveAlert: boolean;
- disableResizeOverlay: boolean;
- titleFixed: string;
+interface Props extends XtermOptions {
+ id: string;
}
-interface Props {
- id: string;
- wsUrl: string;
- tokenUrl: string;
- clientOptions: ClientOptions;
- termOptions: ITerminalOptions;
+interface State {
+ modal: boolean;
}
-export class Xterm extends Component<Props> {
- private textEncoder: TextEncoder;
- private textDecoder: TextDecoder;
+export class Terminal extends Component<Props, State> {
private container: HTMLElement;
- private terminal: Terminal;
-
- private fitAddon: FitAddon;
- private overlayAddon: OverlayAddon;
- private zmodemAddon: ZmodemAddon;
- private webglAddon: WebglAddon;
- private canvasAddon: CanvasAddon;
-
- private socket: WebSocket;
- private token: string;
- private opened = false;
- private title: string;
- private titleFixed: string;
- private resizeTimeout: number;
- private resizeOverlay = true;
- private reconnect = true;
- private doReconnect = true;
+ private xterm: Xterm;
constructor(props: Props) {
- super(props);
-
- this.textEncoder = new TextEncoder();
- this.textDecoder = new TextDecoder();
- this.fitAddon = new FitAddon();
- this.overlayAddon = new OverlayAddon();
+ super();
+ this.xterm = new Xterm(props, this.showModal);
}
async componentDidMount() {
- await this.refreshToken();
- this.openTerminal();
- this.connect();
-
- window.addEventListener('resize', this.onWindowResize);
- window.addEventListener('beforeunload', this.onWindowUnload);
+ await this.xterm.refreshToken();
+ this.xterm.open(this.container);
+ this.xterm.connect();
}
componentWillUnmount() {
- this.socket.close();
- this.terminal.dispose();
-
- window.removeEventListener('resize', this.onWindowResize);
- window.removeEventListener('beforeunload', this.onWindowUnload);
+ this.xterm.dispose();
}
- render({ id }: Props) {
- const control = {
- limit: 100000,
- highWater: 10,
- lowWater: 4,
- pause: () => this.pause(),
- resume: () => this.resume(),
- } as FlowControl;
-
+ render({ id }: Props, { modal }: State) {
return (
- <div id={id} ref={c => (this.container = c)}>
- <ZmodemAddon ref={c => (this.zmodemAddon = c)} sender={this.sendData} control={control} />
+ <div id={id} ref={c => (this.container = c as HTMLElement)}>
+ <Modal show={modal}>
+ <label class="file-label">
+ <input onChange={this.sendFile} class="file-input" type="file" multiple />
+ <span class="file-cta">Choose files…</span>
+ </label>
+ </Modal>
</div>
);
}
@bind
- private pause() {
- const { textEncoder, socket } = this;
- socket.send(textEncoder.encode(Command.PAUSE));
- }
-
- @bind
- private resume() {
- const { textEncoder, socket } = this;
- socket.send(textEncoder.encode(Command.RESUME));
- }
-
- @bind
- private sendData(data: ArrayLike<number>) {
- const { socket } = this;
- const payload = new Uint8Array(data.length + 1);
- payload[0] = Command.INPUT.charCodeAt(0);
- payload.set(data, 1);
- socket.send(payload);
- }
-
- @bind
- private async refreshToken() {
- try {
- const resp = await fetch(this.props.tokenUrl);
- if (resp.ok) {
- const json = await resp.json();
- this.token = json.token;
- }
- } catch (e) {
- console.error(`[ttyd] fetch ${this.props.tokenUrl}: `, e);
- }
- }
-
- @bind
- private onWindowResize() {
- const { fitAddon } = this;
- clearTimeout(this.resizeTimeout);
- this.resizeTimeout = setTimeout(() => fitAddon.fit(), 250) as any;
- }
-
- @bind
- private onWindowUnload(event: BeforeUnloadEvent): any {
- const { socket } = this;
- if (socket && socket.readyState === WebSocket.OPEN) {
- const message = 'Close terminal? this will also terminate the command.';
- event.returnValue = message;
- return message;
- }
- event.preventDefault();
- }
-
- @bind
- private openTerminal() {
- this.terminal = new Terminal(this.props.termOptions);
- const { terminal, container, fitAddon, overlayAddon } = this;
- window.term = terminal as TtydTerminal;
- window.term.fit = () => {
- this.fitAddon.fit();
- };
-
- terminal.loadAddon(fitAddon);
- terminal.loadAddon(overlayAddon);
- terminal.loadAddon(new WebLinksAddon());
- terminal.loadAddon(this.zmodemAddon);
- terminal.loadAddon(new ImageAddon(imageWorkerUrl));
-
- terminal.onTitleChange(data => {
- if (data && data !== '' && !this.titleFixed) {
- document.title = data + ' | ' + this.title;
- }
- });
- terminal.onData(this.onTerminalData);
- terminal.onResize(this.onTerminalResize);
- if (document.queryCommandSupported && document.queryCommandSupported('copy')) {
- terminal.onSelectionChange(() => {
- if (terminal.getSelection() === '') return;
- overlayAddon.showOverlay('\u2702', 200);
- document.execCommand('copy');
- });
- }
- terminal.open(container);
- fitAddon.fit();
- }
-
- @bind
- private connect() {
- this.socket = new WebSocket(this.props.wsUrl, ['tty']);
- const { socket } = this;
-
- socket.binaryType = 'arraybuffer';
- socket.onopen = this.onSocketOpen;
- socket.onmessage = this.onSocketData;
- socket.onclose = this.onSocketClose;
- socket.onerror = this.onSocketError;
- }
-
- @bind
- private setRendererType(value: RendererType) {
- const { terminal } = this;
- const disposeCanvasRenderer = () => {
- try {
- this.canvasAddon?.dispose();
- } catch {
- // ignore
- }
- this.canvasAddon = undefined;
- };
- const disposeWebglRenderer = () => {
- try {
- this.webglAddon?.dispose();
- } catch {
- // ignore
- }
- this.webglAddon = undefined;
- };
- const enableCanvasRenderer = () => {
- if (this.canvasAddon) return;
- this.canvasAddon = new CanvasAddon();
- disposeWebglRenderer();
- try {
- this.terminal.loadAddon(this.canvasAddon);
- console.log(`[ttyd] canvas renderer loaded`);
- } catch (e) {
- console.log(`[ttyd] canvas renderer could not be loaded, falling back to dom renderer`, e);
- disposeCanvasRenderer();
- }
- };
- const enableWebglRenderer = () => {
- if (this.webglAddon) return;
- this.webglAddon = new WebglAddon();
- disposeCanvasRenderer();
- try {
- this.webglAddon.onContextLoss(() => {
- this.webglAddon?.dispose();
- });
- terminal.loadAddon(this.webglAddon);
- console.log(`[ttyd] WebGL renderer loaded`);
- } catch (e) {
- console.log(`[ttyd] WebGL renderer could not be loaded, falling back to canvas renderer`, e);
- disposeWebglRenderer();
- enableCanvasRenderer();
- }
- };
-
- switch (value) {
- case 'canvas':
- enableCanvasRenderer();
- break;
- case 'webgl':
- enableWebglRenderer();
- break;
- case 'dom':
- default:
- break;
- }
- }
-
- @bind
- private applyOptions(options: ITerminalOptions) {
- const { terminal, fitAddon } = this;
-
- Object.keys(options).forEach(key => {
- const value = options[key];
- switch (key) {
- case 'rendererType':
- this.setRendererType(value);
- break;
- case 'disableLeaveAlert':
- if (value) {
- window.removeEventListener('beforeunload', this.onWindowUnload);
- console.log('[ttyd] Leave site alert disabled');
- }
- break;
- case 'disableResizeOverlay':
- if (value) {
- console.log(`[ttyd] Resize overlay disabled`);
- this.resizeOverlay = false;
- }
- break;
- case 'disableReconnect':
- if (value) {
- console.log(`[ttyd] Reconnect disabled`);
- this.reconnect = false;
- this.doReconnect = false;
- }
- break;
- case 'titleFixed':
- if (!value || value === '') return;
- console.log(`[ttyd] setting fixed title: ${value}`);
- this.titleFixed = value;
- document.title = value;
- break;
- default:
- console.log(`[ttyd] option: ${key}=${JSON.stringify(value)}`);
- if (terminal.options[key] instanceof Object) {
- terminal.options[key] = Object.assign({}, terminal.options[key], value);
- } else {
- terminal.options[key] = value;
- }
- if (key.indexOf('font') === 0) fitAddon.fit();
- break;
- }
- });
- }
-
- @bind
- private onSocketOpen() {
- console.log('[ttyd] websocket connection opened');
-
- const { socket, textEncoder, terminal, fitAddon, overlayAddon } = this;
- const dims = fitAddon.proposeDimensions();
- socket.send(
- textEncoder.encode(
- JSON.stringify({
- AuthToken: this.token,
- columns: dims.cols,
- rows: dims.rows,
- })
- )
- );
-
- if (this.opened) {
- terminal.reset();
- terminal.resize(dims.cols, dims.rows);
- overlayAddon.showOverlay('Reconnected', 300);
- } else {
- this.opened = true;
- }
-
- this.doReconnect = this.reconnect;
-
- terminal.focus();
- }
-
- @bind
- private onSocketClose(event: CloseEvent) {
- console.log(`[ttyd] websocket connection closed with code: ${event.code}`);
-
- const { refreshToken, connect, doReconnect, overlayAddon } = this;
- overlayAddon.showOverlay('Connection Closed', null);
-
- // 1000: CLOSE_NORMAL
- if (event.code !== 1000 && doReconnect) {
- overlayAddon.showOverlay('Reconnecting...', null);
- refreshToken().then(connect);
- } else {
- const { terminal } = this;
- const keyDispose = terminal.onKey(e => {
- const event = e.domEvent;
- if (event.key === 'Enter') {
- keyDispose.dispose();
- overlayAddon.showOverlay('Reconnecting...', null);
- refreshToken().then(connect);
- }
- });
- overlayAddon.showOverlay('Press ⏎ to Reconnect', null);
- }
- }
-
- @bind
- private onSocketError(event: Event) {
- console.error('[ttyd] websocket connection error: ', event);
- this.doReconnect = false;
- }
-
- @bind
- private onSocketData(event: MessageEvent) {
- const { textDecoder, zmodemAddon } = this;
- const rawData = event.data as ArrayBuffer;
- const cmd = String.fromCharCode(new Uint8Array(rawData)[0]);
- const data = rawData.slice(1);
-
- switch (cmd) {
- case Command.OUTPUT:
- zmodemAddon.consume(data);
- break;
- case Command.SET_WINDOW_TITLE:
- this.title = textDecoder.decode(data);
- document.title = this.title;
- break;
- case Command.SET_PREFERENCES:
- const prefs = JSON.parse(textDecoder.decode(data));
- const options = Object.assign({}, this.props.clientOptions, prefs) as ITerminalOptions;
- this.applyOptions(options);
- break;
- default:
- console.warn(`[ttyd] unknown command: ${cmd}`);
- break;
- }
- }
-
- @bind
- private onTerminalResize(size: { cols: number; rows: number }) {
- const { overlayAddon, socket, textEncoder, resizeOverlay } = this;
- if (!socket || socket.readyState !== WebSocket.OPEN) return;
-
- const msg = JSON.stringify({ columns: size.cols, rows: size.rows });
- socket.send(textEncoder.encode(Command.RESIZE_TERMINAL + msg));
-
- if (resizeOverlay) {
- setTimeout(() => {
- overlayAddon.showOverlay(`${size.cols}x${size.rows}`);
- }, 500);
- }
+ showModal() {
+ this.setState({ modal: true });
}
@bind
- private onTerminalData(data: string) {
- const { socket, textEncoder } = this;
- if (socket.readyState === WebSocket.OPEN) {
- socket.send(textEncoder.encode(Command.INPUT + data));
- }
+ sendFile(event: Event) {
+ this.setState({ modal: false });
+ const files = (event.target as HTMLInputElement).files;
+ if (files) this.xterm.sendFile(files);
}
}