mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-25 07:52:59 +03:00
Merge pull request #5454 from urbit/m/backport-nu-webterm
webterm: backport
This commit is contained in:
commit
dd6b090bfa
@ -32,7 +32,8 @@
|
||||
++ on-watch
|
||||
|= =path
|
||||
^- (quip card:agent:gall _this)
|
||||
?> ?=([%session @ ~] path)
|
||||
?> =(our src):bowl
|
||||
?> ?=([%session @ %view ~] path)
|
||||
:_ this
|
||||
:: scry prompt and cursor position out of dill for initial response
|
||||
::
|
||||
@ -57,12 +58,13 @@
|
||||
:_ this
|
||||
%+ turn p.sign-arvo
|
||||
|= =blit:dill
|
||||
[%give %fact [%session %$ ~]~ %blit !>(blit)]
|
||||
[%give %fact [%session %$ %view ~]~ %blit !>(blit)]
|
||||
==
|
||||
::
|
||||
++ on-poke
|
||||
|= [=mark =vase]
|
||||
^- (quip card:agent:gall _this)
|
||||
?> =(our src):bowl
|
||||
?. ?=(%belt mark)
|
||||
~| [%unexpected-mark mark]
|
||||
!!
|
||||
|
8
pkg/interface/webterm/api.ts
Normal file
8
pkg/interface/webterm/api.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import Urbit from '@urbit/http-api';
|
||||
const api = new Urbit('', '', (window as any).desk);
|
||||
api.ship = window.ship;
|
||||
// api.verbose = true;
|
||||
// @ts-ignore TODO window typings
|
||||
window.api = api;
|
||||
|
||||
export default api;
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,94 +1,471 @@
|
||||
import { Box, Col } from '@tlon/indigo-react';
|
||||
import React, { Component } from 'react';
|
||||
import dark from '@tlon/indigo-dark';
|
||||
import light from '@tlon/indigo-light';
|
||||
/* eslint-disable max-lines */
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback
|
||||
} from 'react';
|
||||
|
||||
import useTermState from './state';
|
||||
import { useDark } from './join';
|
||||
import api from './api';
|
||||
|
||||
import { Terminal, ITerminalOptions, ITheme } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
import { Box, Col, Reset, _dark, _light } from '@tlon/indigo-react';
|
||||
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
import {
|
||||
Belt, Blit, Stye, Stub, Tint, Deco,
|
||||
pokeTask, pokeBelt
|
||||
} from '@urbit/api/term';
|
||||
|
||||
import bel from './lib/bel';
|
||||
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));
|
||||
type TermAppProps = {
|
||||
ship: string;
|
||||
}
|
||||
|
||||
this.state = this.store.state;
|
||||
const makeTheme = (dark: boolean): ITheme => {
|
||||
let fg, bg: string;
|
||||
if (dark) {
|
||||
fg = 'white';
|
||||
bg = 'rgb(26,26,26)';
|
||||
} 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',
|
||||
fontWeight: 400,
|
||||
// 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(';');
|
||||
}
|
||||
|
||||
resetControllers() {
|
||||
this.api = null;
|
||||
this.subscription = null;
|
||||
// background color
|
||||
//
|
||||
if (s.back !== null) {
|
||||
if (out !== '') {
|
||||
out += ';';
|
||||
}
|
||||
out += '4';
|
||||
out += tint(s.back);
|
||||
}
|
||||
|
||||
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();
|
||||
// foreground color
|
||||
//
|
||||
if (s.fore !== null) {
|
||||
if (out !== '') {
|
||||
out += ';';
|
||||
}
|
||||
out += '3';
|
||||
out += tint(s.fore);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.subscription.delete();
|
||||
this.store.clear();
|
||||
this.resetControllers();
|
||||
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) {
|
||||
//TODO remove for new backend
|
||||
{
|
||||
out += csi('H', term.rows, 1);
|
||||
out += csi('K');
|
||||
}
|
||||
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');
|
||||
//
|
||||
//TODO remove for new backend
|
||||
} else if ('lin' in blit) {
|
||||
out += csi('H', term.rows, 1);
|
||||
out += csi('K');
|
||||
out += blit.lin.join('');
|
||||
} else if ('mor' in blit) {
|
||||
out += '\n';
|
||||
} else {
|
||||
console.log('weird blit', blit);
|
||||
}
|
||||
|
||||
getTheme() {
|
||||
const { props } = this;
|
||||
return ((props.dark && props?.display?.theme == 'auto') ||
|
||||
props?.display?.theme == 'dark'
|
||||
) ? dark : light;
|
||||
}
|
||||
term.write(out);
|
||||
};
|
||||
|
||||
render() {
|
||||
const theme = this.getTheme();
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
// 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) {
|
||||
let k = String.fromCharCode(96 + c);
|
||||
//NOTE prevent remote shut-downs
|
||||
if ('d' !== k) {
|
||||
belts.push({ ctl: k });
|
||||
//TODO for new backend
|
||||
// belts.push({ mod: { mod: 'ctl', key: k } });
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
//TODO re-enable for new backend
|
||||
// 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!
|
||||
//TODO pokeBelt(ses, b);
|
||||
api.poke({
|
||||
app: 'herm',
|
||||
mark: 'belt',
|
||||
json: 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) => {
|
||||
//TODO re-enable once new backend lands
|
||||
// api.poke(pokeTask(selected, { blew: { w: e.cols, h: e.rows } }));
|
||||
});
|
||||
|
||||
ses = { term, fit };
|
||||
|
||||
// open subscription
|
||||
//
|
||||
api.subscribe({ app: 'herm', path: '/session/'+selected+'/view',
|
||||
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 (
|
||||
<>
|
||||
<ThemeProvider theme={dark ? _dark : _light}>
|
||||
<Reset />
|
||||
<Box
|
||||
width='100%'
|
||||
height='100%'
|
||||
p={['0','3']}
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
bg='white'
|
||||
fontFamily='mono'
|
||||
overflow='hidden'
|
||||
>
|
||||
<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' }}
|
||||
minHeight='0'
|
||||
px={['0','2']}
|
||||
pb={['0','2']}
|
||||
ref={container}
|
||||
>
|
||||
{/* @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>
|
||||
);
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TermApp;
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
});
|
@ -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; }
|
||||
}
|
@ -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",
|
||||
@ -18,6 +18,16 @@
|
||||
"display": "standalone",
|
||||
"background_color": "%23FFFFFF",
|
||||
"theme_color": "%23000000"}' />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body, #root {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
24
pkg/interface/webterm/join.ts
Normal file
24
pkg/interface/webterm/join.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from './settings';
|
||||
import useTermState from './state';
|
||||
|
||||
export function useDark() {
|
||||
const [osDark, setOsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const themeWatcher = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const update = (e: MediaQueryListEvent) => {
|
||||
setOsDark(e.matches);
|
||||
};
|
||||
setOsDark(themeWatcher.matches);
|
||||
themeWatcher.addListener(update);
|
||||
|
||||
return () => {
|
||||
themeWatcher.removeListener(update);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const theme = useTermState(s => s.theme);
|
||||
return theme === 'dark' || (osDark && theme === 'auto');
|
||||
}
|
File diff suppressed because one or more lines are too long
1
pkg/interface/webterm/lib/bel.ts
Normal file
1
pkg/interface/webterm/lib/bel.ts
Normal file
File diff suppressed because one or more lines are too long
@ -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++;
|
||||
}
|
||||
}
|
29092
pkg/interface/webterm/package-lock.json
generated
29092
pkg/interface/webterm/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,63 +1,23 @@
|
||||
{
|
||||
"name": "interface",
|
||||
"name": "webterm",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@reach/disclosure": "^0.10.5",
|
||||
"@reach/menu-button": "^0.10.5",
|
||||
"@reach/tabs": "^0.10.5",
|
||||
"@react-spring/web": "^9.1.1",
|
||||
"@tlon/indigo-dark": "^1.0.6",
|
||||
"@tlon/indigo-light": "^1.0.7",
|
||||
"@tlon/indigo-react": "^1.2.23",
|
||||
"@tlon/sigil-js": "^1.4.3",
|
||||
"@urbit/api": "^1.1.1",
|
||||
"@urbit/http-api": "^1.2.1",
|
||||
"any-ascii": "^0.1.7",
|
||||
"aws-sdk": "^2.830.0",
|
||||
"big-integer": "^1.6.48",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.59.2",
|
||||
"css-loader": "^3.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "^2.1.5",
|
||||
"immer": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mousetrap-global-bind": "^1.1.0",
|
||||
"normalize-wheel": "1.0.1",
|
||||
"oembed-parser": "^1.4.5",
|
||||
"prop-types": "^15.7.2",
|
||||
"querystring": "^0.2.0",
|
||||
"react": "^16.14.0",
|
||||
"react-codemirror2": "^6.0.1",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-markdown": "^4.3.1",
|
||||
"react-oembed-container": "^1.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use-gesture": "^9.1.3",
|
||||
"react-virtuoso": "^0.20.3",
|
||||
"react-visibility-sensor": "^5.1.1",
|
||||
"remark": "^12.0.0",
|
||||
"remark-breaks": "^2.0.2",
|
||||
"remark-disable-tokenizers": "1.1.0",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"style-loader": "^1.3.0",
|
||||
"styled-components": "^5.1.1",
|
||||
"styled-system": "^5.1.5",
|
||||
"suncalc": "^1.8.0",
|
||||
"unist-util-visit": "^3.0.0",
|
||||
"urbit-ob": "^5.0.1",
|
||||
"workbox-core": "^6.0.2",
|
||||
"workbox-precaching": "^6.0.2",
|
||||
"workbox-recipes": "^6.0.2",
|
||||
"workbox-routing": "^6.0.2",
|
||||
"yup": "^0.29.3",
|
||||
"xterm": "^4.15.0",
|
||||
"xterm-addon-fit": "^0.5.0",
|
||||
"zustand": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -69,17 +29,11 @@
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-react": "^7.12.10",
|
||||
"@babel/preset-typescript": "^7.12.7",
|
||||
"@storybook/addon-actions": "^6.2.9",
|
||||
"@storybook/addon-essentials": "^6.2.9",
|
||||
"@storybook/addon-links": "^6.2.9",
|
||||
"@storybook/react": "^6.2.9",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/react": "^16.14.2",
|
||||
"@types/react-dom": "^16.9.10",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"@types/styled-components": "^5.1.7",
|
||||
"@types/styled-system": "^5.1.10",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.15.0",
|
||||
"@typescript-eslint/parser": "^4.24.0",
|
||||
"@urbit/eslint-config": "^1.0.0",
|
||||
@ -87,9 +41,7 @@
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-root-import": "^6.6.0",
|
||||
"chromatic": "^5.8.3",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^7.26.0",
|
||||
@ -99,13 +51,7 @@
|
||||
"husky": "^6.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"lint-staged": "^11.0.0",
|
||||
"loki": "^0.28.1",
|
||||
"moment-locales-webpack-plugin": "^1.2.0",
|
||||
"react-hot-loader": "^4.13.0",
|
||||
"sass": "^1.32.5",
|
||||
"sass-loader": "^8.0.2",
|
||||
"storybook-addon-designs": "^6.0.0",
|
||||
"ts-mdast": "^1.0.0",
|
||||
"typescript": "^4.2.4",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-cli": "^3.3.12",
|
||||
@ -121,9 +67,6 @@
|
||||
"start": "webpack-dev-server --config config/webpack.dev.js",
|
||||
"test": "tsc && jest",
|
||||
"jest": "jest",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook",
|
||||
"chromatic": "chromatic --exit-zero-on-changes",
|
||||
"hook-lint": "eslint --cache --fix"
|
||||
},
|
||||
"author": "",
|
||||
|
26
pkg/interface/webterm/state.ts
Normal file
26
pkg/interface/webterm/state.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import create from 'zustand';
|
||||
import produce from 'immer';
|
||||
|
||||
type Session = { term: Terminal, fit: FitAddon };
|
||||
type Sessions = { [id: string]: Session; }
|
||||
|
||||
export interface TermState {
|
||||
sessions: Sessions,
|
||||
selected: string,
|
||||
slogstream: null | EventSource,
|
||||
theme: 'auto' | 'light' | 'dark'
|
||||
};
|
||||
|
||||
const useTermState = create<TermState>((set, get) => ({
|
||||
sessions: {} as Sessions,
|
||||
selected: '', // empty string is default session
|
||||
slogstream: null,
|
||||
theme: 'auto',
|
||||
set: (f: (draft: TermState) => void) => {
|
||||
set(produce(f));
|
||||
}
|
||||
} as TermState));
|
||||
|
||||
export default useTermState;
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
2
pkg/npm/api/term/index.ts
Normal file
2
pkg/npm/api/term/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './lib';
|
22
pkg/npm/api/term/lib.ts
Normal file
22
pkg/npm/api/term/lib.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Scry } from '../http-api/src'
|
||||
import { Poke } from '../http-api/src/types';
|
||||
import { Belt, Task, SessionTask } from './types';
|
||||
|
||||
export const pokeTask = (session: string, task: Task): Poke<SessionTask> => ({
|
||||
app: 'herm',
|
||||
mark: 'herm-task',
|
||||
json: { session, ...task }
|
||||
});
|
||||
|
||||
export const pokeBelt = (
|
||||
session: string,
|
||||
belt: Belt
|
||||
): Poke<SessionTask> => pokeTask(session, { belt });
|
||||
|
||||
//NOTE scry will return string[]
|
||||
// export const scrySessions = (): Scry => ({
|
||||
// app: 'herm',
|
||||
// path: `/sessions`
|
||||
// });
|
||||
//TODO remove stub once new backend lands
|
||||
export const scrySessions = (): string[] => [''];
|
65
pkg/npm/api/term/types.ts
Normal file
65
pkg/npm/api/term/types.ts
Normal file
@ -0,0 +1,65 @@
|
||||
// outputs
|
||||
//
|
||||
|
||||
export type TermUpdate =
|
||||
| Blit;
|
||||
|
||||
export type Tint =
|
||||
| null
|
||||
| 'r' | 'g' | 'b' | 'c' | 'm' | 'y' | 'k' | 'w'
|
||||
| { r: number, g: number, b: number };
|
||||
|
||||
export type Deco = null | 'br' | 'un' | 'bl';
|
||||
|
||||
export type Stye = {
|
||||
deco: Deco[],
|
||||
back: Tint,
|
||||
fore: Tint
|
||||
};
|
||||
|
||||
export type Stub = {
|
||||
stye: Stye,
|
||||
text: string[]
|
||||
}
|
||||
|
||||
export type Blit =
|
||||
| { bel: null } // make a noise
|
||||
| { clr: null } // clear the screen
|
||||
| { hop: number | { r: number, c: number } } // set cursor col/pos
|
||||
| { klr: Stub[] } // put styled
|
||||
| { put: string[] } // put text at cursor
|
||||
| { nel: null } // newline
|
||||
| { sag: { path: string, file: string } } // save to jamfile
|
||||
| { sav: { path: string, file: string } } // save to file
|
||||
| { url: string } // activate url
|
||||
| { wyp: null } // wipe cursor line
|
||||
//
|
||||
| { lin: string[] } // legacy put
|
||||
| { mor: true } // legacy nel
|
||||
|
||||
// inputs
|
||||
//
|
||||
|
||||
export type Bolt =
|
||||
| string
|
||||
| { aro: 'd' | 'l' | 'r' | 'u' }
|
||||
| { bac: null }
|
||||
| { del: null }
|
||||
| { hit: { r: number, c: number } }
|
||||
| { ret: null }
|
||||
|
||||
export type Belt =
|
||||
| Bolt
|
||||
| { mod: { mod: 'ctl' | 'met' | 'hyp', key: Bolt } }
|
||||
| { txt: Array<string> }
|
||||
//
|
||||
| { ctl: string }; // legacy mod
|
||||
|
||||
export type Task =
|
||||
| { belt: Belt }
|
||||
| { blew: { w: number, h: number } }
|
||||
| { flow: { term: string, apps: Array<{ who: string, app: string }> } }
|
||||
| { hail: null }
|
||||
| { hook: null }
|
||||
|
||||
export type SessionTask = { session: string } & Task
|
@ -1,9 +1,9 @@
|
||||
:~ title+'Terminal'
|
||||
info+'A web interface to your Urbit\'s command line (the dojo).'
|
||||
info+'A web interface to your Urbit\'s command line.'
|
||||
color+0x2e.4347
|
||||
glob-http+['https://bootstrap.urbit.org/glob-0v6.ak34j.nao8k.dhqs4.s2atf.td1lc.glob' 0v6.ak34j.nao8k.dhqs4.s2atf.td1lc]
|
||||
glob-http+['https://bootstrap.urbit.org/glob-0v1.fgmgl.utdgt.kdu3r.4e5f9.v58rk.glob' 0v1.fgmgl.utdgt.kdu3r.4e5f9.v58rk]
|
||||
base+'webterm'
|
||||
version+[0 0 1]
|
||||
version+[1 0 0]
|
||||
website+'https://tlon.io'
|
||||
license+'MIT'
|
||||
==
|
||||
|
Loading…
Reference in New Issue
Block a user