mirror of
https://github.com/urbit/shrub.git
synced 2024-12-24 20:47:27 +03:00
c7c601c041
append /view to the existing subscription paths to "wrap" the possibly empty path segment of the session name.
472 lines
12 KiB
TypeScript
472 lines
12 KiB
TypeScript
/* 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';
|
|
|
|
type TermAppProps = {
|
|
ship: string;
|
|
}
|
|
|
|
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(';');
|
|
}
|
|
|
|
// 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) {
|
|
//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);
|
|
}
|
|
|
|
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) {
|
|
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%'
|
|
bg='white'
|
|
fontFamily='mono'
|
|
overflow='hidden'
|
|
>
|
|
<Col
|
|
width='100%'
|
|
height='100%'
|
|
minHeight='0'
|
|
px={['0','2']}
|
|
pb={['0','2']}
|
|
ref={container}
|
|
>
|
|
</Col>
|
|
</Box>
|
|
</ThemeProvider>
|
|
</>
|
|
);
|
|
}
|