aboutsummaryrefslogtreecommitdiff
path: root/xP
diff options
context:
space:
mode:
authorPřemysl Eric Janouch <p@janouch.name>2022-09-06 17:17:32 +0200
committerPřemysl Eric Janouch <p@janouch.name>2022-09-06 19:41:05 +0200
commit8cd94b30f67f8eacf853fbc77bd60fb57a8262dc (patch)
tree2e4580e9aa0ce6c119741f6e9a2bde2a66d04bf2 /xP
parent2d30b6d1154771e194476e136ae341e46f6e08d0 (diff)
downloadxK-8cd94b30f67f8eacf853fbc77bd60fb57a8262dc.tar.gz
xK-8cd94b30f67f8eacf853fbc77bd60fb57a8262dc.tar.xz
xK-8cd94b30f67f8eacf853fbc77bd60fb57a8262dc.zip
xP: implement tab completion
Currently it only goes for the longest common prefix. Refactor WebSocket handling into an abstraction for our protocol. The Go code generater finally needed fixing.
Diffstat (limited to 'xP')
-rw-r--r--xP/public/xP.js293
1 files changed, 214 insertions, 79 deletions
diff --git a/xP/public/xP.js b/xP/public/xP.js
index f21683c..fd3a0ae 100644
--- a/xP/public/xP.js
+++ b/xP/public/xP.js
@@ -1,5 +1,175 @@
// 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 => {
+ reject()
+ this.ws = undefined
+ }
+ })
+ }
+
+ _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 -------------------------------------------------------
+
+// TODO: Probably reset state on disconnect, and indicate to user.
+let rpc = new RelayRpc(proxy)
+rpc.connect()
+ .then(result => {
+ rpc.send({command: 'Hello', version: 1})
+ })
+
+let buffers = new Map()
+let bufferCurrent = undefined
+
+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
+ // TODO: Somehow scroll to the end of it immediately.
+ // TODO: Focus the textarea.
+})
+
+rpc.addEventListener('BufferLine', event => {
+ let e = event.detail, b = buffers.get(e.bufferName)
+ if (b !== undefined)
+ 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 -----------------------------------------------------------------
@@ -33,69 +203,6 @@ function applyColor(fg, bg, inverse) {
return style
}
-// ---- Event processing -------------------------------------------------------
-
-// TODO: Probably reset state on disconnect, and indicate to user.
-let socket = new WebSocket(proxy)
-
-let commandSeq = 0
-function send(command) {
- socket.send(JSON.stringify({commandSeq: ++commandSeq, data: command}))
-}
-
-socket.onopen = function(event) {
- send({command: 'Hello', version: 1})
-}
-
-let buffers = new Map()
-let bufferCurrent = undefined
-
-socket.onmessage = function(event) {
- console.log(event.data)
-
- let e = JSON.parse(event.data).data
- switch (e.event) {
- case 'BufferUpdate':
- {
- let b = buffers.get(e.bufferName)
- if (b === undefined) {
- b = {lines: []}
- buffers.set(e.bufferName, b)
- }
- // TODO: Update any buffer properties.
- break
- }
- case 'BufferRename':
- buffers.set(e.new, buffers.get(e.bufferName))
- buffers.delete(e.bufferName)
- break
- case 'BufferRemove':
- buffers.delete(e.bufferName)
- break
- case 'BufferActivate':
- bufferCurrent = e.bufferName
- // TODO: Somehow scroll to the end of it immediately.
- // TODO: Focus the textarea.
- break
- case 'BufferLine':
- {
- let b = buffers.get(e.bufferName)
- if (b !== undefined)
- b.lines.push({when: e.when, rendition: e.rendition, items: e.items})
- break
- }
- case 'BufferClear':
- {
- let b = buffers.get(e.bufferName)
- if (b !== undefined)
- b.lines.length = 0
- break
- }
- }
-
- m.redraw()
-}
-
// ---- UI ---------------------------------------------------------------------
let BufferList = {
@@ -103,8 +210,8 @@ let BufferList = {
let items = []
buffers.forEach((b, name) => {
let attrs = {
- onclick: e => {
- send({command: 'BufferActivate', bufferName: name})
+ onclick: event => {
+ rpc.send({command: 'BufferActivate', bufferName: name})
},
}
if (name == bufferCurrent)
@@ -213,26 +320,54 @@ let Buffer = {
},
}
+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', {
rows: 1,
- onkeydown: e => {
- // TODO: And perhaps on other actions, too.
- send({command: 'Active'})
- if (e.keyCode !== 13)
- return
-
- send({
- command: 'BufferInput',
- bufferName: bufferCurrent,
- text: e.currentTarget.value,
- })
- e.preventDefault()
- e.currentTarget.value = ''
- },
+ onkeydown: onKeyDown,
})
},
}