xK/xP/public/xP.js

614 lines
14 KiB
JavaScript
Raw Normal View History

// 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
2022-09-06 20:17:23 +02:00
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: {eventSeq: message.eventSeq, ...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"
// Left shifts in Javascript convert to a 32-bit signed number.
let seq = ++this.commandSeq
if ((seq << 0) != seq)
seq = this.commandSeq = 0
this.ws.send(JSON.stringify({commandSeq: seq, data: params}))
return new Promise((resolve, reject) => {
this.promised[seq] = {resolve, reject}
})
}
2022-09-06 23:37:06 +02:00
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
2022-09-06 23:37:06 +02:00
let bufferLog = undefined
let bufferAutoscroll = true
2022-09-06 23:37:06 +02:00
function resetBufferStats(b) {
b.newMessages = 0
b.newUnimportantMessages = 0
b.highlighted = false
}
2022-09-06 20:17:23 +02:00
let connecting = true
rpc.connect().then(result => {
buffers.clear()
bufferCurrent = undefined
2022-09-06 23:37:06 +02:00
bufferLog = undefined
bufferAutoscroll = true
2022-09-06 23:37:06 +02:00
2022-09-06 20:17:23 +02:00
rpc.send({command: 'Hello', version: 1})
connecting = false
m.redraw()
}).catch(error => {
connecting = false
m.redraw()
})
rpc.addEventListener('close', event => {
m.redraw()
})
rpc.addEventListener('Ping', event => {
rpc.send({command: 'PingResponse', eventSeq: event.detail.eventSeq})
})
rpc.addEventListener('BufferUpdate', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b === undefined) {
buffers.set(e.bufferName, (b = {lines: []}))
resetBufferStats(b)
}
})
rpc.addEventListener('BufferStats', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b === undefined)
return
b.newMessages = e.newMessages,
b.newUnimportantMessages = e.newUnimportantMessages
b.highlighted = e.highlighted
})
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 => {
2022-09-06 23:17:47 +02:00
let old = buffers.get(bufferCurrent)
if (old !== undefined)
resetBufferStats(old)
let e = event.detail, b = buffers.get(e.bufferName)
bufferCurrent = e.bufferName
2022-09-06 23:37:06 +02:00
bufferLog = undefined
bufferAutoscroll = true
2022-09-06 23:17:47 +02:00
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), line = {...e}
delete line.event
delete line.leakToActive
if (b === undefined)
return
// Initial sync: skip all other processing, let highlights be.
if (bufferCurrent === undefined) {
b.lines.push(line)
return
}
let visible = !document.hidden && bufferLog === undefined &&
(e.bufferName == bufferCurrent || e.leakToActive)
b.lines.push({...line})
if (!(visible || e.leakToActive) ||
b.newMessages || b.newUnimportantMessages) {
if (line.isUnimportant)
b.newUnimportantMessages++
else
b.newMessages++
}
if (e.leakToActive) {
let bc = buffers.get(bufferCurrent)
bc.lines.push({...line, leaked: true})
if (!visible || bc.newMessages || bc.newUnimportantMessages) {
if (line.isUnimportant)
bc.newUnimportantMessages++
else
bc.newMessages++
}
}
// TODO: Find some way of highlighting the tab in a browser.
// TODO: Also highlight on unseen private messages, like xC does.
if (!visible && line.isHighlight)
b.highlighted = true
})
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}`
}
// ---- UI ---------------------------------------------------------------------
2022-09-10 17:18:08 +02:00
let linkRE = [
/https?:\/\//,
/([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/,
/[^\[\](){}<>"'\s,.:]/,
].map(r => r.source).join('')
2022-09-07 14:07:14 +02:00
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 ? 'Scroll lock' : 'Scroll unlock'),
2022-09-07 14:07:14 +02:00
m('button', {onclick: Toolbar.toggleLog},
bufferLog === undefined ? 'Show log' : 'Hide log'),
])
},
}
let BufferList = {
2022-09-07 14:07:14 +02:00
activate: name => {
rpc.send({command: 'BufferActivate', bufferName: name})
},
view: vnode => {
2022-09-06 23:37:06 +02:00
let items = Array.from(buffers, ([name, b]) => {
let classes = [], displayName = name
if (name == bufferCurrent) {
classes.push('current')
} else {
if (b.highlighted)
classes.push('highlighted')
if (b.newMessages) {
classes.push('activity')
displayName += ` (${b.newMessages})`
}
}
return m('.item', {
onclick: event => BufferList.activate(name),
class: classes.join(' '),
}, displayName)
})
return m('.list', {}, items)
},
}
let Content = {
2022-09-07 14:07:14 +02:00
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
},
linkify: (text, attrs) => {
2022-09-10 17:18:08 +02:00
let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
2022-09-07 14:07:14 +02:00
while ((match = re.exec(text)) !== null) {
if (end < match.index)
a.push(m('span', attrs, text.substring(end, match.index)))
2022-09-10 16:14:12 +02:00
a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0]))
2022-09-07 14:07:14 +02:00
end = re.lastIndex
}
if (end < text.length)
a.push(m('span', attrs, text.substring(end)))
return a
},
view: vnode => {
let line = vnode.children[0]
2022-09-07 14:07:14 +02:00
let mark = undefined
switch (line.rendition) {
2022-09-07 14:07:14 +02:00
case 'Indent': mark = m('span.mark', {}, ''); break
case 'Status': mark = m('span.mark', {}, ''); break
case 'Error': mark = m('span.mark.error', {}, '⚠'); break
case 'Join': mark = m('span.mark.join', {}, '→'); break
case 'Part': mark = m('span.mark.part', {}, '←'); break
case 'Action': mark = 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
return m('.content', vnode.attrs, [mark, line.items.flatMap(item => {
switch (item.kind) {
case 'Text':
2022-09-07 14:07:14 +02:00
return Content.linkify(item.text, {
class: Array.from(classes.keys()).join(' '),
2022-09-07 14:07:14 +02:00
style: Content.applyColor(fg, bg, inverse),
})
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
2022-09-07 14:07:14 +02:00
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
}
2022-09-07 14:07:14 +02:00
})])
},
}
let Buffer = {
controller: new AbortController(),
onbeforeremove: vnode => {
Buffer.controller.abort()
},
onupdate: vnode => {
if (bufferAutoscroll)
vnode.dom.scrollTop = vnode.dom.scrollHeight
},
oncreate: vnode => {
Buffer.onupdate(vnode)
window.addEventListener('resize', event => Buffer.onupdate(vnode),
{signal: Buffer.controller.signal})
},
view: vnode => {
let lines = []
let b = buffers.get(bufferCurrent)
if (b === undefined)
return m('.buffer')
let lastDateMark = undefined
let markBefore = b.lines.length
- b.newMessages - b.newUnimportantMessages
b.lines.forEach((line, i) => {
let date = new Date(line.when)
let dateMark = date.toLocaleDateString()
if (dateMark !== lastDateMark) {
lines.push(m('.date', {}, dateMark))
lastDateMark = dateMark
}
if (i == markBefore)
lines.push(m('.unread'))
let attrs = {}
if (line.leaked)
attrs.class = 'leaked'
// TODO: Make use of isUnimportant.
lines.push(m('.time', {...attrs}, date.toLocaleTimeString()))
lines.push(m(Content, {...attrs}, line))
})
2022-09-07 19:23:17 +02:00
let dateMark = new Date().toLocaleDateString()
if (dateMark !== lastDateMark && lastDateMark !== undefined)
2022-09-07 19:23:17 +02:00
lines.push(m('.date', {}, dateMark))
2022-09-07 14:07:14 +02:00
return m('.buffer', {}, lines)
2022-09-06 23:37:06 +02:00
},
}
2022-09-07 14:07:14 +02:00
let Log = {
oncreate: vnode => {
if (vnode.dom !== undefined)
vnode.dom.scrollTop = vnode.dom.scrollHeight
},
2022-09-10 17:18:08 +02:00
linkify: text => {
let re = new RegExp(linkRE, 'g'), a = [], end = 0, match
while ((match = re.exec(text)) !== null) {
if (end < match.index)
a.push(text.substring(end, match.index))
a.push(m('a[target=_blank]', {href: match[0]}, match[0]))
end = re.lastIndex
}
if (end < text.length)
a.push(text.substring(end))
return a
},
2022-09-07 14:07:14 +02:00
view: vnode => {
2022-09-10 17:18:08 +02:00
return m(".log", {}, Log.linkify(bufferLog))
2022-09-06 23:37:06 +02:00
},
2022-09-07 14:07:14 +02:00
}
2022-09-06 23:37:06 +02:00
2022-09-07 14:07:14 +02:00
let BufferContainer = {
2022-09-06 23:37:06 +02:00
view: vnode => {
2022-09-07 14:07:14 +02:00
return m('.buffer-container', {}, [
m('.filler'),
bufferLog !== undefined ? m(Log) : m(Buffer),
])
},
}
2022-09-07 14:07:14 +02:00
let Input = {
counter: 0,
stamp: textarea => {
return [Input.counter,
textarea.selectionStart, textarea.selectionEnd, textarea.value]
},
2022-09-07 14:07:14 +02:00
complete: textarea => {
if (textarea.selectionStart !== textarea.selectionEnd)
2022-09-07 14:07:14 +02:00
return false
// Cancel any previous autocomplete, and ensure applicability.
Input.counter++
let state = Input.stamp(textarea)
rpc.send({
command: 'BufferComplete',
bufferName: bufferCurrent,
text: textarea.value,
position: textarea.selectionEnd,
2022-09-06 23:37:06 +02:00
}).then(resp => {
if (!Input.stamp(textarea).every((v, k) => v === state[k]))
return
// TODO: Somehow display remaining options, or cycle through.
2022-09-06 23:37:06 +02:00
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')
})
2022-09-07 14:07:14 +02:00
return true
},
submit: textarea => {
rpc.send({
command: 'BufferInput',
bufferName: bufferCurrent,
text: textarea.value,
})
textarea.value = ''
2022-09-07 14:07:14 +02:00
return true
},
2022-09-07 14:07:14 +02:00
onKeyDown: event => {
// TODO: And perhaps on other actions, too.
rpc.send({command: 'Active'})
let textarea = event.currentTarget
let handled = false
switch (event.keyCode) {
case 9:
if (!event.shiftKey)
handled = Input.complete(textarea)
2022-09-07 14:07:14 +02:00
break
case 13:
if (!event.shiftKey)
handled = Input.submit(textarea)
2022-09-07 14:07:14 +02:00
break
}
if (handled)
event.preventDefault()
},
view: vnode => {
2022-09-07 14:07:14 +02:00
return m('textarea#input', {rows: 1, onkeydown: Input.onKeyDown})
},
}
let Main = {
view: vnode => {
2022-09-06 20:17:23 +02:00
let state = "Connected"
if (connecting)
state = "Connecting..."
else if (rpc.ws === undefined)
state = "Disconnected"
return m('.xP', {}, [
2022-09-06 23:37:06 +02:00
m('.title', {}, [`xP (${state})`, m(Toolbar)]),
2022-09-07 14:07:14 +02:00
m('.middle', {}, [m(BufferList), m(BufferContainer)]),
m('.status', {}, bufferCurrent),
m(Input),
])
},
}
2022-09-06 21:27:14 +02:00
window.addEventListener('load', () => m.mount(document.body, Main))