420 lines
9.7 KiB
JavaScript
420 lines
9.7 KiB
JavaScript
// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
|
||
// 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))
|