// Copyright (c) 2022, Přemysl Eric Janouch // SPDX-License-Identifier: 0BSD 'use strict' // ---- RPC -------------------------------------------------------------------- class RelayRpc extends EventTarget { constructor(url) { super() this.url = url this.commandSeq = 0 } connect() { // We can't close the connection immediately, as that queues a task. if (this.ws !== undefined) throw "Already connecting or connected" return new Promise((resolve, reject) => { let ws = this.ws = new WebSocket(this.url) ws.onopen = event => { this._initialize() resolve() } // It's going to be code 1006 with no further info. ws.onclose = event => { this.ws = undefined reject() } }) } _initialize() { this.ws.onopen = undefined this.ws.onmessage = event => { this._process(event.data) } this.ws.onerror = event => { this.dispatchEvent(new CustomEvent('error')) } this.ws.onclose = event => { let message = "Connection closed: " + event.code + " (" + event.reason + ")" for (const seq in this.promised) this.promised[seq].reject(message) this.ws = undefined this.dispatchEvent(new CustomEvent('close', { detail: {message, code: event.code, reason: event.reason}, })) // Now connect() can be called again. } this.promised = {} } _process(data) { console.log(data) if (typeof data !== 'string') throw "Binary messages not supported" let message = JSON.parse(data) if (typeof message !== 'object') throw "Invalid message" let e = message.data if (typeof e !== 'object') throw "Invalid message" switch (e.event) { case 'Error': if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].reject(e.error) else console.error("Unawaited error") break case 'Response': if (this.promised[e.commandSeq] !== undefined) this.promised[e.commandSeq].resolve(e.data) else console.error("Unawaited response") break default: if (typeof e.event !== 'string') throw "Invalid event tag" this.dispatchEvent(new CustomEvent(e.event, {detail: e})) // Minor abstraction layering violation. m.redraw() return } delete this.promised[e.commandSeq] for (const seq in this.promised) { // We don't particularly care about wraparound issues. if (seq >= e.commandSeq) continue this.promised[seq].reject("No response") delete this.promised[seq] } } send(params) { if (this.ws === undefined) throw "Not connected" if (typeof params !== 'object') throw "Method parameters must be an object" let seq = ++this.commandSeq if (seq >= 1 << 32) seq = this.commandSeq = 0 this.ws.send(JSON.stringify({commandSeq: seq, data: params})) return new Promise((resolve, reject) => { this.promised[seq] = {resolve, reject} }) } } // ---- Event processing ------------------------------------------------------- let rpc = new RelayRpc(proxy) let buffers = new Map() let bufferCurrent = undefined let connecting = true rpc.connect().then(result => { buffers.clear() bufferCurrent = undefined rpc.send({command: 'Hello', version: 1}) connecting = false m.redraw() }).catch(error => { connecting = false m.redraw() }) rpc.addEventListener('close', event => { m.redraw() }) rpc.addEventListener('BufferUpdate', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b === undefined) { b = {lines: []} buffers.set(e.bufferName, b) } // TODO: Update any buffer properties. }) rpc.addEventListener('BufferRename', event => { let e = event.detail buffers.set(e.new, buffers.get(e.bufferName)) buffers.delete(e.bufferName) }) rpc.addEventListener('BufferRemove', event => { let e = event.detail buffers.delete(e.bufferName) }) rpc.addEventListener('BufferActivate', event => { let e = event.detail bufferCurrent = e.bufferName setTimeout(() => { let el = document.getElementById('input') if (el !== null) el.focus() }) }) rpc.addEventListener('BufferLine', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b === undefined) return b.lines.push({when: e.when, rendition: e.rendition, items: e.items}) }) rpc.addEventListener('BufferClear', event => { let e = event.detail, b = buffers.get(e.bufferName) if (b !== undefined) b.lines.length = 0 }) // --- Colours ----------------------------------------------------------------- let palette = [ '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc', '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff', ] palette.length = 256 for (let i = 0; i < 216; i++) { let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6 r = !r ? '00' : (55 + 40 * r).toString(16) g = !g ? '00' : (55 + 40 * g).toString(16) b = !b ? '00' : (55 + 40 * b).toString(16) palette[16 + i] = `#${r}${g}${b}` } for (let i = 0; i < 24; i++) { let g = ('0' + (8 + i * 10).toString(16)).slice(-2) palette[232 + i] = `#${g}${g}${g}` } function applyColor(fg, bg, inverse) { if (inverse) [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0] let style = {} if (fg >= 0) style.color = palette[fg] if (bg >= 0) style.backgroundColor = palette[bg] if (style) return style } // ---- UI --------------------------------------------------------------------- let BufferList = { view: vnode => { let items = [] buffers.forEach((b, name) => { let attrs = { onclick: event => { rpc.send({command: 'BufferActivate', bufferName: name}) }, } if (name == bufferCurrent) attrs.class = 'active' items.push(m('.item', attrs, name)) }) return m('.list', {}, items) }, } function linkify(text, attrs, a) { let re = new RegExp([ /https?:\/\//, /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/, /[^\[\](){}<>"'\s,.:]/, ].map(r => r.source).join(''), 'g') let end = 0, match while ((match = re.exec(text)) !== null) { if (end < match.index) a.push(m('span', attrs, text.substring(end, match.index))) a.push(m('a', {href: match[0], ...attrs}, match[0])) end = re.lastIndex } if (end < text.length) a.push(m('span', attrs, text.substring(end))) } let Content = { view: vnode => { let line = vnode.children[0] let content = [] switch (line.rendition) { case 'Indent': content.push(m('span.mark', {}, '')); break case 'Status': content.push(m('span.mark', {}, '–')); break case 'Error': content.push(m('span.mark.error', {}, '⚠')); break case 'Join': content.push(m('span.mark.join', {}, '→')); break case 'Part': content.push(m('span.mark.part', {}, '←')); break case 'Action': content.push(m('span.mark.action', {}, '✶')); break } let classes = new Set() let flip = c => { if (classes.has(c)) classes.delete(c) else classes.add(c) } let fg = -1, bg = -1, inverse = false line.items.forEach(item => { switch (item.kind) { case 'Text': linkify(item.text, { class: Array.from(classes.keys()).join(' '), style: applyColor(fg, bg, inverse), }, content) break case 'Reset': classes.clear() fg = bg = -1 inverse = false break case 'FgColor': fg = item.color break case 'BgColor': bg = item.color break case 'FlipInverse': inverse = !inverse break case 'FlipBold': flip('b'); break case 'FlipItalic': flip('i'); break case 'FlipUnderline': flip('u'); break case 'FlipCrossedOut': flip('s'); break case 'FlipMonospace': flip('m'); break } }) return m('.content', {}, content) }, } let Buffer = { oncreate: vnode => { if (vnode.dom === undefined) return let el = vnode.dom.children[1] if (el !== null) el.scrollTop = el.scrollHeight }, onupdate: vnode => { Buffer.oncreate(vnode) }, view: vnode => { let lines = [] let b = buffers.get(bufferCurrent) if (b === undefined) return let lastDateMark = undefined b.lines.forEach(line => { let date = new Date(line.when) let dateMark = date.toLocaleDateString() if (dateMark !== lastDateMark) { lines.push(m('.date', {}, dateMark)) lastDateMark = dateMark } lines.push(m('.time', {}, date.toLocaleTimeString())) lines.push(m(Content, {}, line)) }) return m('.buffer-container', {}, [ m('.filler'), m('.buffer', {}, lines), ]) }, } function onKeyDown(event) { // TODO: And perhaps on other actions, too. rpc.send({command: 'Active'}) // TODO: Cancel any current autocomplete. let textarea = event.currentTarget switch (event.keyCode) { case 9: if (textarea.selectionStart !== textarea.selectionEnd) return rpc.send({ command: 'BufferComplete', bufferName: bufferCurrent, text: textarea.value, position: textarea.selectionEnd, }).then(response => { // TODO: Somehow display remaining options, or cycle through. if (response.completions.length) textarea.setRangeText(response.completions[0], response.start, textarea.selectionEnd, 'end') if (response.completions.length === 1) textarea.setRangeText(' ', textarea.selectionStart, textarea.selectionEnd, 'end') }) break; case 13: rpc.send({ command: 'BufferInput', bufferName: bufferCurrent, text: textarea.value, }) textarea.value = '' break; default: return } event.preventDefault() } // TODO: This should be remembered across buffer switches, // and we'll probably have to intercept /all/ key presses. let Input = { view: vnode => { return m('textarea#input', { rows: 1, onkeydown: onKeyDown, }) }, } let Main = { view: vnode => { let state = "Connected" if (connecting) state = "Connecting..." else if (rpc.ws === undefined) state = "Disconnected" return m('.xP', {}, [ m('.title', {}, `xP (${state})`), m('.middle', {}, [m(BufferList), m(Buffer)]), m('.status', {}, bufferCurrent), m(Input), ]) }, } window.addEventListener('load', () => m.mount(document.body, Main))