diff --git a/src/app/line/line.less b/src/app/line/line.less index c05603ac9..9e36b9ae5 100644 --- a/src/app/line/line.less +++ b/src/app/line/line.less @@ -226,13 +226,12 @@ } .ts, - .termopts, - .renderer { + .termopts { display: flex; } - .renderer .renderer-icon { - margin-right: 0.5em; + .renderer-icon { + margin-right: 0.2em; } .metapart-mono { diff --git a/src/app/line/linecomps.tsx b/src/app/line/linecomps.tsx index 7c88f39f2..6bbbea434 100644 --- a/src/app/line/linecomps.tsx +++ b/src/app/line/linecomps.tsx @@ -291,10 +291,10 @@ class LineHeader extends React.Component<{ screen: LineContainerType; line: Line
|
-
+
- {renderer}
+
{renderer}
); diff --git a/src/plugins/core/ptydata.ts b/src/plugins/core/ptydata.ts index a5540e002..05f9aae16 100644 --- a/src/plugins/core/ptydata.ts +++ b/src/plugins/core/ptydata.ts @@ -129,4 +129,56 @@ class PacketDataBuffer extends PtyDataBuffer { } } -export { PtyDataBuffer, PacketDataBuffer }; +class JsonLinesDataBuffer extends PtyDataBuffer { + parsePos: number; + callback: (any) => void; + + constructor(callback: (any) => void) { + super(); + this.parsePos = 0; + this.callback = callback; + } + + reset(): void { + super.reset(); + this.parsePos = 0; + } + + processLine(line: string) { + if (line.length == 0) { + return; + } + let jsonVal: any = null; + try { + jsonVal = JSON.parse(line.trim()); + } catch (e) { + console.log("invalid json", line, e); + return; + } + if (jsonVal != null) { + this.callback(jsonVal); + } + } + + parseData() { + for (let i = this.parsePos; i < this.dataSize; i++) { + let ch = this.rawData[i]; + if (ch == NewLineCharCode) { + // line does *not* include the newline + let line = new TextDecoder().decode( + new Uint8Array(this.rawData.buffer, this.parsePos, i - this.parsePos) + ); + this.parsePos = i + 1; + this.processLine(line); + } + } + return; + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + super.receiveData(pos, data, reason); + this.parseData(); + } +} + +export { PtyDataBuffer, PacketDataBuffer, JsonLinesDataBuffer }; diff --git a/src/plugins/plugins.ts b/src/plugins/plugins.ts index 4a55a8cd7..7a14c9f4e 100644 --- a/src/plugins/plugins.ts +++ b/src/plugins/plugins.ts @@ -9,6 +9,7 @@ import { CSVRenderer } from "./csv/csv"; import { OpenAIRenderer, OpenAIRendererModel } from "./openai/openai"; import { SimplePdfRenderer } from "./pdf/pdf"; import { SimpleMediaRenderer } from "./media/media"; +import { WaveAppRenderer, WaveAppRendererModel } from "./waveapp/waveapp"; import { isBlank } from "@/util/util"; import { sprintf } from "sprintf-js"; @@ -100,6 +101,18 @@ const PluginConfigs: RendererPluginType[] = [ mimeTypes: ["video/*", "audio/*"], simpleComponent: SimpleMediaRenderer, }, + { + name: "waveapp", + rendererType: "full", + heightType: "pixels", + dataType: "model", + collapseType: "remove", + hidePrompt: true, + globalCss: null, + mimeTypes: ["application/x-waveapp"], + fullComponent: WaveAppRenderer, + modelCtor: () => new WaveAppRendererModel(), + }, ]; class PluginModelClass { diff --git a/src/plugins/waveapp/readme.md b/src/plugins/waveapp/readme.md new file mode 100644 index 000000000..d102da66d --- /dev/null +++ b/src/plugins/waveapp/readme.md @@ -0,0 +1 @@ +# WaveApp Plugin diff --git a/src/plugins/waveapp/waveapp.less b/src/plugins/waveapp/waveapp.less new file mode 100644 index 000000000..28483e44e --- /dev/null +++ b/src/plugins/waveapp/waveapp.less @@ -0,0 +1,6 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.waveapp-renderer { + line-height: normal; +} diff --git a/src/plugins/waveapp/waveapp.tsx b/src/plugins/waveapp/waveapp.tsx new file mode 100644 index 000000000..24955993c --- /dev/null +++ b/src/plugins/waveapp/waveapp.tsx @@ -0,0 +1,247 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import { debounce } from "throttle-debounce"; +import { boundMethod } from "autobind-decorator"; +import { JsonLinesDataBuffer } from "../core/ptydata"; +import { Markdown } from "@/elements"; +import * as ijson from "@/util/ijson"; + +import "./waveapp.less"; + +type WaveAppProps = { + lineId: string; + isSelected: boolean; + isFocused: boolean; + savedHeight: number; + initialData: any; + onPacket: (packetFn: (packet: any) => void) => void; +}; + +type WaveAppNode = { + tag: string; + props?: Record; + children?: (WaveAppNode | string)[]; +}; + +const TagMap: Record> = {}; + +function convertNodeToTag(node: WaveAppNode | string, idx?: number): JSX.Element | string { + if (node == null) { + return null; + } + if (idx == null) { + idx = 0; + } + if (typeof node === "string") { + return node; + } + let key = node.props?.key ?? "child-" + idx; + let TagComp = TagMap[node.tag]; + if (!TagComp) { + return ( +
+ Unknown tag:{node.tag} +
+ ); + } + return ; +} + +@mobxReact.observer +class WaveAppHtmlTag extends React.Component<{ node: WaveAppNode }, {}> { + render() { + let { tag, props, children } = this.props.node; + let divProps = {}; + if (props != null) { + for (let [key, val] of Object.entries(props)) { + if (key.startsWith("on")) { + divProps[key] = (e: any) => { + console.log("handler", key, val); + }; + } else { + divProps[key] = mobx.toJS(val); + } + } + } + let childrenComps = []; + if (children != null) { + for (let idx = 0; idx < children.length; idx++) { + let comp = convertNodeToTag(children[idx], idx); + if (comp != null) { + childrenComps.push(comp); + } + } + } + return React.createElement(tag, divProps, childrenComps); + } +} + +TagMap["div"] = WaveAppHtmlTag; +TagMap["b"] = WaveAppHtmlTag; +TagMap["i"] = WaveAppHtmlTag; +TagMap["p"] = WaveAppHtmlTag; +TagMap["span"] = WaveAppHtmlTag; +TagMap["a"] = WaveAppHtmlTag; +TagMap["h1"] = WaveAppHtmlTag; +TagMap["h2"] = WaveAppHtmlTag; +TagMap["h3"] = WaveAppHtmlTag; +TagMap["h4"] = WaveAppHtmlTag; +TagMap["h5"] = WaveAppHtmlTag; +TagMap["h6"] = WaveAppHtmlTag; +TagMap["ul"] = WaveAppHtmlTag; +TagMap["ol"] = WaveAppHtmlTag; +TagMap["li"] = WaveAppHtmlTag; + +class WaveAppRendererModel { + context: RendererContext; + opts: RendererOpts; + isDone: OV; + api: RendererModelContainerApi; + savedHeight: number; + loading: OV; + ptyDataSource: (termContext: TermContextUnion) => Promise; + packetData: JsonLinesDataBuffer; + rawCmd: WebCmd; + version: OV; + loadError: OV = mobx.observable.box(null, { name: "renderer-loadError" }); + data: OV = mobx.observable.box(null, { name: "renderer-data" }); + + constructor() { + this.packetData = new JsonLinesDataBuffer(this.packetCallback.bind(this)); + this.version = mobx.observable.box(0); + } + + initialize(params: RendererModelInitializeParams): void { + this.loading = mobx.observable.box(true, { name: "renderer-loading" }); + this.isDone = mobx.observable.box(params.isDone, { name: "renderer-isDone" }); + this.context = params.context; + this.opts = params.opts; + this.api = params.api; + this.savedHeight = params.savedHeight; + this.ptyDataSource = params.ptyDataSource; + this.rawCmd = params.rawCmd; + setTimeout(() => this.reload(0), 10); + } + + packetCallback(jsonVal: any) { + console.log("packet-callback", jsonVal); + try { + let data = this.data.get(); + let newData = ijson.applyCommand(data, jsonVal); + console.log("got newdata", newData); + if (newData != data) { + mobx.action(() => { + this.data.set(newData); + })(); + } + } catch (e) { + console.log("error adding data", e); + } + return; + } + + dispose(): void { + return; + } + + reload(delayMs: number): void { + mobx.action(() => { + this.loading.set(true); + this.loadError.set(null); + })(); + let rtnp = this.ptyDataSource(this.context); + if (rtnp == null) { + console.log("no promise returned from ptyDataSource (waveapp renderer)", this.context); + return; + } + rtnp.then((ptydata) => { + setTimeout(() => { + this.packetData.reset(); + this.receiveData(ptydata.pos, ptydata.data, "reload"); + mobx.action(() => { + this.loading.set(false); + })(); + }, delayMs); + }).catch((e) => { + console.log("error loading data", e); + mobx.action(() => { + this.loadError.set("error loading data: " + e); + })(); + }); + } + + giveFocus(): void { + return; + } + + updateOpts(opts: RendererOptsUpdate): void { + Object.assign(this.opts, opts); + } + + setIsDone(): void { + if (this.isDone.get()) { + return; + } + mobx.action(() => { + this.isDone.set(true); + })(); + } + + receiveData(pos: number, data: Uint8Array, reason?: string): void { + this.packetData.receiveData(pos, data, reason); + } + + updateHeight(newHeight: number): void { + if (this.savedHeight != newHeight) { + this.savedHeight = newHeight; + this.api.saveHeight(newHeight); + } + } +} + +@mobxReact.observer +class WaveAppRenderer extends React.Component<{ model: WaveAppRendererModel }, {}> { + renderError() { + const model = this.props.model; + return
{model.loadError.get()}
; + } + + render() { + let model = this.props.model; + let styleVal = null; + if (model.loading.get() && model.savedHeight >= 0 && model.isDone) { + styleVal = { + height: model.savedHeight, + maxHeight: model.opts.maxSize.height, + }; + } else { + styleVal = { + maxHeight: model.opts.maxSize.height, + }; + } + let version = model.version.get(); + let loadError = model.loadError.get(); + if (loadError != null) { + return ( +
+ {this.renderError()} +
+ ); + } + let node = model.data.get(); + if (node == null) { + return
; + } + return ( +
+ {convertNodeToTag(node)} +
+ ); + } +} + +export { WaveAppRendererModel, WaveAppRenderer };