xK/xP/public/xP.js

472 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}
})
}
base64decode(str) {
return decodeURIComponent(atob(str).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''))
}
}
// ---- Event processing -------------------------------------------------------
let rpc = new RelayRpc(proxy)
let buffers = new Map()
let bufferCurrent = undefined
let bufferLog = undefined
let bufferAutoscroll = true
let connecting = true
rpc.connect().then(result => {
buffers.clear()
bufferCurrent = undefined
bufferLog = undefined
bufferAutoscroll = true
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, b = buffers.get(e.bufferName)
let old = buffers.get(bufferCurrent)
bufferCurrent = e.bufferName
bufferLog = undefined
bufferAutoscroll = true
let textarea = document.getElementById('input')
if (textarea === null)
return
textarea.focus()
if (old !== undefined)
old.input = textarea.value
if (b !== undefined)
textarea.value = b.input || ''
else
textarea.value = ''
})
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 = Array.from(buffers, ([name, b]) => {
let attrs = {
onclick: event =>
rpc.send({command: 'BufferActivate', bufferName: name}),
}
if (name == bufferCurrent)
attrs.class = 'active'
return 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 ||
bufferLog !== undefined || !bufferAutoscroll)
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'),
bufferLog !== undefined
? m(".log", {}, bufferLog)
: m('.buffer', {}, lines),
])
},
}
let Toolbar = {
toggleAutoscroll: () => {
bufferAutoscroll = !bufferAutoscroll
},
toggleLog: () => {
if (bufferLog) {
bufferLog = undefined
return
}
rpc.send({
command: 'BufferLog',
bufferName: bufferCurrent,
}).then(resp => {
bufferLog = rpc.base64decode(resp.log)
m.redraw()
})
},
view: vnode => {
return m('.toolbar', {}, [
m('button', {onclick: Toolbar.toggleAutoscroll},
bufferAutoscroll ? 'Pause autoscroll' : 'Unpause autoscroll'),
m('button', {onclick: Toolbar.toggleLog},
bufferLog === undefined ? 'Show log' : 'Hide log'),
])
},
}
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(resp => {
// TODO: Somehow display remaining options, or cycle through.
if (resp.completions.length)
textarea.setRangeText(resp.completions[0],
resp.start, textarea.selectionEnd, 'end')
if (resp.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()
}
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(Toolbar)]),
m('.middle', {}, [m(BufferList), m(Buffer)]),
m('.status', {}, bufferCurrent),
m(Input),
])
},
}
window.addEventListener('load', () => m.mount(document.body, Main))