webterm: backport

Doesn't quite compile, untested.
This commit is contained in:
fang 2021-11-25 13:00:52 +01:00
parent 06e5c59635
commit 04f16e047b
No known key found for this signature in database
GPG Key ID: EB035760C1BBA972
15 changed files with 474 additions and 864 deletions

View File

@ -1,50 +0,0 @@
import _ from 'lodash';
export default class Api {
ship: any;
channel: any;
bindPaths: any[];
constructor(ship, channel) {
this.ship = ship;
this.channel = channel;
this.bindPaths = [];
}
bind(path, method, ship = this.ship, appl = 'herm', success, fail) {
this.bindPaths = _.uniq([...this.bindPaths, path]);
(window as any).subscriptionId = this.channel.subscribe(ship, appl, path,
(err) => {
fail(err);
},
(event) => {
success({
data: event,
from: {
ship,
path
}
});
},
(err) => {
fail(err);
});
}
belt(belt) {
return this.action('herm', 'belt', belt);
}
action(appl, mark, data) {
return new Promise((resolve, reject) => {
this.channel.poke(window.ship, appl, mark, data,
(json) => {
resolve(json);
},
(err) => {
reject(err);
});
});
}
}

View File

@ -1,94 +1,444 @@
import React, {
useEffect,
useRef,
useCallback
} from 'react';
import Helmet from 'react-helmet';
import useTermState from './state';
import { useDark } from '../src/logic/state/join';
import { Terminal, ITerminalOptions, ITheme } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { saveAs } from 'file-saver';
import { Box, Col } from '@tlon/indigo-react';
import React, { Component } from 'react';
import dark from '@tlon/indigo-dark';
import light from '@tlon/indigo-light';
import { ThemeProvider } from 'styled-components';
import Api from './api';
import { History } from './components/history';
import { Input } from './components/input';
import './css/custom.css';
import Store from './store';
import Subscription from './subscription';
import Channel from './lib/channel';
class TermApp extends Component<any, any> {
store: Store;
api: any;
subscription: any;
constructor(props) {
super(props);
this.store = new Store();
this.store.setStateHandler(this.setState.bind(this));
import '../../../../node_modules/xterm/css/xterm.css';
import api from '../src/logic/api/index';
import {
Belt, Blit, Stye, Stub, Tint, Deco,
pokeTask, pokeBelt
} from '../../npm/term';
this.state = this.store.state;
}
import bel from './lib/bel';
resetControllers() {
this.api = null;
this.subscription = null;
}
componentDidMount() {
this.resetControllers();
// eslint-disable-next-line new-cap
const channel = new Channel();
this.api = new Api(window.ship, channel);
this.store.api = this.api;
this.subscription = new Subscription(this.store, this.api, channel);
this.subscription.start();
}
componentWillUnmount() {
this.subscription.delete();
this.store.clear();
this.resetControllers();
}
getTheme() {
const { props } = this;
return ((props.dark && props?.display?.theme == 'auto') ||
props?.display?.theme == 'dark'
) ? dark : light;
}
render() {
const theme = this.getTheme();
return (
<ThemeProvider theme={theme}>
<Box
width='100%'
height='100%'
p={['0','3']}
style={{ boxSizing: 'border-box' }}
>
<Col
p={3}
backgroundColor='white'
width='100%'
height='100%'
minHeight={0}
minWidth={0}
color='lightGray'
borderRadius={2}
border={['0','1']}
cursor='text'
style={{ boxSizing: 'border-box' }}
>
{/* @ts-ignore declare props in later pass */}
<History log={this.state.lines.slice(0, -1)} />
<Input
ship={this.props.ship}
cursor={this.state.cursor}
api={this.api}
store={this.store}
line={this.state.lines.slice(-1)[0]}
/>
</Col>
</Box>
</ThemeProvider>
);
}
type TermAppProps = {
ship: string;
notificationsCount: number;
}
export default TermApp;
const makeTheme = (dark: boolean): ITheme => {
let fg, bg: string;
if (dark) {
fg = 'white';
bg = 'black';
} else {
fg = 'black';
bg = 'white';
}
// TODO indigo colors.
// we can't pluck these from ThemeContext because they have transparency.
// technically xterm supports transparency, but it degrades performance.
return {
foreground: fg,
background: bg,
brightBlack: '#7f7f7f', // NOTE slogs
cursor: fg
};
};
const termConfig: ITerminalOptions = {
logLevel: 'warn',
//
convertEol: true,
//
rows: 24,
cols: 80,
scrollback: 10000,
//
fontFamily: '"Source Code Pro","Roboto mono","Courier New",monospace',
// NOTE theme colors configured dynamically
//
bellStyle: 'sound',
bellSound: bel,
//
// allows text selection by holding modifier (option, or shift)
macOptionClickForcesSelection: true
};
const csi = (cmd: string, ...args: number[]) => {
return '\x1b[' + args.join(';') + cmd;
};
const tint = (t: Tint) => {
switch (t) {
case null: return '9';
case 'k': return '0';
case 'r': return '1';
case 'g': return '2';
case 'y': return '3';
case 'b': return '4';
case 'm': return '5';
case 'c': return '6';
case 'w': return '7';
default: return `8;2;${t.r%256};${t.g%256};${t.b%256}`;
}
};
const stye = (s: Stye) => {
let out = '';
// text decorations
//
if (s.deco.length > 0) {
out += s.deco.reduce((decs: number[], deco: Deco) => {
/* eslint-disable max-statements-per-line */
switch (deco) {
case null: decs.push(0); return decs;
case 'br': decs.push(1); return decs;
case 'un': decs.push(4); return decs;
case 'bl': decs.push(5); return decs;
default: console.log('weird deco', deco); return decs;
}
}, []).join(';');
}
// background color
//
if (s.back !== null) {
if (out !== '') {
out += ';';
}
out += '4';
out += tint(s.back);
}
// foreground color
//
if (s.fore !== null) {
if (out !== '') {
out += ';';
}
out += '3';
out += tint(s.fore);
}
if (out === '') {
return out;
}
return '\x1b[' + out + 'm';
};
const showBlit = (term: Terminal, blit: Blit) => {
let out = '';
if ('bel' in blit) {
out += '\x07';
} else if ('clr' in blit) {
term.clear();
out += csi('u');
} else if ('hop' in blit) {
if (typeof blit.hop === 'number') {
out += csi('H', term.rows, blit.hop + 1);
} else {
out += csi('H', term.rows - blit.hop.r, blit.hop.c + 1);
}
out += csi('s'); // save cursor position
} else if ('put' in blit) {
out += blit.put.join('');
out += csi('u');
} else if ('klr' in blit) {
out += blit.klr.reduce((lin: string, p: Stub) => {
lin += stye(p.stye);
lin += p.text.join('');
lin += csi('m', 0);
return lin;
}, '');
out += csi('u');
} else if ('nel' in blit) {
out += '\n';
} else if ('sag' in blit || 'sav' in blit) {
const sav = ('sag' in blit) ? blit.sag : blit.sav;
const name = sav.path.split('/').slice(-2).join('.');
const buff = Buffer.from(sav.file, 'base64');
const blob = new Blob([buff], { type: 'application/octet-stream' });
saveAs(blob, name);
} else if ('url' in blit) {
window.open(blit.url);
} else if ('wyp' in blit) {
out += '\r' + csi('K');
out += csi('u');
} else {
console.log('weird blit', blit);
}
term.write(out);
};
// NOTE should generally only be passed the default terminal session
const showSlog = (term: Terminal, slog: string) => {
// set scroll region to exclude the bottom line,
// scroll up one line,
// move cursor to start of the newly created whitespace,
// set text to grey,
// print the slog,
// restore color, scroll region, and cursor.
//
term.write(csi('r', 1, term.rows - 1)
+ csi('S', 1)
+ csi('H', term.rows - 1, 1)
+ csi('m', 90)
+ slog
+ csi('m', 0)
+ csi('r')
+ csi('u'));
};
const readInput = (term: Terminal, e: string): Belt[] => {
const belts: Belt[] = [];
let strap = '';
while (e.length > 0) {
let c = e.charCodeAt(0);
// text input
//
if (c >= 32 && c !== 127) {
strap += e[0];
e = e.slice(1);
continue;
} else if ('' !== strap) {
belts.push({ txt: strap.split('') });
strap = '';
}
// special keys/characters
//
if (0 === c) {
term.write('\x07'); // bel
} else if (8 === c || 127 === c) {
belts.push({ bac: null });
} else if (13 === c) {
belts.push({ ret: null });
} else if (c <= 26) {
belts.push({ mod: { mod: 'ctl', key: String.fromCharCode(96 + c) } });
}
// escape sequences
//
if (27 === c) { // ESC
e = e.slice(1);
c = e.charCodeAt(0);
if (91 === c || 79 === c) { // [ or O
e = e.slice(1);
c = e.charCodeAt(0);
/* eslint-disable max-statements-per-line */
switch (c) {
case 65: belts.push({ aro: 'u' }); break;
case 66: belts.push({ aro: 'd' }); break;
case 67: belts.push({ aro: 'r' }); break;
case 68: belts.push({ aro: 'l' }); break;
//
case 77: {
const m = e.charCodeAt(1) - 31;
if (1 === m) {
const c = e.charCodeAt(2) - 32;
const r = e.charCodeAt(3) - 32;
belts.push({ hit: { r: term.rows - r, c: c - 1 } });
}
e = e.slice(3);
break;
}
//
default: term.write('\x07'); break; // bel
}
} else if (c >= 97 && c <= 122) { // a <= c <= z
belts.push({ mod: { mod: 'met', key: e[0] } });
} else if (c === 46) { // .
belts.push({ mod: { mod: 'met', key: '.' } });
} else if (c === 8 || c === 127) {
belts.push({ mod: { mod: 'met', key: { bac: null } } });
} else {
term.write('\x07'); break; // bel
}
}
e = e.slice(1);
}
if ('' !== strap) {
belts.push({ txt: strap.split('') });
strap = '';
}
return belts;
};
export default function TermApp(props: TermAppProps) {
const container = useRef<HTMLDivElement>(null);
// TODO allow switching of selected
const { sessions, selected, slogstream, set } = useTermState();
const session = sessions[selected];
const dark = useDark();
const setupSlog = useCallback(() => {
console.log('slog: setting up...');
let available = false;
const slog = new EventSource('/~_~/slog', { withCredentials: true });
slog.onopen = (e) => {
console.log('slog: opened stream');
available = true;
};
slog.onmessage = (e) => {
const session = useTermState.getState().sessions[''];
if (!session) {
console.log('default session mia!', 'slog:', slog);
return;
}
showSlog(session.term, e.data);
};
slog.onerror = (e) => {
console.error('slog: eventsource error:', e);
if (available) {
window.setTimeout(() => {
if (slog.readyState !== EventSource.CLOSED) {
return;
}
console.log('slog: reconnecting...');
setupSlog();
}, 10000);
}
};
set((state) => {
state.slogstream = slog;
});
}, [sessions]);
const onInput = useCallback((ses: string, e: string) => {
const term = useTermState.getState().sessions[ses].term;
const belts = readInput(term, e);
belts.map((b) => { // NOTE passing api.poke(pokeBelt makes `this` undefined!
api.poke(pokeBelt(ses, b));
});
}, [sessions]);
const onResize = useCallback(() => {
// TODO debounce, if it ever becomes a problem
session?.fit.fit();
}, [session]);
// on-init, open slogstream
//
useEffect(() => {
if (!slogstream) {
setupSlog();
}
window.addEventListener('resize', onResize);
return () => {
// TODO clean up subs?
window.removeEventListener('resize', onResize);
};
}, [onResize, setupSlog]);
// on dark mode change, change terminals' theme
//
useEffect(() => {
const theme = makeTheme(dark);
for (const ses in sessions) {
sessions[ses].term.setOption('theme', theme);
}
if (container.current) {
container.current.style.backgroundColor = theme.background || '';
}
}, [dark, sessions]);
// on selected change, maybe setup the term, or put it into the container
//
useEffect(() => {
let ses = session;
// initialize terminal
//
if (!ses) {
// set up terminal
//
const term = new Terminal(termConfig);
term.setOption('theme', makeTheme(dark));
const fit = new FitAddon();
term.loadAddon(fit);
// start mouse reporting
//
term.write(csi('?9h'));
// set up event handlers
//
term.onData(e => onInput(selected, e));
term.onBinary(e => onInput(selected, e));
term.onResize((e) => {
api.poke(pokeTask(selected, { blew: { w: e.cols, h: e.rows } }));
});
ses = { term, fit };
// open subscription
//
api.subscribe({ app: 'herm', path: '/session/'+selected,
event: (e) => {
const ses = useTermState.getState().sessions[selected];
if (!ses) {
console.log('on blit: no such session', selected, sessions, useTermState.getState().sessions);
return;
}
showBlit(ses.term, e);
},
quit: () => { // quit
// TODO show user a message
}
});
}
if (container.current && !container.current.contains(ses.term.element || null)) {
ses.term.open(container.current);
ses.fit.fit();
ses.term.focus();
}
set((state) => {
state.sessions[selected] = ses;
});
return () => {
// TODO unload term from container
// but term.dispose is too powerful? maybe just empty the container?
};
}, [set, session, container]);
return (
<>
<Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape</title>
</Helmet>
<Box
width='100%'
height='100%'
px={['0','3']}
pb={['0','3']}
display='flex'
>
<Col
width='100%'
minHeight='0'
color='washedGray'
borderRadius={['0','2']}
border={['0','1']}
p='1'
ref={container}
>
</Col>
</Box>
</>
);
}

View File

@ -1,35 +0,0 @@
import { Box } from '@tlon/indigo-react';
import React, { Component } from 'react';
import Line from './line';
export class History extends Component {
constructor(props) {
super(props);
}
render() {
return (
<Box
height='100%'
minHeight={0}
minWidth={0}
display='flex'
flexDirection='column-reverse'
overflowY='scroll'
style={{ resize: 'none' }}
>
<Box
mt='auto'
>
{/* @ts-ignore declare props in later pass */}
{this.props.log.map((line, i) => {
// @ts-ignore react memo not passing props
return <Line key={i} line={line} />;
})}
</Box>
</Box>
);
}
}
export default History;

View File

@ -1,128 +0,0 @@
import { BaseInput, Box, Row } from '@tlon/indigo-react';
import React, { Component } from 'react';
export class Input extends Component<any, {}> {
inputRef: React.RefObject<unknown>;
constructor(props) {
super(props);
this.state = {};
this.keyPress = this.keyPress.bind(this);
this.paste = this.paste.bind(this);
this.click = this.click.bind(this);
this.inputRef = React.createRef();
}
componentDidUpdate() {
if (
document.activeElement == this.inputRef.current
) {
// @ts-ignore ref type issues
this.inputRef.current.focus();
// @ts-ignore ref type issues
this.inputRef.current.setSelectionRange(this.props.cursor, this.props.cursor);
}
}
keyPress(e) {
const key = e.key;
// let paste and leap events pass
if ((e.getModifierState('Control') || e.getModifierState('Meta'))
&& (e.key === 'v' || e.key === '/')) {
return;
}
let belt = null;
if (key === 'ArrowLeft')
belt = { aro: 'l' };
else if (key === 'ArrowRight')
belt = { aro: 'r' };
else if (key === 'ArrowUp')
belt = { aro: 'u' };
else if (key === 'ArrowDown')
belt = { aro: 'd' };
else if (key === 'Backspace')
belt = { bac: null };
else if (key === 'Delete')
belt = { del: null };
else if (key === 'Tab')
belt = { ctl: 'i' };
else if (key === 'Enter')
belt = { ret: null };
else if (key.length === 1)
belt = { txt: [key] };
else
belt = null;
if (belt && e.getModifierState('Control')) {
if (belt.txt !== undefined)
belt = { ctl: belt.txt[0] };
} else
if (belt &&
(e.getModifierState('Meta') || e.getModifierState('Alt'))) {
if (belt.bac !== undefined)
belt = { met: 'bac' };
}
if (belt !== null) {
this.props.api.belt(belt);
}
e.preventDefault();
}
paste(e) {
const clipboardData = e.clipboardData || (window as any).clipboardData;
const clipboardText = clipboardData.getData('Text');
this.props.api.belt({ txt: [...clipboardText] });
e.preventDefault();
}
click(e) {
// prevent desynced cursor movement
e.preventDefault();
e.target.setSelectionRange(this.props.cursor, this.props.cursor);
}
render() {
const line = this.props.line;
let prompt = 'connecting...';
if (line) {
if (line.lin) {
prompt = line.lin.join('');
} else if (line.klr) {
// TODO render prompt style
prompt = line.klr.reduce((l, p) => (l + p.text.join('')), '');
}
}
return (
<Row flexGrow={1} position='relative'>
<Box flexShrink={0} width='100%' color='black' fontSize={0}>
<BaseInput
autoFocus
autoCorrect="off"
autoCapitalize="off"
color='lightGray'
minHeight={0}
display='inline-block'
width='100%'
spellCheck="false"
tabindex={0}
wrap="off"
fontFamily="mono"
id="term"
cursor={this.props.cursor}
onKeyDown={this.keyPress}
onClick={this.click}
onPaste={this.paste}
// @ts-ignore indigo-react doesn't let us pass refs
ref={this.inputRef}
defaultValue="connecting..."
value={prompt}
/>
</Box>
</Row>
);
}
}
export default Input;

View File

@ -1,66 +0,0 @@
import { Text } from '@tlon/indigo-react';
import React from 'react';
// @ts-ignore line isn't in props?
export default React.memo(({ line }) => {
// line body to jsx
// NOTE lines are lists of characters that might span multiple codepoints
//
let text = '';
if (line.lin) {
text = line.lin.join('');
} else if (line.klr) {
text = line.klr.map((part, i) => {
const prop = part.stye.deco.reduce((prop, deco) => {
switch (deco) {
case null: return prop;
case 'br': return { bold: true, ...prop };
case 'bl': return { className: 'blink', ...prop };
case 'un': return { style: { textDecoration: 'underline' }, ...prop };
default: console.log('weird deco', deco); return prop;
}
}, {});
switch (part.stye.fore) {
case null: break;
case 'r': prop.color = 'red'; break;
case 'g': prop.color = 'green'; break;
case 'b': prop.color = 'blue'; break;
case 'c': prop.color = 'cyan'; break;
case 'm': prop.color = 'purple'; break;
case 'y': prop.color = 'yellow'; break;
case 'k': prop.color = 'black'; break;
case 'w': prop.color = 'white'; break;
default: prop.color = '#' + part.stye.fore;
}
switch (part.stye.back) {
case null: break;
case 'r': prop.backgroundColor = 'red'; break;
case 'g': prop.backgroundColor = 'green'; break;
case 'b': prop.backgroundColor = 'blue'; break;
case 'c': prop.backgroundColor = 'cyan'; break;
case 'm': prop.backgroundColor = 'purple'; break;
case 'y': prop.backgroundColor = 'yellow'; break;
case 'k': prop.backgroundColor = 'black'; break;
case 'w': prop.backgroundColor = 'white'; break;
default: prop.backgroundColor = '#' + part.stye.back;
}
if (Object.keys(prop).length === 0) {
return part.text;
} else {
return (<Text mono fontSize='inherit' key={i} {...prop}>
{part.text.join('')}
</Text>);
}
});
}
// render line
//
return (
<Text mono display='flex'
fontSize={0}
style={{ overflowWrap: 'break-word', whiteSpace: 'pre-wrap' }}
>
{text}
</Text>
);
});

View File

@ -1,23 +0,0 @@
body, #root {
height: 100vh;
margin: 0;
padding: 0;
}
input#term {
background-color: inherit;
color: inherit;
border: none;
}
.blink {
animation: 4s ease-in-out infinite opacity_blink;
}
@keyframes opacity_blink {
0% { opacity: 0; }
10% { opacity: 1; }
80% { opacity: 1; }
90% { opacity: 0; }
100% { opacity: 0; }
}

