xK/xP/public/xP.js

865 lines
20 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(''))
}
}
// ---- Utilities --------------------------------------------------------------
2022-09-11 15:54:39 +02:00
function utf8Encode(s) { return new TextEncoder().encode(s) }
function utf8Decode(s) { return new TextDecoder().decode(s) }
// On macOS, the Alt/Option key transforms characters, which basically breaks
// all event.altKey shortcuts, so require combining them with Control as well
// on that system.
function hasShortcutModifiers(event) {
// This method of detection only works with Blink browsers, as of writing.
return event.altKey && !event.metaKey &&
(navigator.userAgentData?.platform === 'macOS') === event.ctrlKey
}
const audioContext = new AudioContext()
function beep() {
let gain = audioContext.createGain()
gain.gain.value = 0.5
gain.connect(audioContext.destination)
let oscillator = audioContext.createOscillator()
oscillator.type = "triangle"
oscillator.frequency.value = 800
oscillator.connect(gain)
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.1)
}
let iconLink = undefined
let iconState = undefined
function updateIcon(highlighted) {
if (iconState === highlighted)
return
iconState = highlighted
let canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
let ctx = canvas.getContext('2d')
ctx.arc(16, 16, 12, 0, 2 * Math.PI)
ctx.fillStyle = highlighted ? '#ff5f00' : '#ccc'
ctx.fill()
if (iconLink === undefined) {
iconLink = document.createElement('link')
iconLink.type = 'image/png'
iconLink.rel = 'icon'
document.getElementsByTagName('head')[0].appendChild(iconLink)
}
iconLink.href = canvas.toDataURL();
}
// ---- Event processing -------------------------------------------------------
let rpc = new RelayRpc(proxy)
let buffers = new Map()
2022-09-10 19:33:39 +02:00
let bufferLast = undefined
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
let servers = new Map()
function bufferResetStats(b) {
b.newMessages = 0
b.newUnimportantMessages = 0
b.highlighted = false
}
2022-09-10 19:33:39 +02:00
function bufferActivate(name) {
rpc.send({command: 'BufferActivate', bufferName: name})
}
function bufferToggleLog() {
if (bufferLog) {
setTimeout(() =>
document.getElementById('input')?.focus())
bufferLog = undefined
m.redraw()
return
}
let name = bufferCurrent
rpc.send({
command: 'BufferLog',
bufferName: name,
}).then(resp => {
if (bufferCurrent !== name)
return
bufferLog = rpc.base64decode(resp.log)
m.redraw()
})
}
2022-09-06 20:17:23 +02:00
let connecting = true
rpc.connect().then(result => {
buffers.clear()
2022-09-10 19:33:39 +02:00
bufferLast = undefined
2022-09-06 20:17:23 +02:00
bufferCurrent = undefined
2022-09-06 23:37:06 +02:00
bufferLog = undefined
bufferAutoscroll = true
2022-09-06 23:37:06 +02:00
servers.clear()
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})
})
// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
rpc.addEventListener('BufferUpdate', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b === undefined) {
buffers.set(e.bufferName, (b = {
lines: [],
history: [],
historyAt: 0,
}))
bufferResetStats(b)
}
b.hideUnimportant = e.hideUnimportant
b.kind = e.context.kind
b.server = servers.get(e.context.serverName)
})
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)
2022-09-10 19:33:39 +02:00
if (e.bufferName === bufferLast)
bufferLast = undefined
})
rpc.addEventListener('BufferActivate', event => {
2022-09-06 23:17:47 +02:00
let old = buffers.get(bufferCurrent)
if (old !== undefined)
bufferResetStats(old)
2022-09-10 19:33:39 +02:00
bufferLast = bufferCurrent
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()
2022-09-11 01:01:16 +02:00
if (old !== undefined) {
2022-09-06 23:17:47 +02:00
old.input = textarea.value
2022-09-11 01:01:16 +02:00
old.inputStart = textarea.selectionStart
old.inputEnd = textarea.selectionEnd
old.inputDirection = textarea.selectionDirection
// Note that we effectively overwrite the newest line
// with the current textarea contents, and jump there.
old.historyAt = old.history.length
2022-09-11 01:01:16 +02:00
}
2022-09-06 23:17:47 +02:00
2022-09-11 01:01:16 +02:00
textarea.value = ''
if (b !== undefined && b.input !== undefined) {
textarea.value = b.input
textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection)
}
})
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++
}
}
if (line.isHighlight ||
(!visible && b.kind === 'PrivateMessage' && !line.isUnimportant)) {
beep()
if (!visible)
b.highlighted = true
}
})
rpc.addEventListener('BufferClear', event => {
let e = event.detail, b = buffers.get(e.bufferName)
if (b !== undefined)
b.lines.length = 0
})
// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
rpc.addEventListener('ServerUpdate', event => {
let e = event.detail, s = servers.get(e.serverName)
if (s === undefined)
servers.set(e.serverName, (s = {}))
s.state = e.state
})
rpc.addEventListener('ServerRename', event => {
let e = event.detail
servers.set(e.new, servers.get(e.serverName))
servers.delete(e.serverName)
})
rpc.addEventListener('ServerRemove', event => {
let e = event.detail
servers.delete(e.serverName)
})
// --- 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
},
view: vnode => {
return m('.toolbar', {}, [
m('button', {onclick: Toolbar.toggleAutoscroll},
bufferAutoscroll ? 'Scroll lock' : 'Scroll unlock'),
m('button', {onclick: event => bufferToggleLog()},
2022-09-07 14:07:14 +02:00
bufferLog === undefined ? 'Show log' : 'Hide log'),
])
},
}
let BufferList = {
view: vnode => {
let highlighted = false
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')
highlighted = true
}
if (b.newMessages) {
classes.push('activity')
displayName += ` (${b.newMessages})`
}
}
return m('.item', {
2022-09-10 19:33:39 +02:00
onclick: event => bufferActivate(name),
class: classes.join(' '),
}, displayName)
})
updateIcon(highlighted)
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
2022-09-11 21:30:51 +02:00
let squashing = false
let markBefore = b.lines.length
- b.newMessages - b.newUnimportantMessages
b.lines.forEach((line, i) => {
if (i == markBefore)
lines.push(m('.unread'))
2022-09-11 21:30:51 +02:00
if (!line.isUnimportant || !b.hideUnimportant) {
squashing = false
} else if (squashing) {
return
2022-09-11 21:30:51 +02:00
} else {
squashing = true
}
let date = new Date(line.when)
let dateMark = date.toLocaleDateString()
if (dateMark !== lastDateMark) {
lines.push(m('.date', {}, dateMark))
lastDateMark = dateMark
}
2022-09-11 21:30:51 +02:00
if (squashing) {
lines.push(m('.time.hidden'))
lines.push(m('.content'))
return
}
let attrs = {}
if (line.leaked)
attrs.class = 'leaked'
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 => {
vnode.dom.scrollTop = vnode.dom.scrollHeight
vnode.dom.focus()
},
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),
])
},
}
let Status = {
view: vnode => {
let b = buffers.get(bufferCurrent)
if (b === undefined)
return m('.status', {}, 'Synchronizing...')
let status = `${bufferCurrent}`
if (b.hideUnimportant)
status += `<H>`
if (b.server !== undefined)
status += ` (${b.server.state})`
return m('.status', {}, status)
},
}
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,
2022-09-11 15:54:39 +02:00
position: utf8Encode(
textarea.value.slice(0, textarea.selectionEnd)).length,
2022-09-06 23:37:06 +02:00
}).then(resp => {
if (!Input.stamp(textarea).every((v, k) => v === state[k]))
return
2022-09-11 15:54:39 +02:00
let preceding = utf8Encode(textarea.value).slice(0, resp.start)
let start = utf8Decode(preceding).length
// TODO: Somehow display remaining options, or cycle through.
2022-09-11 15:54:39 +02:00
if (resp.completions.length) {
2022-09-06 23:37:06 +02:00
textarea.setRangeText(resp.completions[0],
2022-09-11 15:54:39 +02:00
start, textarea.selectionEnd, 'end')
} else {
beep()
}
2022-09-06 23:37:06 +02:00
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,
})
// b.history[b.history.length] is virtual, and is represented
// either by textarea contents when it's currently being edited,
// or by b.input in all other cases.
let b = buffers.get(bufferCurrent)
b.history.push(textarea.value)
b.historyAt = b.history.length
textarea.value = ''
2022-09-07 14:07:14 +02:00
return true
},
previous: textarea => {
let b = buffers.get(bufferCurrent)
if (b === undefined)
return false
if (b.historyAt > 0) {
if (b.historyAt == b.history.length)
b.input = textarea.value
textarea.value = b.history[--b.historyAt]
} else {
beep()
}
return true
},
next: textarea => {
let b = buffers.get(bufferCurrent)
if (b === undefined)
return false
if (b.historyAt < b.history.length) {
if (++b.historyAt == b.history.length)
textarea.value = b.input
else
textarea.value = b.history[b.historyAt]
} else {
beep()
}
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
if (hasShortcutModifiers(event)) {
switch (event.key) {
case 'p':
handled = Input.previous(textarea)
break
case 'n':
handled = Input.next(textarea)
break
}
} else if (!event.altKey && !event.ctrlKey && !event.metaKey &&
!event.shiftKey) {
switch (event.keyCode) {
case 9:
handled = Input.complete(textarea)
break
case 13:
handled = Input.submit(textarea)
break
}
2022-09-07 14:07:14 +02:00
}
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),
m(Input),
])
},
}
2022-09-06 21:27:14 +02:00
window.addEventListener('load', () => m.mount(document.body, Main))
document.addEventListener('keydown', event => {
if (rpc.ws == undefined || !hasShortcutModifiers(event))
return
let names = undefined
switch (event.key) {
case 'h':
bufferToggleLog()
break
case 'a':
for (const [name, b] of buffers)
if (name !== bufferCurrent && b.newMessages) {
2022-09-10 19:33:39 +02:00
bufferActivate(name)
break
}
break
case '!':
for (const [name, b] of buffers)
if (name !== bufferCurrent && b.highlighted) {
2022-09-10 19:33:39 +02:00
bufferActivate(name)
break
}
break
case 'Tab':
if (bufferLast !== undefined)
bufferActivate(bufferLast)
break
case 'PageUp':
names = [...buffers.keys()]
for (let i = 0; i < names.length; i++)
if (names[i] === bufferCurrent) {
bufferActivate(names.at(--i))
break
}
break
case 'PageDown':
names = [...buffers.keys()]
for (let i = 0; i < names.length; i++)
if (names[i] === bufferCurrent) {
bufferActivate(names.at(++i) || names[0])
break
}
break
default:
2022-09-10 19:33:39 +02:00
return
}
2022-09-10 19:33:39 +02:00
event.preventDefault()
})