From 8cd94b30f67f8eacf853fbc77bd60fb57a8262dc Mon Sep 17 00:00:00 2001
From: Přemysl Eric Janouch <p@janouch.name>
Date: Tue, 6 Sep 2022 17:17:32 +0200
Subject: 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.
---
 xP/public/xP.js | 293 +++++++++++++++++++++++++++++++++++++++++---------------
 1 file changed, 214 insertions(+), 79 deletions(-)

(limited to 'xP')

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,
 		})
 	},
 }
-- 
cgit v1.2.3-70-g09d2