View File

@ -8,8 +8,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/~landscape/img/touch_icon.png">
<link rel="icon" type="image/png" href="/~landscape/img/Favicon.png">
<!--<link rel="apple-touch-icon" href="/~landscape/img/touch_icon.png">
<link rel="icon" type="image/png" href="/~landscape/img/Favicon.png">-->
<link rel="manifest"
href='data:application/manifest+json,{
"name": "Terminal",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,290 +0,0 @@
export default class Channel {
constructor() {
this.init();
this.deleteOnUnload();
// a way to handle channel errors
//
//
this.onChannelError = (err) => {
console.error('event source error: ', err);
};
this.onChannelOpen = (e) => {
console.log('open', e);
};
}
init() {
this.debounceInterval = 500;
// unique identifier: current time and random number
//
this.uid =
new Date().getTime().toString() +
"-" +
Math.random().toString(16).slice(-6);
this.requestId = 1;
// the currently connected EventSource
//
this.eventSource = null;
// the id of the last EventSource event we received
//
this.lastEventId = 0;
// this last event id acknowledgment sent to the server
//
this.lastAcknowledgedEventId = 0;
// a registry of requestId to successFunc/failureFunc
//
// These functions are registered during a +poke and are executed
// in the onServerEvent()/onServerError() callbacks. Only one of
// the functions will be called, and the outstanding poke will be
// removed after calling the success or failure function.
//
this.outstandingPokes = new Map();
// a registry of requestId to subscription functions.
//
// These functions are registered during a +subscribe and are
// executed in the onServerEvent()/onServerError() callbacks. The
// event function will be called whenever a new piece of data on this
// subscription is available, which may be 0, 1, or many times. The
// disconnect function may be called exactly once.
//
this.outstandingSubscriptions = new Map();
this.outstandingJSON = [];
this.debounceTimer = null;
}
resetDebounceTimer() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.debounceTimer = setTimeout(() => {
this.sendJSONToChannel();
}, this.debounceInterval)
}
setOnChannelError(onError = (err) => {}) {
this.onChannelError = onError;
}
setOnChannelOpen(onOpen = (e) => {}) {
this.onChannelOpen = onOpen;
}
deleteOnUnload() {
window.addEventListener("beforeunload", (event) => {
this.delete();
});
}
clearQueue() {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
this.sendJSONToChannel();
}
// sends a poke to an app on an urbit ship
//
poke(ship, app, mark, json, successFunc, failureFunc) {
let id = this.nextId();
this.outstandingPokes.set(
id,
{
success: successFunc,
fail: failureFunc
}
);
const j = {
id,
action: "poke",
ship,
app,
mark,
json
};
this.sendJSONToChannel(j);
}
// subscribes to a path on an specific app and ship.
//
// Returns a subscription id, which is the same as the same internal id
// passed to your Urbit.
subscribe(
ship,
app,
path,
connectionErrFunc = () => {},
eventFunc = () => {},
quitFunc = () => {},
subAckFunc = () => {},
) {
let id = this.nextId();
this.outstandingSubscriptions.set(
id,
{
err: connectionErrFunc,
event: eventFunc,
quit: quitFunc,
subAck: subAckFunc
}
);
const json = {
id,
action: "subscribe",
ship,
app,
path
}
this.resetDebounceTimer();
this.outstandingJSON.push(json);
return id;
}
// quit the channel
//
delete() {
let id = this.nextId();
clearInterval(this.ackTimer);
navigator.sendBeacon(this.channelURL(), JSON.stringify([{
id,
action: "delete"
}]));
if (this.eventSource) {
this.eventSource.close();
}
}
// unsubscribe to a specific subscription
//
unsubscribe(subscription) {
let id = this.nextId();
this.sendJSONToChannel({
id,
action: "unsubscribe",
subscription
});
}
// sends a JSON command command to the server.
//
sendJSONToChannel(j) {
let req = new XMLHttpRequest();
req.open("PUT", this.channelURL());
req.setRequestHeader("Content-Type", "application/json");
if (this.lastEventId == this.lastAcknowledgedEventId) {
if (j) {
this.outstandingJSON.push(j);
}
if (this.outstandingJSON.length > 0) {
let x = JSON.stringify(this.outstandingJSON);
req.send(x);
}
} else {
// we add an acknowledgment to clear the server side queue
//
// The server side puts messages it sends us in a queue until we
// acknowledge that we received it.
//
let payload = [
...this.outstandingJSON,
{action: "ack", "event-id": this.lastEventId}
];
if (j) {
payload.push(j)
}
let x = JSON.stringify(payload);
req.send(x);
this.lastAcknowledgedEventId = this.lastEventId;
}
this.outstandingJSON = [];
this.connectIfDisconnected();
}
// connects to the EventSource if we are not currently connected
//
connectIfDisconnected() {
if (this.eventSource) {
return;
}
this.eventSource = new EventSource(this.channelURL(), {withCredentials:true});
this.eventSource.onmessage = e => {
this.lastEventId = parseInt(e.lastEventId, 10);
let obj = JSON.parse(e.data);
let pokeFuncs = this.outstandingPokes.get(obj.id);
let subFuncs = this.outstandingSubscriptions.get(obj.id);
if (obj.response == "poke" && !!pokeFuncs) {
let funcs = pokeFuncs;
if (obj.hasOwnProperty("ok")) {
funcs["success"]();
} else if (obj.hasOwnProperty("err")) {
funcs["fail"](obj.err);
} else {
console.error("Invalid poke response: ", obj);
}
this.outstandingPokes.delete(obj.id);
} else if (obj.response == "subscribe" ||
(obj.response == "poke" && !!subFuncs)) {
let funcs = subFuncs;
if (obj.hasOwnProperty("err")) {
funcs["err"](obj.err);
this.outstandingSubscriptions.delete(obj.id);
} else if (obj.hasOwnProperty("ok")) {
funcs["subAck"](obj);
}
} else if (obj.response == "diff") {
// ensure we ack before channel clogs
if((this.lastEventId - this.lastAcknowledgedEventId) > 30) {
this.clearQueue();
}
let funcs = subFuncs;
funcs["event"](obj.json);
} else if (obj.response == "quit") {
let funcs = subFuncs;
funcs["quit"](obj);
this.outstandingSubscriptions.delete(obj.id);
} else {
console.log("Unrecognized response: ", e);
}
}
this.eventSource.onopen = this.onChannelOpen;
this.eventSource.onerror = e => {
this.delete();
this.init();
this.onChannelError(e);
}
}
channelURL() {
return "/~/channel/" + this.uid;
}
nextId() {
return this.requestId++;
}
}

