mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-01 03:23:09 +03:00
webterm: support multiple sessions
Fully implements webterm support for multiple dill terminal sessions. Remaining work includes styling, session creation safety (name-wise), and general cleanup. Co-authored-by: tomholford <tomholford@users.noreply.github.com> Co-authored-by: liam-fitzgerald <liam@tlon.io>
This commit is contained in:
parent
998f7d081a
commit
d98611a04b
7
pkg/interface/webterm/.eslintrc.js
Normal file
7
pkg/interface/webterm/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
"rules": {
|
||||
"spaced-comment": false,
|
||||
}
|
||||
}
|
||||
|
||||
export default config;
|
1
pkg/interface/webterm/.nvmrc
Normal file
1
pkg/interface/webterm/.nvmrc
Normal file
@ -0,0 +1 @@
|
||||
16.14.0
|
276
pkg/interface/webterm/Buffer.tsx
Normal file
276
pkg/interface/webterm/Buffer.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { Terminal, ITerminalOptions } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import bel from './lib/bel';
|
||||
import api from './api';
|
||||
|
||||
import {
|
||||
Belt, pokeTask, pokeBelt
|
||||
} from '@urbit/api/term';
|
||||
import { Session } from './state';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import useTermState from './state';
|
||||
import React from 'react';
|
||||
import { Box, Col } from '@tlon/indigo-react';
|
||||
import { makeTheme } from './lib/theme';
|
||||
import { useDark } from './join';
|
||||
import { showBlit, csi, showSlog } from './lib/blit';
|
||||
|
||||
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 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({ 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;
|
||||
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;
|
||||
};
|
||||
|
||||
const onResize = (session: Session) => () => {
|
||||
//TODO debounce, if it ever becomes a problem
|
||||
//TODO test that we only send this to the selected session,
|
||||
// and that we *do* send it on-selected-change if necessary.
|
||||
session?.fit.fit();
|
||||
};
|
||||
|
||||
const onInput = (name: string, session: Session, e: string) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const term = session.term;
|
||||
const belts = readInput(term, e);
|
||||
belts.map((b) => {
|
||||
api.poke(pokeBelt(name, b));
|
||||
});
|
||||
};
|
||||
|
||||
interface BufferProps {
|
||||
name: string,
|
||||
selected: boolean,
|
||||
}
|
||||
|
||||
export default function Buffer({ name, selected }: BufferProps) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const dark = useDark();
|
||||
|
||||
const session: Session = useTermState(s => s.sessions[name]);
|
||||
|
||||
const initSession = useCallback(async (name: string, dark: boolean) => {
|
||||
console.log('setting up', name);
|
||||
|
||||
// set up xterm terminal
|
||||
//
|
||||
const term = new Terminal(termConfig);
|
||||
term.setOption('theme', makeTheme(dark));
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
fit.fit();
|
||||
term.focus();
|
||||
|
||||
// start mouse reporting
|
||||
//
|
||||
term.write(csi('?9h'));
|
||||
|
||||
const ses: Session = { term, fit, hasBell: false };
|
||||
|
||||
// set up event handlers
|
||||
//
|
||||
term.onData(e => onInput(name, ses, e));
|
||||
term.onBinary(e => onInput(name, ses, e));
|
||||
term.onResize((e) => {
|
||||
api.poke(pokeTask(name, { blew: { w: e.cols, h: e.rows } }));
|
||||
});
|
||||
|
||||
// open subscription
|
||||
//
|
||||
await api.subscribe({ app: 'herm', path: '/session/'+name+'/view',
|
||||
event: (e) => {
|
||||
showBlit(ses.term, e);
|
||||
if (e.bel && !selected) {
|
||||
useTermState.getState().set(state => {
|
||||
state.sessions[name].hasBell = true;
|
||||
});
|
||||
}
|
||||
//TODO should handle %bye on this higher level though, for deletion
|
||||
},
|
||||
quit: () => { // quit
|
||||
// TODO show user a message
|
||||
console.error('oops quit, pls handle');
|
||||
}
|
||||
});
|
||||
|
||||
useTermState.getState().set((state) => {
|
||||
state.sessions[name] = ses;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// init session
|
||||
useEffect(() => {
|
||||
if(session) {
|
||||
return;
|
||||
}
|
||||
|
||||
initSession(name, dark);
|
||||
}, [name]);
|
||||
|
||||
// on selected change, maybe setup the term, or put it into the container
|
||||
//
|
||||
const setContainer = useCallback((containerRef: HTMLDivElement | null) => {
|
||||
let newContainer = containerRef || container.current;
|
||||
if(session && newContainer) {
|
||||
container.current = newContainer;
|
||||
console.log('newcont', newContainer);
|
||||
// session.term.open(newContainer);
|
||||
} else {
|
||||
console.log('kaboom', session);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// on-init, open slogstream and fetch existing sessions
|
||||
//
|
||||
useEffect(() => {
|
||||
|
||||
window.addEventListener('resize', onResize(session));
|
||||
|
||||
return () => {
|
||||
// TODO clean up subs?
|
||||
window.removeEventListener('resize', onResize(session));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// on dark mode change, change terminals' theme
|
||||
//
|
||||
useEffect(() => {
|
||||
const theme = makeTheme(dark);
|
||||
if (session) {
|
||||
session.term.setOption('theme', theme);
|
||||
}
|
||||
if (container.current) {
|
||||
container.current.style.backgroundColor = theme.background || '';
|
||||
}
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && selected && !session.term.isOpen) {
|
||||
session!.term.open(container.current);
|
||||
session!.fit.fit();
|
||||
session!.term.focus();
|
||||
session!.term.isOpen = true;
|
||||
}
|
||||
}, [selected, session]);
|
||||
|
||||
return (
|
||||
!session && !selected ?
|
||||
<p>Loading...</p>
|
||||
:
|
||||
<Box
|
||||
width='100%'
|
||||
height='100%'
|
||||
bg='white'
|
||||
fontFamily='mono'
|
||||
overflow='hidden'
|
||||
style={selected ? {} : {display: 'none'}}
|
||||
>
|
||||
<Col
|
||||
width='100%'
|
||||
height='100%'
|
||||
minHeight='0'
|
||||
px={['0','2']}
|
||||
pb={['0','2']}
|
||||
ref={setContainer}
|
||||
>
|
||||
</Col>
|
||||
</Box>
|
||||
);
|
||||
}
|
44
pkg/interface/webterm/Tab.tsx
Normal file
44
pkg/interface/webterm/Tab.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { DEFAULT_SESSION } from './constants';
|
||||
import React, { useCallback } from 'react';
|
||||
import useTermState from './state';
|
||||
import { style } from 'styled-system';
|
||||
import api from './api';
|
||||
import { pokeTask } from '@urbit/api/term';
|
||||
|
||||
export const Tab = ( { session, name } ) => {
|
||||
|
||||
const isSelected = useTermState().selected === name;
|
||||
|
||||
const onClick = () => {
|
||||
console.log('click!', name);
|
||||
useTermState.getState().set((state) => {
|
||||
state.selected = name;
|
||||
state.sessions[name].hasBell = false;
|
||||
});
|
||||
useTermState.getState().sessions[name]?.term?.focus();
|
||||
}
|
||||
|
||||
const onDelete = (e) => {
|
||||
e.stopPropagation();
|
||||
api.poke(pokeTask(name, { shut: null }));
|
||||
useTermState.getState().set(state => {
|
||||
if (state.selected === name) {
|
||||
state.selected = DEFAULT_SESSION;
|
||||
}
|
||||
state.names = state.names.filter(n => n !== name);
|
||||
delete state.sessions[name];
|
||||
});
|
||||
//TODO clean up the subscription
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'tab ' + isSelected ? 'selected' : ''}>
|
||||
<a className='session-name' onClick={onClick}>
|
||||
{session?.hasBell ? '🔔 ' : ''}
|
||||
{name === DEFAULT_SESSION ? 'default' : name}
|
||||
{' '}
|
||||
</a>
|
||||
{name === DEFAULT_SESSION ? null : <a className="delete-session" onClick={onDelete}>x</a>}
|
||||
</div>
|
||||
);
|
||||
};
|
36
pkg/interface/webterm/Tabs.tsx
Normal file
36
pkg/interface/webterm/Tabs.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { pokeTask } from '@urbit/api/term';
|
||||
import api from './api';
|
||||
import React from 'react';
|
||||
import useTermState from './state';
|
||||
import { Tab } from './Tab';
|
||||
|
||||
export const Tabs = () => {
|
||||
const { sessions, names } = useTermState();
|
||||
|
||||
const onAddClick = () => {
|
||||
const name = prompt('please entew a session name uwu');
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
//TODO name must be @ta
|
||||
api.poke(pokeTask(name, { open: { term: 'hood', apps: [{ who: '~'+(window as any).ship, app: 'dojo' }] } }));
|
||||
useTermState.getState().set(state => {
|
||||
state.names = [name, ...state.names].sort();
|
||||
state.selected = name;
|
||||
state.sessions[name] = null;
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabs">
|
||||
{ names.map((n, i) => {
|
||||
return (
|
||||
<Tab session={sessions[n]} name={n} key={i} />
|
||||
);
|
||||
})}
|
||||
<button onClick={onAddClick}>+</button>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,292 +1,42 @@
|
||||
/* eslint-disable max-lines */
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback
|
||||
useCallback, useEffect
|
||||
} from 'react';
|
||||
|
||||
import useTermState from './state';
|
||||
import useTermState, { Sessions } 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 { Reset, _dark, _light } from '@tlon/indigo-react';
|
||||
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
import {
|
||||
Belt, Blit, Stye, Stub, Tint, Deco,
|
||||
pokeTask, pokeBelt
|
||||
scrySessions
|
||||
} from '@urbit/api/term';
|
||||
|
||||
import bel from './lib/bel';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { Tabs } from './Tabs';
|
||||
import Buffer from './Buffer';
|
||||
import { DEFAULT_SESSION } from './constants';
|
||||
import { showSlog } from './lib/blit';
|
||||
|
||||
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) {
|
||||
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) {
|
||||
let k = String.fromCharCode(96 + c);
|
||||
//NOTE prevent remote shut-downs
|
||||
if ('d' !== k) {
|
||||
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;
|
||||
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 { names, selected } = useTermState();
|
||||
const dark = useDark();
|
||||
|
||||
const initSessions = useCallback(async () => {
|
||||
const response = await api.scry(scrySessions());
|
||||
|
||||
useTermState.getState().set((state) => {
|
||||
state.names = response.sort();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setupSlog = useCallback(() => {
|
||||
console.log('slog: setting up...');
|
||||
let available = false;
|
||||
@ -298,9 +48,10 @@ export default function TermApp(props: TermAppProps) {
|
||||
};
|
||||
|
||||
slog.onmessage = (e) => {
|
||||
const session = useTermState.getState().sessions[''];
|
||||
const session = useTermState.getState().sessions[DEFAULT_SESSION];
|
||||
if (!session) {
|
||||
console.log('default session mia!', 'slog:', slog);
|
||||
console.log('slog: default session mia!', 'msg:', e.data);
|
||||
console.log(Object.keys(useTermState.getState().sessions), session);
|
||||
return;
|
||||
}
|
||||
showSlog(session.term, e.data);
|
||||
@ -319,131 +70,26 @@ export default function TermApp(props: TermAppProps) {
|
||||
}
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
useTermState.getState().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) => {
|
||||
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+'/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]);
|
||||
initSessions();
|
||||
setupSlog();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<Tabs />
|
||||
<div className="buffer-container">
|
||||
{names.map(name => {
|
||||
return <Buffer name={name} selected={name === selected} />;
|
||||
})}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</>
|
||||
);
|
||||
|
1
pkg/interface/webterm/constants.ts
Normal file
1
pkg/interface/webterm/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const DEFAULT_SESSION = '';
|
@ -27,6 +27,44 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buffer-container {
|
||||
height: calc(100% - 33px);
|
||||
}
|
||||
|
||||
div.tabs {
|
||||
height: 33px;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: flex-start;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
div.tabs > * {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
border: solid 1px black;
|
||||
padding: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.tab {
|
||||
text-align: center;
|
||||
/* display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: space-between; */
|
||||
}
|
||||
|
||||
div.tabs > div.selected > a.session-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.tabs > a.delete-session {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
73
pkg/interface/webterm/lib/blit.ts
Normal file
73
pkg/interface/webterm/lib/blit.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Blit, Stub } from '@urbit/api/term';
|
||||
import { stye } from '../lib/stye';
|
||||
|
||||
export const csi = (cmd: string, ...args: number[]) => {
|
||||
return '\x1b[' + args.join(';') + cmd;
|
||||
};
|
||||
|
||||
export 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);
|
||||
};
|
||||
|
||||
export 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'));
|
||||
};
|
60
pkg/interface/webterm/lib/stye.ts
Normal file
60
pkg/interface/webterm/lib/stye.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Deco, Stye, Tint } from '@urbit/api/term';
|
||||
|
||||
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}`;
|
||||
}
|
||||
};
|
||||
|
||||
export 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';
|
||||
};
|
21
pkg/interface/webterm/lib/theme.ts
Normal file
21
pkg/interface/webterm/lib/theme.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ITheme } from 'xterm';
|
||||
|
||||
export 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
|
||||
};
|
||||
};
|
@ -3,18 +3,22 @@ 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 type Session = { term: Terminal, fit: FitAddon, hasBell: boolean } | null;
|
||||
export type Sessions = { [id: string]: Session; }
|
||||
|
||||
export interface TermState {
|
||||
sessions: Sessions,
|
||||
names: string[],
|
||||
selected: string,
|
||||
slogstream: null | EventSource,
|
||||
theme: 'auto' | 'light' | 'dark'
|
||||
};
|
||||
theme: 'auto' | 'light' | 'dark',
|
||||
//TODO: figure out the type
|
||||
set: any,
|
||||
}
|
||||
|
||||
const useTermState = create<TermState>((set, get) => ({
|
||||
sessions: {} as Sessions,
|
||||
names: [''],
|
||||
selected: '', // empty string is default session
|
||||
slogstream: null,
|
||||
theme: 'auto',
|
||||
|
26
pkg/interface/webterm/tsconfig.json
Normal file
26
pkg/interface/webterm/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"noUnusedLocals": false,
|
||||
"noImplicitAny": false,
|
||||
"noEmit": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"strict": false,
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": [
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { Scry } from '../http-api/src'
|
||||
import { Poke } from '../http-api/src/types';
|
||||
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> => ({
|
||||
|
@ -53,8 +53,8 @@ export type Belt =
|
||||
export type Task =
|
||||
| { belt: Belt }
|
||||
| { blew: { w: number, h: number } }
|
||||
| { flow: { term: string, apps: Array<{ who: string, app: string }> } }
|
||||
| { hail: null }
|
||||
| { hook: null }
|
||||
| { open: { term: string, apps: Array<{ who: string, app: string }> } }
|
||||
| { shut: null }
|
||||
|
||||
export type SessionTask = { session: string } & Task
|
||||
|
Loading…
Reference in New Issue
Block a user