View File

@ -24772,6 +24772,16 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
"xterm": {
"version": "4.15.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-4.15.0.tgz",
"integrity": "sha512-Ik1GoSq1yqKZQ2LF37RPS01kX9t4TP8gpamUYblD09yvWX5mEYuMK4CcqH6+plgiNEZduhTz/UrcaWs97gOlOw=="
},
"xterm-addon-fit": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz",
"integrity": "sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ=="
},
"y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@ -57,6 +57,8 @@
"workbox-precaching": "^6.0.2",
"workbox-recipes": "^6.0.2",
"workbox-routing": "^6.0.2",
"xterm": "^4.15.0",
"xterm-addon-fit": "^0.5.0",
"yup": "^0.29.3",
"zustand": "^3.5.0"
},

View File

@ -0,0 +1,21 @@
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { createState } from "../src/logic/state/base";
type Session = { term: Terminal, fit: FitAddon };
type Sessions = { [id: string]: Session; }
export interface TermState {
sessions: Sessions,
selected: string,
slogstream: null | EventSource,
};
const useTermState = createState<TermState>('Term', {
sessions: {},
selected: '', // empty string is default session
slogstream: null,
}, ['sessions', 'slogstream']); //TODO consider persisting
export default useTermState;

View File

@ -1,94 +0,0 @@
import { saveAs } from 'file-saver';
import bel from './lib/bel';
export default class Store {
state: any;
api: any;
setState: any;
constructor() {
this.state = this.initialState();
}
initialState() {
return {
lines: [''],
cursor: 0
};
}
clear() {
this.setState(this.initialState());
}
handleEvent(data) {
// process slogs
//
if (data.slog) {
this.state.lines.splice(this.state.lines.length-1, 0, { lin: [data.slog] });
this.setState({ lines: this.state.lines });
return;
}
// process blits
//
const blit = data.data;
switch (Object.keys(blit)[0]) {
case 'bel':
bel.play();
break;
case 'clr':
this.state.lines = this.state.lines.slice(-1);
this.setState({ lines: this.state.lines });
break;
case 'hop':
// since lines are lists of characters that might span multiple
// codepoints, we need to calculate the byte-wise cursor position
// to avoid incorrect cursor rendering.
//
const line = this.state.lines[this.state.lines.length - 1];
let hops;
if (line.lin) {
hops = line.lin.slice(0, blit.hop);
} else if (line.klr) {
hops = line.klr.reduce((h, p) => {
if (h.length >= blit.hop)
return h;
return [...h, ...p.text.slice(0, blit.hop - h.length)];
}, []);
}
this.setState({ cursor: hops.join('').length });
break;
case 'lin':
this.state.lines[this.state.lines.length - 1] = blit;
this.setState({ lines: this.state.lines });
break;
case 'klr':
this.state.lines[this.state.lines.length - 1] = blit;
this.setState({ lines: this.state.lines });
break;
case 'mor':
this.state.lines.push('');
this.setState({ lines: this.state.lines });
break;
case 'sag':
blit.sav = blit.sag;
break;
case 'sav':
const name = blit.sav.path.split('/').slice(-2).join('.');
const buff = new Buffer(blit.sav.file, 'base64');
const blob = new Blob([buff], { type: 'application/octet-stream' });
saveAs(blob, name);
break;
case 'url':
// TODO too invasive? just print as <a>?
window.open(blit.url);
break;
default: console.log('weird blit', blit);
}
}
setStateHandler(setState) {
this.setState = setState;
}
}

View File

@ -1,87 +0,0 @@
export default class Subscription {
store: any;
api: any;
channel: any;
firstRoundComplete: boolean;
constructor(store, api, channel) {
this.store = store;
this.api = api;
this.channel = channel;
this.channel.setOnChannelError(this.onChannelError.bind(this));
this.firstRoundComplete = false;
}
start() {
if (this.api.ship) {
this.firstRound();
} else {
console.error('~~~ ERROR: Must set api.ship before operation ~~~');
}
this.setupSlog();
}
setupSlog() {
let available = false;
const slog = new EventSource('/~_~/slog', { withCredentials: true });
slog.onopen = (e) => {
console.log('slog: opened stream');
available = true;
};
slog.onmessage = (e) => {
this.handleEvent({ slog: e.data });
};
slog.onerror = (e) => {
console.error('slog: eventsource error:', e);
if (available) {
window.setTimeout(() => {
if (slog.readyState !== EventSource.CLOSED)
return;
console.log('slog: reconnecting...');
this.setupSlog();
}, 10000);
}
};
}
delete() {
this.channel.delete();
}
onChannelError(err) {
console.error('event source error: ', err);
console.log('initiating new channel');
this.firstRoundComplete = false;
setTimeout(() => {
this.store.handleEvent({
data: { clear : true }
});
this.start();
}, 2000);
}
subscribe(path, app) {
this.api.bind(path, 'PUT', this.api.ship, app,
this.handleEvent.bind(this),
(err) => {
console.log(err);
this.subscribe(path, app);
},
() => {
this.subscribe(path, app);
});
}
firstRound() {
this.subscribe('/session/', 'herm');
}
handleEvent(diff) {
this.store.handleEvent(diff);
}
}