From 46783ba315f470e3c166d09f99ad4ce4708a31a5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Thu, 17 Oct 2024 14:50:36 -0700 Subject: [PATCH] vdom 3 (#1033) --- cmd/generatego/main-generatego.go | 1 + cmd/test/test-main.go | 4 +- cmd/wsh/cmd/wshcmd-html.go | 81 ++-- frontend/app/block/block.tsx | 5 +- frontend/app/store/wshclient.ts | 4 + frontend/app/store/wshclientapi.ts | 15 + frontend/app/view/term/term-wsh.tsx | 52 +++ frontend/app/view/term/term.tsx | 275 +++++--------- frontend/app/view/term/vdom-model.tsx | 528 ++++++++++++++++++++++++++ frontend/app/view/term/vdom.tsx | 222 +++++++++-- frontend/types/custom.d.ts | 1 + frontend/types/gotypes.d.ts | 160 +++++++- frontend/util/keyutil.ts | 51 +++ pkg/tsgen/tsgen.go | 10 +- pkg/util/utilfn/compare.go | 89 +++++ pkg/vdom/cssparser/cssparser.go | 159 ++++++++ pkg/vdom/cssparser/cssparser_test.go | 81 ++++ pkg/vdom/vdom.go | 108 +++--- pkg/vdom/vdom_comp.go | 4 +- pkg/vdom/vdom_html.go | 184 +++++++-- pkg/vdom/vdom_root.go | 134 +++++-- pkg/vdom/vdom_test.go | 22 +- pkg/vdom/vdom_types.go | 195 ++++++++++ pkg/vdom/vdomclient/vdomclient.go | 199 ++++++++++ pkg/waveobj/metaconsts.go | 4 + pkg/waveobj/wtypemeta.go | 4 + pkg/wps/wpstypes.go | 1 + pkg/wshrpc/wshclient/wshclient.go | 19 + pkg/wshrpc/wshrpctypes.go | 13 + pkg/wshutil/wshrouter.go | 5 + 30 files changed, 2247 insertions(+), 383 deletions(-) create mode 100644 frontend/app/view/term/term-wsh.tsx create mode 100644 frontend/app/view/term/vdom-model.tsx create mode 100644 pkg/util/utilfn/compare.go create mode 100644 pkg/vdom/cssparser/cssparser.go create mode 100644 pkg/vdom/cssparser/cssparser_test.go create mode 100644 pkg/vdom/vdom_types.go create mode 100644 pkg/vdom/vdomclient/vdomclient.go diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index a409b7e31..a656f0cb6 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -29,6 +29,7 @@ func GenerateWshClient() error { "github.com/wavetermdev/waveterm/pkg/waveobj", "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", + "github.com/wavetermdev/waveterm/pkg/vdom", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go index 10d1933fe..aaac013d7 100644 --- a/cmd/test/test-main.go +++ b/cmd/test/test-main.go @@ -14,7 +14,7 @@ import ( func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := vdom.UseState(ctx, false) - var clickedDiv *vdom.Elem + var clickedDiv *vdom.VDomElem if clicked { clickedDiv = vdom.Bind(`
clicked
`, nil) } @@ -35,7 +35,7 @@ func Page(ctx context.Context, props map[string]any) any { } func Button(ctx context.Context, props map[string]any) any { - ref := vdom.UseRef(ctx, nil) + ref := vdom.UseVDomRef(ctx) clName, setClName := vdom.UseState(ctx, "button") vdom.UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 6bffc992e..968995aa8 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -4,9 +4,12 @@ package cmd import ( - "fmt" + "log" + "time" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) @@ -15,29 +18,61 @@ func init() { } var htmlCmd = &cobra.Command{ - Use: "html", - Hidden: true, - Short: "Launch a demo html-mode terminal", - Run: htmlRun, - PreRunE: preRunSetupRpcClient, + Use: "html", + Hidden: true, + Short: "launch demo vdom application", + RunE: htmlRun, } -func htmlRun(cmd *cobra.Command, args []string) { - defer wshutil.DoShutdown("normal exit", 0, true) - setTermHtmlMode() - for { - var buf [1]byte - _, err := WrappedStdin.Read(buf[:]) - if err != nil { - wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) - } - if buf[0] == 0x03 { - wshutil.DoShutdown("read Ctrl-C from stdin", 1, true) - break - } - if buf[0] == 'x' { - wshutil.DoShutdown("read 'x' from stdin", 0, true) - break - } +func MakeVDom() *vdom.VDomElem { + vdomStr := ` +
+

hello vdom world

+
| num[]
+
+ +
+
+ ` + elem := vdom.Bind(vdomStr, nil) + return elem +} + +func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { + if event.PropName == "clickinc" { + client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) + return } } + +func htmlRun(cmd *cobra.Command, args []string) error { + WriteStderr("running wsh html %q\n", RpcContext.BlockId) + + client, err := vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true}) + if err != nil { + return err + } + client.SetGlobalEventHandler(GlobalEventHandler) + log.Printf("created client: %v\n", client) + client.SetAtomVal("bgcolor", "#0000ff77") + client.SetAtomVal("text", "initial text") + client.SetAtomVal("num", 0) + client.SetRootElem(MakeVDom()) + err = client.CreateVDomContext() + if err != nil { + return err + } + log.Printf("created context\n") + go func() { + <-client.DoneCh + wshutil.DoShutdown("vdom closed by FE", 0, true) + }() + log.Printf("created vdom context\n") + go func() { + time.Sleep(5 * time.Second) + client.SetAtomVal("text", "updated text") + client.SendAsyncInitiation() + }() + <-client.DoneCh + return nil +} diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 3b5129039..1f7f9827a 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -36,7 +36,7 @@ type FullBlockProps = { function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel): ViewModel { if (blockView === "term") { - return makeTerminalModel(blockId); + return makeTerminalModel(blockId, nodeModel); } if (blockView === "preview") { return makePreviewModel(blockId, nodeModel); @@ -253,7 +253,7 @@ const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { const Block = memo((props: BlockProps) => { counterInc("render-Block"); - counterInc("render-Block-" + props.nodeModel.blockId.substring(0, 8)); + counterInc("render-Block-" + props.nodeModel?.blockId?.substring(0, 8)); const [blockData, loading] = useWaveObjectValue(makeORef("block", props.nodeModel.blockId)); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; @@ -264,6 +264,7 @@ const Block = memo((props: BlockProps) => { useEffect(() => { return () => { unregisterBlockComponentModel(props.nodeModel.blockId); + viewModel?.dispose?.(); }; }, []); if (loading || isBlank(props.nodeModel.blockId) || blockData == null) { diff --git a/frontend/app/store/wshclient.ts b/frontend/app/store/wshclient.ts index 93e7a7e38..3ec87ab00 100644 --- a/frontend/app/store/wshclient.ts +++ b/frontend/app/store/wshclient.ts @@ -18,6 +18,10 @@ class RpcResponseHelper { this.done = cmdMsg.reqid == null; } + getSource(): string { + return this.cmdMsg?.source; + } + sendResponse(msg: RpcMessage) { if (this.done || util.isBlank(this.cmdMsg.reqid)) { return; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 44a9923c4..733dd6df0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -217,6 +217,21 @@ class RpcApiType { return client.wshRpcCall("test", data, opts); } + // command "vdomasyncinitiation" [call] + VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomasyncinitiation", data, opts); + } + + // command "vdomcreatecontext" [call] + VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomcreatecontext", data, opts); + } + + // command "vdomrender" [call] + VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): Promise { + return client.wshRpcCall("vdomrender", data, opts); + } + // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { return client.wshRpcCall("webselector", data, opts); diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx new file mode 100644 index 000000000..1eaca2b92 --- /dev/null +++ b/frontend/app/view/term/term-wsh.tsx @@ -0,0 +1,52 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WOS } from "@/app/store/global"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { TermViewModel } from "@/app/view/term/term"; +import debug from "debug"; + +const dlog = debug("wave:vdom"); + +export class TermWshClient extends WshClient { + blockId: string; + model: TermViewModel; + + constructor(blockId: string, model: TermViewModel) { + super(makeFeBlockRouteId(blockId)); + this.blockId = blockId; + this.model = model; + } + + handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { + console.log("vdom-create", rh.getSource(), data); + this.model.vdomModel.reset(); + this.model.vdomModel.backendRoute = rh.getSource(); + if (!data.persist) { + const unsubFn = waveEventSubscribe({ + eventType: "route:gone", + scope: rh.getSource(), + handler: () => { + RpcApi.SetMetaCommand(this, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": null }, + }); + unsubFn(); + }, + }); + } + RpcApi.SetMetaCommand(this, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": "html" }, + }); + this.model.vdomModel.queueUpdate(true); + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + console.log("async-initiation", rh.getSource(), data); + this.model.vdomModel.queueUpdate(true); + } +} diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index bbf0ef28a..d68c0e6da 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -4,12 +4,15 @@ import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { TermWshClient } from "@/app/view/term/term-wsh"; import { VDomView } from "@/app/view/term/vdom"; +import { VDomModel } from "@/app/view/term/vdom-model"; +import { NodeModel } from "@/layout/index"; import { WOS, atoms, getConnStatusAtom, getSettingsKeyAtom, globalStore, useSettingsPrefixAtom } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; -import * as util from "@/util/util"; import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; @@ -19,102 +22,35 @@ import { computeTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; -const keyMap = { - Enter: "\r", - Backspace: "\x7f", - Tab: "\t", - Escape: "\x1b", - ArrowUp: "\x1b[A", - ArrowDown: "\x1b[B", - ArrowRight: "\x1b[C", - ArrowLeft: "\x1b[D", - Insert: "\x1b[2~", - Delete: "\x1b[3~", - Home: "\x1b[1~", - End: "\x1b[4~", - PageUp: "\x1b[5~", - PageDown: "\x1b[6~", -}; - -function keyboardEventToASCII(event: React.KeyboardEvent): string { - // check modifiers - // if no modifiers are set, just send the key - if (!event.altKey && !event.ctrlKey && !event.metaKey) { - if (event.key == null || event.key == "") { - return ""; - } - if (keyMap[event.key] != null) { - return keyMap[event.key]; - } - if (event.key.length == 1) { - return event.key; - } else { - console.log("not sending keyboard event", event.key, event); - } - } - // if meta or alt is set, there is no ASCII representation - if (event.metaKey || event.altKey) { - return ""; - } - // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value - if (event.ctrlKey) { - if ( - (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || - (event.key >= "a" && event.key <= "z") - ) { - const key = event.key.toUpperCase(); - return String.fromCharCode(key.charCodeAt(0) - 64); - } - } - return ""; -} - type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; }; -function vdomText(text: string): VDomElem { - return { - tag: "#text", - text: text, - }; -} - -const testVDom: VDomElem = { - id: "testid1", - tag: "div", - children: [ - { - id: "testh1", - tag: "h1", - children: [vdomText("Hello World")], - }, - { - id: "testp", - tag: "p", - children: [vdomText("This is a paragraph (from VDOM)")], - }, - ], -}; - class TermViewModel { viewType: string; + nodeModel: NodeModel; connected: boolean; termRef: React.RefObject; blockAtom: jotai.Atom; termMode: jotai.Atom; - htmlElemFocusRef: React.RefObject; blockId: string; viewIcon: jotai.Atom; viewName: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; + termWshClient: TermWshClient; + shellProcStatusRef: React.MutableRefObject; + vdomModel: VDomModel; - constructor(blockId: string) { + constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "term"; this.blockId = blockId; + this.termWshClient = new TermWshClient(blockId, this); + DefaultRouter.registerRoute(makeFeBlockRouteId(blockId), this.termWshClient); + this.nodeModel = nodeModel; + this.vdomModel = new VDomModel(blockId, nodeModel, null, this.termWshClient); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); @@ -152,6 +88,10 @@ class TermViewModel { }); } + dispose() { + DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); + } + giveFocus(): boolean { let termMode = globalStore.get(this.termMode); if (termMode == "term") { @@ -159,15 +99,70 @@ class TermViewModel { this.termRef.current.terminal.focus(); return true; } - } else { - if (this.htmlElemFocusRef?.current) { - this.htmlElemFocusRef.current.focus(); - return true; - } } return false; } + keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { + const blockAtom = WOS.getWaveObjectAtom(`block:${this.blockId}`); + const blockData = globalStore.get(blockAtom); + const newTermMode = blockData?.meta?.["term:mode"] == "html" ? null : "html"; + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": newTermMode }, + }); + return true; + } + const blockData = globalStore.get(this.blockAtom); + if (blockData.meta?.["term:mode"] == "html") { + return this.vdomModel?.globalKeydownHandler(waveEvent); + } + return false; + } + + handleTerminalKeydown(event: KeyboardEvent): boolean { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); + if (waveEvent.type != "keydown") { + return true; + } + if (this.keyDownHandler(waveEvent)) { + event.preventDefault(); + event.stopPropagation(); + return false; + } + // deal with terminal specific keybindings + if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { + const p = navigator.clipboard.readText(); + p.then((text) => { + this.termRef.current?.terminal.paste(text); + }); + event.preventDefault(); + event.stopPropagation(); + return false; + } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { + const sel = this.termRef.current?.terminal.getSelection(); + navigator.clipboard.writeText(sel); + event.preventDefault(); + event.stopPropagation(); + return false; + } + if (this.shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { + // restart + const tabId = globalStore.get(atoms.staticTabId); + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: this.blockId }); + prtn.catch((e) => console.log("error controller resync (enter)", this.blockId, e)); + return false; + } + const globalKeys = getAllGlobalKeyBindings(); + for (const key of globalKeys) { + if (keyutil.checkKeyPressed(waveEvent, key)) { + return false; + } + } + return true; + } + setTerminalTheme(themeName: string) { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("block", this.blockId), @@ -215,8 +210,8 @@ class TermViewModel { } } -function makeTerminalModel(blockId: string): TermViewModel { - return new TermViewModel(blockId); +function makeTerminalModel(blockId: string, nodeModel: NodeModel): TermViewModel { + return new TermViewModel(blockId, nodeModel); } interface TerminalViewProps { @@ -247,63 +242,22 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => }); const TerminalView = ({ blockId, model }: TerminalViewProps) => { - const viewRef = React.createRef(); + const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); const termRef = React.useRef(null); model.termRef = termRef; - const shellProcStatusRef = React.useRef(null); - const htmlElemFocusRef = React.useRef(null); - model.htmlElemFocusRef = htmlElemFocusRef; + const spstatusRef = React.useRef(null); + model.shellProcStatusRef = spstatusRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); + let termMode = blockData?.meta?.["term:mode"] ?? "term"; + if (termMode != "term" && termMode != "html") { + termMode = "term"; + } + const termModeRef = React.useRef(termMode); React.useEffect(() => { - function handleTerminalKeydown(event: KeyboardEvent): boolean { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (waveEvent.type != "keydown") { - return true; - } - // deal with terminal specific keybindings - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - event.preventDefault(); - event.stopPropagation(); - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; - } - if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:v")) { - const p = navigator.clipboard.readText(); - p.then((text) => { - termRef.current?.terminal.paste(text); - }); - event.preventDefault(); - event.stopPropagation(); - return false; - } else if (keyutil.checkKeyPressed(waveEvent, "Ctrl:Shift:c")) { - const sel = termRef.current?.terminal.getSelection(); - navigator.clipboard.writeText(sel); - event.preventDefault(); - event.stopPropagation(); - return false; - } - if (shellProcStatusRef.current != "running" && keyutil.checkKeyPressed(waveEvent, "Enter")) { - // restart - const tabId = globalStore.get(atoms.staticTabId); - const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { tabid: tabId, blockid: blockId }); - prtn.catch((e) => console.log("error controller resync (enter)", blockId, e)); - return false; - } - const globalKeys = getAllGlobalKeyBindings(); - for (const key of globalKeys) { - if (keyutil.checkKeyPressed(waveEvent, key)) { - return false; - } - } - return true; - } const fullConfig = globalStore.get(atoms.fullConfigAtom); const termTheme = computeTheme(fullConfig, blockData?.meta?.["term:theme"]); const themeCopy = { ...termTheme }; @@ -335,7 +289,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { scrollback: termScrollback, }, { - keydownHandler: handleTerminalKeydown, + keydownHandler: model.handleTerminalKeydown.bind(model), useWebGl: !termSettings?.["term:disablewebgl"], } ); @@ -352,29 +306,13 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { }; }, [blockId, termSettings]); - const handleHtmlKeyDown = (event: React.KeyboardEvent) => { - const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); - if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { - // reset term:mode - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", blockId), - meta: { "term:mode": null }, - }); - return false; + React.useEffect(() => { + if (termModeRef.current == "html" && termMode == "term") { + // focus the terminal + model.giveFocus(); } - const asciiVal = keyboardEventToASCII(event); - if (asciiVal.length == 0) { - return false; - } - const b64data = util.stringToBase64(asciiVal); - RpcApi.ControllerInputCommand(TabRpcClient, { blockid: blockId, inputdata64: b64data }); - return true; - }; - - let termMode = blockData?.meta?.["term:mode"] ?? "term"; - if (termMode != "term" && termMode != "html") { - termMode = "term"; - } + termModeRef.current = termMode; + }, [termMode]); // set intitial controller status, and then subscribe for updates React.useEffect(() => { @@ -382,7 +320,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { if (status == null) { return; } - shellProcStatusRef.current = status; + model.shellProcStatusRef.current = status; if (status == "running") { termRef.current?.setIsRunning(true); } else { @@ -418,26 +356,9 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
-
{ - if (htmlElemFocusRef.current != null) { - htmlElemFocusRef.current.focus(); - } - }} - > -
- {}} - /> -
+
- +
diff --git a/frontend/app/view/term/vdom-model.tsx b/frontend/app/view/term/vdom-model.tsx new file mode 100644 index 000000000..6eaf71858 --- /dev/null +++ b/frontend/app/view/term/vdom-model.tsx @@ -0,0 +1,528 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore, WOS } from "@/app/store/global"; +import { makeORef } from "@/app/store/wos"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { TermWshClient } from "@/app/view/term/term-wsh"; +import { NodeModel } from "@/layout/index"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import debug from "debug"; +import * as jotai from "jotai"; + +const dlog = debug("wave:vdom"); + +type AtomContainer = { + val: any; + beVal: any; + usedBy: Set; +}; + +type RefContainer = { + refFn: (elem: HTMLElement) => void; + vdomRef: VDomRef; + elem: HTMLElement; + updated: boolean; +}; + +function makeVDomIdMap(vdom: VDomElem, idMap: Map) { + if (vdom == null) { + return; + } + if (vdom.waveid != null) { + idMap.set(vdom.waveid, vdom); + } + if (vdom.children == null) { + return; + } + for (let child of vdom.children) { + makeVDomIdMap(child, idMap); + } +} + +function convertEvent(e: React.SyntheticEvent, fromProp: string): any { + if (e == null) { + return null; + } + if (fromProp == "onClick") { + return { type: "click" }; + } + if (fromProp == "onKeyDown") { + const waveKeyEvent = adaptFromReactOrNativeKeyEvent(e as React.KeyboardEvent); + return waveKeyEvent; + } + if (fromProp == "onFocus") { + return { type: "focus" }; + } + if (fromProp == "onBlur") { + return { type: "blur" }; + } + return { type: "unknown" }; +} + +export class VDomModel { + blockId: string; + nodeModel: NodeModel; + viewRef: React.RefObject; + vdomRoot: jotai.PrimitiveAtom = jotai.atom(); + atoms: Map = new Map(); // key is atomname + refs: Map = new Map(); // key is refid + batchedEvents: VDomEvent[] = []; + messages: VDomMessage[] = []; + needsInitialization: boolean = true; + needsResync: boolean = true; + vdomNodeVersion: WeakMap> = new WeakMap(); + compoundAtoms: Map> = new Map(); + rootRefId: string = crypto.randomUUID(); + termWshClient: TermWshClient; + backendRoute: string; + backendOpts: VDomBackendOpts; + shouldDispose: boolean; + disposed: boolean; + hasPendingRequest: boolean; + needsUpdate: boolean; + maxNormalUpdateIntervalMs: number = 100; + needsImmediateUpdate: boolean; + lastUpdateTs: number = 0; + queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + + constructor( + blockId: string, + nodeModel: NodeModel, + viewRef: React.RefObject, + termWshClient: TermWshClient + ) { + this.blockId = blockId; + this.nodeModel = nodeModel; + this.viewRef = viewRef; + this.termWshClient = termWshClient; + this.reset(); + } + + reset() { + globalStore.set(this.vdomRoot, null); + this.atoms.clear(); + this.refs.clear(); + this.batchedEvents = []; + this.messages = []; + this.needsResync = true; + this.needsInitialization = true; + this.vdomNodeVersion = new WeakMap(); + this.compoundAtoms.clear(); + this.rootRefId = crypto.randomUUID(); + this.backendRoute = null; + this.backendOpts = {}; + this.shouldDispose = false; + this.disposed = false; + this.hasPendingRequest = false; + this.needsUpdate = false; + this.maxNormalUpdateIntervalMs = 100; + this.needsImmediateUpdate = false; + this.lastUpdateTs = 0; + this.queuedUpdate = null; + } + + globalKeydownHandler(e: WaveKeyboardEvent): boolean { + if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { + this.shouldDispose = true; + this.queueUpdate(true); + return true; + } + if (this.backendOpts?.globalkeyboardevents) { + if (e.cmd || e.meta) { + return false; + } + this.batchedEvents.push({ + waveid: null, + propname: "onKeyDown", + eventdata: e, + }); + this.queueUpdate(); + return true; + } + return false; + } + + hasRefUpdates() { + for (let ref of this.refs.values()) { + if (ref.updated) { + return true; + } + } + return false; + } + + getRefUpdates(): VDomRefUpdate[] { + let updates: VDomRefUpdate[] = []; + for (let ref of this.refs.values()) { + if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { + const ru: VDomRefUpdate = { + refid: ref.vdomRef.refid, + hascurrent: ref.vdomRef.hascurrent, + }; + if (ref.vdomRef.trackposition && ref.elem != null) { + ru.position = { + offsetheight: ref.elem.offsetHeight, + offsetwidth: ref.elem.offsetWidth, + scrollheight: ref.elem.scrollHeight, + scrollwidth: ref.elem.scrollWidth, + scrolltop: ref.elem.scrollTop, + boundingclientrect: ref.elem.getBoundingClientRect(), + }; + } + updates.push(ru); + ref.updated = false; + } + } + return updates; + } + + queueUpdate(quick: boolean = false, delay: number = 10) { + this.needsUpdate = true; + let nowTs = Date.now(); + if (delay > this.maxNormalUpdateIntervalMs) { + delay = this.maxNormalUpdateIntervalMs; + } + if (quick) { + if (this.queuedUpdate) { + if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { + return; + } + clearTimeout(this.queuedUpdate.timeoutId); + this.queuedUpdate = null; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(true); + }, 0); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; + return; + } + if (this.queuedUpdate) { + return; + } + let lastUpdateDiff = nowTs - this.lastUpdateTs; + let timeoutMs: number = null; + if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { + // it has been a while since the last update, so use delay + timeoutMs = delay; + } else { + timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; + } + if (timeoutMs < delay) { + timeoutMs = delay; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(false); + }, timeoutMs); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; + } + + async _sendRenderRequest(force: boolean) { + this.queuedUpdate = null; + if (this.disposed) { + return; + } + if (this.hasPendingRequest) { + if (force) { + this.needsImmediateUpdate = true; + } + return; + } + if (!force && !this.needsUpdate) { + return; + } + if (this.backendRoute == null) { + console.log("vdom-model", "no backend route"); + return; + } + this.hasPendingRequest = true; + this.needsImmediateUpdate = false; + try { + const feUpdate = this.createFeUpdate(); + dlog("fe-update", feUpdate); + const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute }); + this.handleBackendUpdate(beUpdate); + } finally { + this.lastUpdateTs = Date.now(); + this.hasPendingRequest = false; + } + if (this.needsImmediateUpdate) { + this.queueUpdate(true); + } + } + + getAtomContainer(atomName: string): AtomContainer { + let container = this.atoms.get(atomName); + if (container == null) { + container = { + val: null, + beVal: null, + usedBy: new Set(), + }; + this.atoms.set(atomName, container); + } + return container; + } + + getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { + let container = this.refs.get(vdomRef.refid); + if (container == null) { + container = { + refFn: (elem: HTMLElement) => { + container.elem = elem; + const hasElem = elem != null; + if (vdomRef.hascurrent != hasElem) { + container.updated = true; + vdomRef.hascurrent = hasElem; + } + }, + vdomRef: vdomRef, + elem: null, + updated: false, + }; + this.refs.set(vdomRef.refid, container); + } + return container; + } + + tagUseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.add(waveId); + } + } + + tagUnuseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.delete(waveId); + } + } + + getVDomNodeVersionAtom(vdom: VDomElem) { + let atom = this.vdomNodeVersion.get(vdom); + if (atom == null) { + atom = jotai.atom(0); + this.vdomNodeVersion.set(vdom, atom); + } + return atom; + } + + incVDomNodeVersion(vdom: VDomElem) { + if (vdom == null) { + return; + } + const atom = this.getVDomNodeVersionAtom(vdom); + globalStore.set(atom, globalStore.get(atom) + 1); + } + + addErrorMessage(message: string) { + this.messages.push({ + messagetype: "error", + message: message, + }); + } + + handleRenderUpdates(update: VDomBackendUpdate, idMap: Map) { + if (!update.renderupdates) { + return; + } + for (let renderUpdate of update.renderupdates) { + if (renderUpdate.updatetype == "root") { + globalStore.set(this.vdomRoot, renderUpdate.vdom); + continue; + } + if (renderUpdate.updatetype == "append") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + parent.children.push(renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "replace") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children[renderUpdate.index] = renderUpdate.vdom; + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "remove") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 1); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "insert") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); + } + } + + setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map) { + dlog("setAtomValue", atomName, value, fromBe); + let container = this.getAtomContainer(atomName); + container.val = value; + if (fromBe) { + container.beVal = value; + } + for (let id of container.usedBy) { + this.incVDomNodeVersion(idMap.get(id)); + } + } + + handleStateSync(update: VDomBackendUpdate, idMap: Map) { + if (update.statesync == null) { + return; + } + for (let sync of update.statesync) { + this.setAtomValue(sync.atom, sync.value, true, idMap); + } + } + + getRefElem(refId: string): HTMLElement { + if (refId == this.rootRefId) { + return this.viewRef.current; + } + const ref = this.refs.get(refId); + return ref?.elem; + } + + handleRefOperations(update: VDomBackendUpdate, idMap: Map) { + if (update.refoperations == null) { + return; + } + for (let refOp of update.refoperations) { + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); + continue; + } + if (refOp.op == "focus") { + if (elem == null) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); + continue; + } + try { + elem.focus(); + } catch (e) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); + } + } else { + this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); + } + } + } + + handleBackendUpdate(update: VDomBackendUpdate) { + if (update == null) { + return; + } + const idMap = new Map(); + const vdomRoot = globalStore.get(this.vdomRoot); + if (update.opts != null) { + this.backendOpts = update.opts; + } + makeVDomIdMap(vdomRoot, idMap); + this.handleRenderUpdates(update, idMap); + this.handleStateSync(update, idMap); + this.handleRefOperations(update, idMap); + if (update.messages) { + for (let message of update.messages) { + console.log("vdom-message", this.blockId, message.messagetype, message.message); + if (message.stacktrace) { + console.log("vdom-message-stacktrace", message.stacktrace); + } + } + } + } + + callVDomFunc(fnDecl: VDomFunc, e: any, compId: string, propName: string) { + const eventData = convertEvent(e, propName); + if (fnDecl.globalevent) { + const waveEvent: VDomEvent = { + waveid: null, + propname: fnDecl.globalevent, + eventdata: eventData, + }; + this.batchedEvents.push(waveEvent); + } else { + const vdomEvent: VDomEvent = { + waveid: compId, + propname: propName, + eventdata: eventData, + }; + this.batchedEvents.push(vdomEvent); + } + this.queueUpdate(); + } + + createFeUpdate(): VDomFrontendUpdate { + const blockORef = makeORef("block", this.blockId); + const blockAtom = WOS.getWaveObjectAtom(blockORef); + const blockData = globalStore.get(blockAtom); + const isBlockFocused = globalStore.get(this.nodeModel.isFocused); + const renderContext: VDomRenderContext = { + blockid: this.blockId, + focused: isBlockFocused, + width: this.viewRef?.current?.offsetWidth ?? 0, + height: this.viewRef?.current?.offsetHeight ?? 0, + rootrefid: this.rootRefId, + background: false, + }; + const feUpdate: VDomFrontendUpdate = { + type: "frontendupdate", + ts: Date.now(), + blockid: this.blockId, + initialize: this.needsInitialization, + rendercontext: renderContext, + dispose: this.shouldDispose, + resync: this.needsResync, + events: this.batchedEvents, + refupdates: this.getRefUpdates(), + }; + this.needsResync = false; + this.needsInitialization = false; + this.batchedEvents = []; + if (this.shouldDispose) { + this.disposed = true; + } + return feUpdate; + } +} diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx index 4e87ed46d..718a392ab 100644 --- a/frontend/app/view/term/vdom.tsx +++ b/frontend/app/view/term/vdom.tsx @@ -1,9 +1,25 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { VDomModel } from "@/app/view/term/vdom-model"; +import { NodeModel } from "@/layout/index"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { useAtomValueSafe } from "@/util/util"; +import debug from "debug"; +import * as jotai from "jotai"; import * as React from "react"; +const TextTag = "#text"; +const FragmentTag = "#fragment"; +const WaveTextTag = "wave:text"; +const WaveNullTag = "wave:null"; + +const VDomObjType_Ref = "ref"; +const VDomObjType_Binding = "binding"; +const VDomObjType_Func = "func"; + +const dlog = debug("wave:vdom"); + const AllowedTags: { [tagName: string]: boolean } = { div: true, b: true, @@ -30,38 +46,38 @@ const AllowedTags: { [tagName: string]: boolean } = { form: true, }; -function convertVDomFunc(fnDecl: VDomFuncType, compId: string, propName: string): (e: any) => void { +function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { return (e: any) => { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { let waveEvent = adaptFromReactOrNativeKeyEvent(e); - for (let keyDesc of fnDecl["#keys"]) { + for (let keyDesc of fnDecl.keys || []) { if (checkKeyPressed(waveEvent, keyDesc)) { e.preventDefault(); e.stopPropagation(); - callFunc(e, compId, propName); + model.callVDomFunc(fnDecl, e, compId, propName); return; } } return; } - if (fnDecl["#preventDefault"]) { + if (fnDecl.preventdefault) { e.preventDefault(); } - if (fnDecl["#stopPropagation"]) { + if (fnDecl.stoppropagation) { e.stopPropagation(); } - callFunc(e, compId, propName); + model.callVDomFunc(fnDecl, e, compId, propName); }; } -function convertElemToTag(elem: VDomElem): JSX.Element | string { +function convertElemToTag(elem: VDomElem, model: VDomModel): JSX.Element | string { if (elem == null) { return null; } - if (elem.tag == "#text") { + if (elem.tag == TextTag) { return elem.text; } - return React.createElement(VDomTag, { elem: elem, key: elem.id }); + return React.createElement(VDomTag, { key: elem.waveid, elem, model }); } function isObject(v: any): boolean { @@ -72,19 +88,35 @@ function isArray(v: any): boolean { return Array.isArray(v); } -function callFunc(e: any, compId: string, propName: string) { - console.log("callfunc", compId, propName); -} - -function updateRefFunc(elem: any, ref: VDomRefType) { - console.log("updateref", ref["#ref"], elem); -} - -function VDomTag({ elem }: { elem: VDomElem }) { - if (!AllowedTags[elem.tag]) { - return
{"Invalid Tag <" + elem.tag + ">"}
; +function resolveBinding(binding: VDomBinding, model: VDomModel): [any, string[]] { + const bindName = binding.bind; + if (bindName == null || bindName == "") { + return [null, []]; + } + // for now we only recognize $.[atomname] bindings + if (!bindName.startsWith("$.")) { + return [null, []]; + } + const atomName = bindName.substring(2); + if (atomName == "") { + return [null, []]; + } + const atom = model.getAtomContainer(atomName); + if (atom == null) { + return [null, []]; + } + return [atom.val, [atomName]]; +} + +type GenericPropsType = { [key: string]: any }; + +// returns props, and a set of atom keys used in the props +function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set] { + let props: GenericPropsType = {}; + let atomKeys = new Set(); + if (elem.props == null) { + return [props, atomKeys]; } - let props = {}; for (let key in elem.props) { let val = elem.props[key]; if (val == null) { @@ -94,35 +126,149 @@ function VDomTag({ elem }: { elem: VDomElem }) { if (val == null) { continue; } - if (isObject(val) && "#ref" in val) { - props[key] = (elem: HTMLElement) => { - updateRefFunc(elem, val); - }; + if (isObject(val) && val.type == VDomObjType_Ref) { + const valRef = val as VDomRef; + const refContainer = model.getOrCreateRefContainer(valRef); + props[key] = refContainer.refFn; } continue; } - if (isObject(val) && "#func" in val) { - props[key] = convertVDomFunc(val, elem.id, key); + if (isObject(val) && val.type == VDomObjType_Func) { + const valFunc = val as VDomFunc; + props[key] = convertVDomFunc(model, valFunc, elem.waveid, key); continue; } + if (isObject(val) && val.type == VDomObjType_Binding) { + const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model); + props[key] = propVal; + for (let atomDep of atomDeps) { + atomKeys.add(atomDep); + } + continue; + } + if (key == "style" && isObject(val)) { + // assuming the entire style prop wasn't bound, look through the individual keys and bind them + for (let styleKey in val) { + let styleVal = val[styleKey]; + if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) { + const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model); + val[styleKey] = stylePropVal; + for (let styleAtomDep of styleAtomDeps) { + atomKeys.add(styleAtomDep); + } + } + } + // fallthrough to set props[key] = val + } + props[key] = val; } + return [props, atomKeys]; +} + +function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] { let childrenComps: (string | JSX.Element)[] = []; - if (elem.children) { - for (let child of elem.children) { - if (child == null) { - continue; - } - childrenComps.push(convertElemToTag(child)); - } - } - if (elem.tag == "#fragment") { + if (elem.children == null) { return childrenComps; } + for (let child of elem.children) { + if (child == null) { + continue; + } + childrenComps.push(convertElemToTag(child, model)); + } + return childrenComps; +} + +function stringSetsEqual(set1: Set, set2: Set): boolean { + if (set1.size != set2.size) { + return false; + } + for (let elem of set1) { + if (!set2.has(elem)) { + return false; + } + } + return true; +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); + const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); + let [props, atomKeys] = convertProps(elem, model); + React.useEffect(() => { + if (stringSetsEqual(atomKeys, oldAtomKeys)) { + return; + } + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + model.tagUseAtoms(elem.waveid, atomKeys); + setOldAtomKeys(atomKeys); + }, [atomKeys]); + React.useEffect(() => { + return () => { + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + }; + }, []); + + if (elem.tag == WaveNullTag) { + return null; + } + if (elem.tag == WaveTextTag) { + return props.text; + } + if (!AllowedTags[elem.tag]) { + return
{"Invalid Tag <" + elem.tag + ">"}
; + } + let childrenComps = convertChildren(elem, model); + dlog("children", childrenComps); + if (elem.tag == FragmentTag) { + return childrenComps; + } + props.key = "e-" + elem.waveid; return React.createElement(elem.tag, props, childrenComps); } -function VDomView({ rootNode }: { rootNode: VDomElem }) { - let rtn = convertElemToTag(rootNode); +function vdomText(text: string): VDomElem { + return { + tag: "#text", + text: text, + }; +} + +const testVDom: VDomElem = { + waveid: "testid1", + tag: "div", + children: [ + { + waveid: "testh1", + tag: "h1", + children: [vdomText("Hello World")], + }, + { + waveid: "testp", + tag: "p", + children: [vdomText("This is a paragraph (from VDOM)")], + }, + ], +}; + +function VDomView({ + blockId, + nodeModel, + viewRef, + model, +}: { + blockId: string; + nodeModel: NodeModel; + viewRef: React.RefObject; + model: VDomModel; +}) { + let rootNode = useAtomValueSafe(model?.vdomRoot); + if (!model || viewRef.current == null || rootNode == null) { + return null; + } + dlog("render", rootNode); + model.viewRef = viewRef; + let rtn = convertElemToTag(rootNode, model); return
{rtn}
; } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index bc9d44164..22139cd81 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -274,6 +274,7 @@ declare global { getSettingsMenuItems?: () => ContextMenuItem[]; giveFocus?: () => boolean; keyDownHandler?: (e: WaveKeyboardEvent) => boolean; + dispose?: () => void; } type UpdaterStatus = "up-to-date" | "checking" | "downloading" | "ready" | "error" | "installing"; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 740c38dd8..971bdc96a 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -192,6 +192,16 @@ declare global { count: number; }; + // vdom.DomRect + type DomRect = { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + }; + // waveobj.FileDef type FileDef = { filetype?: string; @@ -324,6 +334,9 @@ declare global { "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; + "vdom:*"?: boolean; + "vdom:initialized"?: boolean; + "vdom:correlationid"?: string; count?: number; }; @@ -588,27 +601,150 @@ declare global { checkboxstat?: boolean; }; - // vdom.Elem + // vdom.VDomAsyncInitiationRequest + type VDomAsyncInitiationRequest = { + type: "asyncinitiationrequest"; + ts: number; + blockid?: string; + }; + + // vdom.VDomBackendOpts + type VDomBackendOpts = { + closeonctrlc?: boolean; + globalkeyboardevents?: boolean; + }; + + // vdom.VDomBackendUpdate + type VDomBackendUpdate = { + type: "backendupdate"; + ts: number; + blockid: string; + opts?: VDomBackendOpts; + renderupdates?: VDomRenderUpdate[]; + statesync?: VDomStateSync[]; + refoperations?: VDomRefOperation[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomBinding + type VDomBinding = { + type: "binding"; + bind: string; + }; + + // vdom.VDomCreateContext + type VDomCreateContext = { + type: "createcontext"; + ts: number; + meta?: MetaType; + newblock?: boolean; + persist?: boolean; + }; + + // vdom.VDomElem type VDomElem = { - id?: string; + waveid?: string; tag: string; props?: {[key: string]: any}; children?: VDomElem[]; text?: string; }; - // vdom.VDomFuncType - type VDomFuncType = { - #func: string; - #stopPropagation?: boolean; - #preventDefault?: boolean; - #keys?: string[]; + // vdom.VDomEvent + type VDomEvent = { + waveid: string; + propname: string; + eventdata: any; }; - // vdom.VDomRefType - type VDomRefType = { - #ref: string; - current: any; + // vdom.VDomFrontendUpdate + type VDomFrontendUpdate = { + type: "frontendupdate"; + ts: number; + blockid: string; + correlationid?: string; + initialize?: boolean; + dispose?: boolean; + resync?: boolean; + rendercontext?: VDomRenderContext; + events?: VDomEvent[]; + statesync?: VDomStateSync[]; + refupdates?: VDomRefUpdate[]; + messages?: VDomMessage[]; + }; + + // vdom.VDomFunc + type VDomFunc = { + type: "func"; + stoppropagation?: boolean; + preventdefault?: boolean; + globalevent?: string; + keys?: string[]; + }; + + // vdom.VDomMessage + type VDomMessage = { + messagetype: string; + message: string; + stacktrace?: string; + params?: any[]; + }; + + // vdom.VDomRef + type VDomRef = { + type: "ref"; + refid: string; + trackposition?: boolean; + position?: VDomRefPosition; + hascurrent?: boolean; + }; + + // vdom.VDomRefOperation + type VDomRefOperation = { + refid: string; + op: string; + params?: any[]; + }; + + // vdom.VDomRefPosition + type VDomRefPosition = { + offsetheight: number; + offsetwidth: number; + scrollheight: number; + scrollwidth: number; + scrolltop: number; + boundingclientrect: DomRect; + }; + + // vdom.VDomRefUpdate + type VDomRefUpdate = { + refid: string; + hascurrent: boolean; + position?: VDomRefPosition; + }; + + // vdom.VDomRenderContext + type VDomRenderContext = { + blockid: string; + focused: boolean; + width: number; + height: number; + rootrefid: string; + background?: boolean; + }; + + // vdom.VDomRenderUpdate + type VDomRenderUpdate = { + updatetype: "root"|"append"|"replace"|"remove"|"insert"; + waveid?: string; + vdom: VDomElem; + index?: number; + }; + + // vdom.VDomStateSync + type VDomStateSync = { + atom: string; + value: any; }; type WSCommandType = { diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index b1fb5ccdf..daf50840e 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -241,6 +241,56 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { return rtn; } +const keyMap = { + Enter: "\r", + Backspace: "\x7f", + Tab: "\t", + Escape: "\x1b", + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowRight: "\x1b[C", + ArrowLeft: "\x1b[D", + Insert: "\x1b[2~", + Delete: "\x1b[3~", + Home: "\x1b[1~", + End: "\x1b[4~", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", +}; + +function keyboardEventToASCII(event: WaveKeyboardEvent): string { + // check modifiers + // if no modifiers are set, just send the key + if (!event.alt && !event.control && !event.meta) { + if (event.key == null || event.key == "") { + return ""; + } + if (keyMap[event.key] != null) { + return keyMap[event.key]; + } + if (event.key.length == 1) { + return event.key; + } else { + console.log("not sending keyboard event", event.key, event); + } + } + // if meta or alt is set, there is no ASCII representation + if (event.meta || event.alt) { + return ""; + } + // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value + if (event.control) { + if ( + (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || + (event.key >= "a" && event.key <= "z") + ) { + const key = event.key.toUpperCase(); + return String.fromCharCode(key.charCodeAt(0) - 64); + } + } + return ""; +} + export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, @@ -248,6 +298,7 @@ export { getKeyUtilPlatform, isCharacterKeyEvent, isInputEvent, + keyboardEventToASCII, keydownWrapper, parseKeyDescription, setKeyUtilPlatform, diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index cbde00941..503e4e890 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -42,9 +42,13 @@ var ExtraTypes = []any{ wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, userinput.UserInputRequest{}, - vdom.Elem{}, - vdom.VDomFuncType{}, - vdom.VDomRefType{}, + vdom.VDomCreateContext{}, + vdom.VDomElem{}, + vdom.VDomFunc{}, + vdom.VDomRef{}, + vdom.VDomBinding{}, + vdom.VDomFrontendUpdate{}, + vdom.VDomBackendUpdate{}, waveobj.MetaTSType{}, } diff --git a/pkg/util/utilfn/compare.go b/pkg/util/utilfn/compare.go new file mode 100644 index 000000000..d9c96a24e --- /dev/null +++ b/pkg/util/utilfn/compare.go @@ -0,0 +1,89 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +import ( + "reflect" +) + +// this is a shallow equal, but with special handling for numeric types +// it will up convert to float64 and compare +func JsonValEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + typeA := reflect.TypeOf(a) + typeB := reflect.TypeOf(b) + if typeA == typeB && typeA.Comparable() { + return a == b + } + if IsNumericType(a) && IsNumericType(b) { + return CompareAsFloat64(a, b) + } + if typeA != typeB { + return false + } + // for slices and maps, compare their pointers + valA := reflect.ValueOf(a) + valB := reflect.ValueOf(b) + switch valA.Kind() { + case reflect.Slice, reflect.Map: + return valA.Pointer() == valB.Pointer() + } + return false +} + +// Helper to check if a value is a numeric type +func IsNumericType(val any) bool { + switch val.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +// Helper to handle numeric comparisons as float64 +func CompareAsFloat64(a, b any) bool { + valA, okA := ToFloat64(a) + valB, okB := ToFloat64(b) + return okA && okB && valA == valB +} + +// Convert various numeric types to float64 for comparison +func ToFloat64(val any) (float64, bool) { + switch v := val.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} diff --git a/pkg/vdom/cssparser/cssparser.go b/pkg/vdom/cssparser/cssparser.go new file mode 100644 index 000000000..2960d61d5 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser.go @@ -0,0 +1,159 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "strings" + "unicode" +) + +type Parser struct { + Input string + Pos int + Length int + InQuote bool + QuoteChar rune + OpenParens int + Debug bool +} + +func MakeParser(input string) *Parser { + return &Parser{ + Input: input, + Length: len(input), + } +} + +func (p *Parser) Parse() (map[string]string, error) { + result := make(map[string]string) + lastProp := "" + for { + p.skipWhitespace() + if p.eof() { + break + } + propName, err := p.parseIdentifierColon(lastProp) + if err != nil { + return nil, err + } + lastProp = propName + p.skipWhitespace() + value, err := p.parseValue(propName) + if err != nil { + return nil, err + } + result[propName] = value + p.skipWhitespace() + if p.eof() { + break + } + if !p.expectChar(';') { + break + } + } + p.skipWhitespace() + if !p.eof() { + return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1) + } + return result, nil +} + +func (p *Parser) parseIdentifierColon(lastProp string) (string, error) { + start := p.Pos + for !p.eof() { + c := p.peekChar() + if isIdentChar(c) || c == '-' { + p.advance() + } else { + break + } + } + attrName := p.Input[start:p.Pos] + p.skipWhitespace() + if p.eof() { + return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1) + } + if attrName == "" { + return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1) + } + if !p.expectChar(':') { + return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1) + } + return attrName, nil +} + +func (p *Parser) parseValue(propName string) (string, error) { + start := p.Pos + quotePos := 0 + parenPosStack := make([]int, 0) + for !p.eof() { + c := p.peekChar() + if p.InQuote { + if c == p.QuoteChar { + p.InQuote = false + } else if c == '\\' { + p.advance() + } + } else { + if c == '"' || c == '\'' { + p.InQuote = true + p.QuoteChar = c + quotePos = p.Pos + } else if c == '(' { + p.OpenParens++ + parenPosStack = append(parenPosStack, p.Pos) + } else if c == ')' { + if p.OpenParens == 0 { + return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1) + } + p.OpenParens-- + parenPosStack = parenPosStack[:len(parenPosStack)-1] + } else if c == ';' && p.OpenParens == 0 { + break + } + } + p.advance() + } + if p.eof() && p.InQuote { + return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1) + } + if p.eof() && p.OpenParens > 0 { + return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1) + } + return strings.TrimSpace(p.Input[start:p.Pos]), nil +} + +func isIdentChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func (p *Parser) skipWhitespace() { + for !p.eof() && unicode.IsSpace(p.peekChar()) { + p.advance() + } +} + +func (p *Parser) expectChar(expected rune) bool { + if !p.eof() && p.peekChar() == expected { + p.advance() + return true + } + return false +} + +func (p *Parser) peekChar() rune { + if p.Pos >= p.Length { + return 0 + } + return rune(p.Input[p.Pos]) +} + +func (p *Parser) advance() { + p.Pos++ +} + +func (p *Parser) eof() bool { + return p.Pos >= p.Length +} diff --git a/pkg/vdom/cssparser/cssparser_test.go b/pkg/vdom/cssparser/cssparser_test.go new file mode 100644 index 000000000..669d05aa2 --- /dev/null +++ b/pkg/vdom/cssparser/cssparser_test.go @@ -0,0 +1,81 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "log" + "testing" +) + +func compareMaps(a, b map[string]string) error { + if len(a) != len(b) { + return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b)) + } + for k, v := range a { + if b[k] != v { + return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k]) + } + } + return nil +} + +func TestParse1(t *testing.T) { + style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";` + p := MakeParser(style) + parsed, err := p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected := map[string]string{ + "background": `url("example;with;semicolons.jpg")`, + "color": "red", + "margin-right": "5px", + "content": `"hello;world"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } + + style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";` + p = MakeParser(style) + parsed, err = p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected = map[string]string{ + "margin-right": `calc(10px + 5px)`, + "color": "red", + "font-family": `"Arial"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } +} + +func TestParserErrors(t *testing.T) { + style := `hello more: bad;` + p := MakeParser(style) + _, err := p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `background: url("example.jpg` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `foo: url(...` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) +} diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index 4cab277b7..0503c46b5 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -15,35 +15,6 @@ import ( // ReactNode types = nil | string | Elem -const TextTag = "#text" -const FragmentTag = "#fragment" - -const ChildrenPropKey = "children" -const KeyPropKey = "key" - -// doubles as VDOM structure -type Elem struct { - Id string `json:"id,omitempty"` // used for vdom - Tag string `json:"tag"` - Props map[string]any `json:"props,omitempty"` - Children []Elem `json:"children,omitempty"` - Text string `json:"text,omitempty"` -} - -type VDomRefType struct { - RefId string `json:"#ref"` - Current any `json:"current"` -} - -// can be used to set preventDefault/stopPropagation -type VDomFuncType struct { - Fn any `json:"-"` // the actual function to call (called via reflection) - FuncType string `json:"#func"` - StopPropagation bool `json:"#stopPropagation,omitempty"` - PreventDefault bool `json:"#preventDefault,omitempty"` - Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" -} - // generic hook structure type Hook struct { Init bool // is initialized @@ -56,7 +27,7 @@ type Hook struct { type CFunc = func(ctx context.Context, props map[string]any) any -func (e *Elem) Key() string { +func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { return "" @@ -68,8 +39,8 @@ func (e *Elem) Key() string { return "" } -func TextElem(text string) Elem { - return Elem{Tag: TextTag, Text: text} +func TextElem(text string) VDomElem { + return VDomElem{Tag: TextTag, Text: text} } func mergeProps(props *map[string]any, newProps map[string]any) { @@ -85,8 +56,8 @@ func mergeProps(props *map[string]any, newProps map[string]any) { } } -func E(tag string, parts ...any) *Elem { - rtn := &Elem{Tag: tag} +func E(tag string, parts ...any) *VDomElem { + rtn := &VDomElem{Tag: tag} for _, part := range parts { if part == nil { continue @@ -135,19 +106,44 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { } setVal := func(newVal T) { hookVal.Val = newVal - vc.Root.AddRenderWork(vc.Comp.Id) + vc.Root.AddRenderWork(vc.Comp.WaveId) } return rtnVal, setVal } -func UseRef(ctx context.Context, initialVal any) *VDomRefType { +func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { vc, hookVal := getHookFromCtx(ctx) if !hookVal.Init { hookVal.Init = true - refId := vc.Comp.Id + ":" + strconv.Itoa(hookVal.Idx) - hookVal.Val = &VDomRefType{RefId: refId, Current: initialVal} + closedWaveId := vc.Comp.WaveId + hookVal.UnmountFn = func() { + atom := vc.Root.GetAtom(atomName) + delete(atom.UsedBy, closedWaveId) + } } - refVal, ok := hookVal.Val.(*VDomRefType) + atom := vc.Root.GetAtom(atomName) + atom.UsedBy[vc.Comp.WaveId] = true + atomVal, ok := atom.Val.(T) + if !ok { + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) + } + setVal := func(newVal T) { + atom.Val = newVal + for waveId := range atom.UsedBy { + vc.Root.AddRenderWork(waveId) + } + } + return atomVal, setVal +} + +func UseVDomRef(ctx context.Context) *VDomRef { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) + hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} + } + refVal, ok := hookVal.Val.(*VDomRef) if !ok { panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") } @@ -159,7 +155,7 @@ func UseId(ctx context.Context) string { if vc == nil { panic("UseId must be called within a component (no context)") } - return vc.Comp.Id + return vc.Comp.WaveId } func depsEqual(deps1 []any, deps2 []any) bool { @@ -181,7 +177,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) return } if depsEqual(hookVal.Deps, deps) { @@ -189,7 +185,7 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { } hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) } func numToString[T any](value T) (string, bool) { @@ -207,24 +203,24 @@ func numToString[T any](value T) (string, bool) { } } -func partToElems(part any) []Elem { +func partToElems(part any) []VDomElem { if part == nil { return nil } switch part := part.(type) { case string: - return []Elem{TextElem(part)} - case *Elem: + return []VDomElem{TextElem(part)} + case *VDomElem: if part == nil { return nil } - return []Elem{*part} - case Elem: - return []Elem{part} - case []Elem: + return []VDomElem{*part} + case VDomElem: + return []VDomElem{part} + case []VDomElem: return part - case []*Elem: - var rtn []Elem + case []*VDomElem: + var rtn []VDomElem for _, e := range part { if e == nil { continue @@ -235,11 +231,11 @@ func partToElems(part any) []Elem { } sval, ok := numToString(part) if ok { - return []Elem{TextElem(sval)} + return []VDomElem{TextElem(sval)} } partVal := reflect.ValueOf(part) if partVal.Kind() == reflect.Slice { - var rtn []Elem + var rtn []VDomElem for i := 0; i < partVal.Len(); i++ { subPart := partVal.Index(i).Interface() rtn = append(rtn, partToElems(subPart)...) @@ -248,14 +244,14 @@ func partToElems(part any) []Elem { } stringer, ok := part.(fmt.Stringer) if ok { - return []Elem{TextElem(stringer.String())} + return []VDomElem{TextElem(stringer.String())} } jsonStr, jsonErr := json.Marshal(part) if jsonErr == nil { - return []Elem{TextElem(string(jsonStr))} + return []VDomElem{TextElem(string(jsonStr))} } typeText := "invalid:" + reflect.TypeOf(part).String() - return []Elem{TextElem(typeText)} + return []VDomElem{TextElem(typeText)} } func isWaveTag(tag string) bool { diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go index 3b51701a5..118375b36 100644 --- a/pkg/vdom/vdom_comp.go +++ b/pkg/vdom/vdom_comp.go @@ -13,10 +13,10 @@ type ChildKey struct { } type Component struct { - Id string + WaveId string Tag string Key string - Elem *Elem + Elem *VDomElem Mounted bool // hooks diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index 6e8586d2e..ca06658d1 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -10,11 +10,18 @@ import ( "strings" "github.com/wavetermdev/htmltoken" + "github.com/wavetermdev/waveterm/pkg/vdom/cssparser" ) // can tokenize and bind HTML to Elems -func appendChildToStack(stack []*Elem, child *Elem) { +const Html_BindPrefix = "#bind:" +const Html_ParamPrefix = "#param:" +const Html_GlobalEventPrefix = "#globalevent" +const Html_BindParamTagName = "bindparam" +const Html_BindTagName = "bind" + +func appendChildToStack(stack []*VDomElem, child *VDomElem) { if child == nil { return } @@ -25,14 +32,14 @@ func appendChildToStack(stack []*Elem, child *Elem) { parent.Children = append(parent.Children, *child) } -func pushElemStack(stack []*Elem, elem *Elem) []*Elem { +func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { if elem == nil { return stack } return append(stack, elem) } -func popElemStack(stack []*Elem) []*Elem { +func popElemStack(stack []*VDomElem) []*VDomElem { if len(stack) <= 1 { return stack } @@ -41,14 +48,14 @@ func popElemStack(stack []*Elem) []*Elem { return stack[:len(stack)-1] } -func curElemTag(stack []*Elem) string { +func curElemTag(stack []*VDomElem) string { if len(stack) == 0 { return "" } return stack[len(stack)-1].Tag } -func finalizeStack(stack []*Elem) *Elem { +func finalizeStack(stack []*VDomElem) *VDomElem { if len(stack) == 0 { return nil } @@ -74,8 +81,38 @@ func getAttr(token htmltoken.Token, key string) string { return "" } -func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { - elem := &Elem{Tag: token.Data} +func attrToProp(attrVal string, params map[string]any) any { + if strings.HasPrefix(attrVal, Html_ParamPrefix) { + bindKey := attrVal[len(Html_ParamPrefix):] + bindVal, ok := params[bindKey] + if !ok { + return nil + } + return bindVal + } + if strings.HasPrefix(attrVal, Html_BindPrefix) { + bindKey := attrVal[len(Html_BindPrefix):] + if bindKey == "" { + return nil + } + return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} + } + if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { + splitArr := strings.Split(attrVal, ":") + if len(splitArr) < 2 { + return nil + } + eventName := splitArr[1] + if eventName == "" { + return nil + } + return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} + } + return attrVal +} + +func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { + elem := &VDomElem{Tag: token.Data} if len(token.Attr) > 0 { elem.Props = make(map[string]any) } @@ -83,16 +120,8 @@ func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { if attr.Key == "" || attr.Val == "" { continue } - if strings.HasPrefix(attr.Val, "#bind:") { - bindKey := attr.Val[6:] - bindVal, ok := data[bindKey] - if !ok { - continue - } - elem.Props[attr.Key] = bindVal - continue - } - elem.Props[attr.Key] = attr.Val + propVal := attrToProp(attr.Val, params) + elem.Props[attr.Key] = propVal } return elem } @@ -177,12 +206,101 @@ func processTextStr(s string) string { return strings.TrimSpace(s) } -func Bind(htmlStr string, data map[string]any) *Elem { +func makePathStr(elemPath []string) string { + return strings.Join(elemPath, " ") +} + +func capitalizeAscii(s string) string { + if s == "" || s[0] < 'a' || s[0] > 'z' { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func toReactName(input string) string { + // Check for CSS custom properties (variables) which start with '--' + if strings.HasPrefix(input, "--") { + return input + } + parts := strings.Split(input, "-") + result := "" + index := 0 + if parts[0] == "" && len(parts) > 1 { + // handle vendor prefixes + prefix := parts[1] + if prefix == "ms" { + result += "ms" + } else { + result += capitalizeAscii(prefix) + } + index = 2 // Skip the empty string and prefix + } else { + result += parts[0] + index = 1 + } + // Convert remaining parts to CamelCase + for ; index < len(parts); index++ { + if parts[index] != "" { + result += capitalizeAscii(parts[index]) + } + } + return result +} + +func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { + if len(styleMap) == 0 { + return nil + } + rtn := make(map[string]any) + for key, val := range styleMap { + rtn[toReactName(key)] = attrToProp(val, params) + } + return rtn +} + +func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { + styleText, ok := elem.Props["style"].(string) + if !ok { + return nil + } + parser := cssparser.MakeParser(styleText) + m, err := parser.Parse() + if err != nil { + return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) + } + elem.Props["style"] = convertStyleToReactStyles(m, params) + return nil +} + +func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { + if elem == nil { + return + } + // call fixStyleAttribute, and walk children + elemCountMap := make(map[string]int) + if len(elemPath) == 0 { + elemPath = append(elemPath, elem.Tag) + } + fixStyleAttribute(elem, params, elemPath) + for i := range elem.Children { + child := &elem.Children[i] + elemCountMap[child.Tag]++ + subPath := child.Tag + if elemCountMap[child.Tag] > 1 { + subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) + } + elemPath = append(elemPath, subPath) + fixupStyleAttributes(&elem.Children[i], params, elemPath) + elemPath = elemPath[:len(elemPath)-1] + } +} + +func Bind(htmlStr string, params map[string]any) *VDomElem { htmlStr = processWhitespace(htmlStr) r := strings.NewReader(htmlStr) iter := htmltoken.NewTokenizer(r) - var elemStack []*Elem - elemStack = append(elemStack, &Elem{Tag: FragmentTag}) + var elemStack []*VDomElem + elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) var tokenErr error outer: for { @@ -190,15 +308,15 @@ outer: token := iter.Token() switch tokenType { case htmltoken.StartTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } - elem := tokenToElem(token, data) + elem := tokenToElem(token, params) elemStack = pushElemStack(elemStack, elem) case htmltoken.EndTagToken: - if token.Data == "bind" { - tokenErr = errors.New("bind tag must be self closing") + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") break outer } if len(elemStack) <= 1 { @@ -211,16 +329,22 @@ outer: } elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: - if token.Data == "bind" { + if token.Data == Html_BindParamTagName { keyAttr := getAttr(token, "key") - dataVal := data[keyAttr] + dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } continue } - elem := tokenToElem(token, data) + if token.Data == Html_BindTagName { + keyAttr := getAttr(token, "key") + binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} + appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) + continue + } + elem := tokenToElem(token, params) appendChildToStack(elemStack, elem) case htmltoken.TextToken: if token.Data == "" { @@ -249,5 +373,7 @@ outer: errTextElem := TextElem(tokenErr.Error()) appendChildToStack(elemStack, &errTextElem) } - return finalizeStack(elemStack) + rtn := finalizeStack(elemStack) + fixupStyleAttributes(rtn, params, nil) + return rtn } diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index 898ab61f8..acbe67fd0 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type vdomContextKeyType struct{} @@ -22,13 +23,20 @@ type VDomContextVal struct { HookIdx int } +type Atom struct { + Val any + Dirty bool + UsedBy map[string]bool // component waveid -> true +} + type RootElem struct { OuterCtx context.Context Root *Component CFuncs map[string]CFunc - CompMap map[string]*Component // component id -> component + CompMap map[string]*Component // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool + Atoms map[string]*Atom } const ( @@ -57,9 +65,49 @@ func MakeRoot() *RootElem { Root: nil, CFuncs: make(map[string]CFunc), CompMap: make(map[string]*Component), + Atoms: make(map[string]*Atom), } } +func (r *RootElem) GetAtom(name string) *Atom { + atom, ok := r.Atoms[name] + if !ok { + atom = &Atom{UsedBy: make(map[string]bool)} + r.Atoms[name] = atom + } + return atom +} + +func (r *RootElem) GetAtomVal(name string) any { + atom := r.GetAtom(name) + return atom.Val +} + +func (r *RootElem) GetStateSync(full bool) []VDomStateSync { + stateSync := make([]VDomStateSync, 0) + for atomName, atom := range r.Atoms { + if atom.Dirty || full { + stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) + atom.Dirty = false + } + } + return stateSync +} + +func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { + atom := r.GetAtom(name) + if !markDirty { + atom.Val = val + return + } + // try to avoid setting the value and marking as dirty if it's the "same" + if utilfn.JsonValEqual(val, atom.Val) { + return + } + atom.Val = val + atom.Dirty = true +} + func (r *RootElem) SetOuterCtx(ctx context.Context) { r.OuterCtx = ctx } @@ -68,30 +116,60 @@ func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { r.CFuncs[name] = cfunc } -func (r *RootElem) Render(elem *Elem) { +func (r *RootElem) Render(elem *VDomElem) { log.Printf("Render %s\n", elem.Tag) r.render(elem, &r.Root) } -func (r *RootElem) Event(id string, propName string) { +func (vdf *VDomFunc) CallFn() { + if vdf.Fn == nil { + return + } + rval := reflect.ValueOf(vdf.Fn) + if rval.Kind() != reflect.Func { + return + } + rval.Call(nil) +} + +func callVDomFn(fnVal any, data any) { + if fnVal == nil { + return + } + fn := fnVal + if vdf, ok := fnVal.(*VDomFunc); ok { + fn = vdf.Fn + } + if fn == nil { + return + } + rval := reflect.ValueOf(fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + return + } + if rtype.NumIn() == 1 { + rval.Call([]reflect.Value{reflect.ValueOf(data)}) + return + } +} + +func (r *RootElem) Event(id string, propName string, data any) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return } fnVal := comp.Elem.Props[propName] - if fnVal == nil { - return - } - fn, ok := fnVal.(func()) - if !ok { - return - } - fn() + callVDomFn(fnVal, data) } // this will be called by the frontend to say the DOM has been mounted // it will eventually send any updated "refs" to the backend as well -func (r *RootElem) runWork() { +func (r *RootElem) RunWork() { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups @@ -123,7 +201,7 @@ func (r *RootElem) runWork() { } } -func (r *RootElem) render(elem *Elem, comp **Component) { +func (r *RootElem) render(elem *VDomElem, comp **Component) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -171,13 +249,13 @@ func (r *RootElem) unmount(comp **Component) { r.unmount(&child) } } - delete(r.CompMap, (*comp).Id) + delete(r.CompMap, (*comp).WaveId) *comp = nil } func (r *RootElem) createComp(tag string, key string, comp **Component) { - *comp = &Component{Id: uuid.New().String(), Tag: tag, Key: key} - r.CompMap[(*comp).Id] = *comp + *comp = &Component{WaveId: uuid.New().String(), Tag: tag, Key: key} + r.CompMap[(*comp).WaveId] = *comp } func (r *RootElem) renderText(text string, comp **Component) { @@ -186,7 +264,7 @@ func (r *RootElem) renderText(text string, comp **Component) { } } -func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { +func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*Component) []*Component { newChildren := make([]*Component, len(elems)) curCM := make(map[ChildKey]*Component) usedMap := make(map[*Component]bool) @@ -217,7 +295,7 @@ func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Com return newChildren } -func (r *RootElem) renderSimple(elem *Elem, comp **Component) { +func (r *RootElem) renderSimple(elem *VDomElem, comp **Component) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } @@ -243,7 +321,7 @@ func getRenderContext(ctx context.Context) *VDomContextVal { return v.(*VDomContextVal) } -func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { +func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -262,11 +340,11 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { r.unmount(&(*comp).Comp) return } - var rtnElem *Elem + var rtnElem *VDomElem if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { - rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} + rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).Comp) } @@ -282,7 +360,7 @@ func convertPropsToVDom(props map[string]any) map[string]any { } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { - vdomProps[k] = VDomFuncType{FuncType: "server"} + vdomProps[k] = VDomFunc{Type: ObjectType_Func} continue } vdomProps[k] = v @@ -290,8 +368,8 @@ func convertPropsToVDom(props map[string]any) map[string]any { return vdomProps } -func convertBaseToVDom(c *Component) *Elem { - elem := &Elem{Id: c.Id, Tag: c.Tag} +func convertBaseToVDom(c *Component) *VDomElem { + elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } @@ -304,12 +382,12 @@ func convertBaseToVDom(c *Component) *Elem { return elem } -func convertToVDom(c *Component) *Elem { +func convertToVDom(c *Component) *VDomElem { if c == nil { return nil } if c.Tag == TextTag { - return &Elem{Tag: TextTag, Text: c.Text} + return &VDomElem{Tag: TextTag, Text: c.Text} } if isBaseTag(c.Tag) { return convertBaseToVDom(c) @@ -318,11 +396,11 @@ func convertToVDom(c *Component) *Elem { } } -func (r *RootElem) makeVDom(comp *Component) *Elem { +func (r *RootElem) makeVDom(comp *Component) *VDomElem { vdomElem := convertToVDom(comp) return vdomElem } -func (r *RootElem) MakeVDom() *Elem { +func (r *RootElem) MakeVDom() *VDomElem { return r.makeVDom(r.Root) } diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go index 430e07ff3..2be63fa41 100644 --- a/pkg/vdom/vdom_test.go +++ b/pkg/vdom/vdom_test.go @@ -18,7 +18,7 @@ type TestContext struct { func Page(ctx context.Context, props map[string]any) any { clicked, setClicked := UseState(ctx, false) - var clickedDiv *Elem + var clickedDiv *VDomElem if clicked { clickedDiv = Bind(`
clicked
`, nil) } @@ -30,8 +30,8 @@ func Page(ctx context.Context, props map[string]any) any { `

hello world

- - + +
`, map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, @@ -39,7 +39,7 @@ func Page(ctx context.Context, props map[string]any) any { } func Button(ctx context.Context, props map[string]any) any { - ref := UseRef(ctx, nil) + ref := UseVDomRef(ctx) clName, setClName := UseState(ctx, "button") UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") @@ -52,8 +52,8 @@ func Button(ctx context.Context, props map[string]any) any { testContext.ButtonId = compId } return Bind(` -
- +
+
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) } @@ -85,10 +85,10 @@ func Test1(t *testing.T) { t.Fatalf("root.Root is nil") } printVDom(root) - root.runWork() + root.RunWork() printVDom(root) - root.Event(testContext.ButtonId, "onClick") - root.runWork() + root.Event(testContext.ButtonId, "onClick", nil) + root.RunWork() printVDom(root) } @@ -111,8 +111,8 @@ func TestBind(t *testing.T) { elem = Bind(`

hello world

- - + +
`, nil) jsonBytes, _ = json.MarshalIndent(elem, "", " ") diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go new file mode 100644 index 000000000..2230e8f21 --- /dev/null +++ b/pkg/vdom/vdom_types.go @@ -0,0 +1,195 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "time" + + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +const TextTag = "#text" +const WaveTextTag = "wave:text" +const WaveNullTag = "wave:null" +const FragmentTag = "#fragment" +const BindTag = "#bind" + +const ChildrenPropKey = "children" +const KeyPropKey = "key" + +const ObjectType_Ref = "ref" +const ObjectType_Binding = "binding" +const ObjectType_Func = "func" + +// vdom element +type VDomElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []VDomElem `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +//// protocol messages + +type VDomCreateContext struct { + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta waveobj.MetaMapType `json:"meta,omitempty"` + NewBlock bool `json:"newblock,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +type VDomAsyncInitiationRequest struct { + Type string `json:"type" tstype:"\"asyncinitiationrequest\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid,omitempty"` +} + +func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { + return VDomAsyncInitiationRequest{ + Type: "asyncinitiationrequest", + Ts: time.Now().UnixMilli(), + BlockId: blockId, + } +} + +type VDomFrontendUpdate struct { + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Initialize bool `json:"initialize,omitempty"` // initialize the app + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext VDomRenderContext `json:"rendercontext,omitempty"` + Events []VDomEvent `json:"events,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +type VDomBackendUpdate struct { + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *VDomBackendOpts `json:"opts,omitempty"` + RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefOperations []VDomRefOperation `json:"refoperations,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +///// prop types + +// used in props +type VDomBinding struct { + Type string `json:"type" tstype:"\"binding\""` + Bind string `json:"bind"` +} + +// used in props +type VDomFunc struct { + Fn any `json:"-"` // server side function (called with reflection) + Type string `json:"type" tstype:"\"func\""` + StopPropagation bool `json:"stoppropagation,omitempty"` + PreventDefault bool `json:"preventdefault,omitempty"` + GlobalEvent string `json:"globalevent,omitempty"` + Keys []string `json:"keys,omitempty"` // special for keyDown events a list of keys to "capture" +} + +// used in props +type VDomRef struct { + Type string `json:"type" tstype:"\"ref\""` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + Position *VDomRefPosition `json:"position,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` +} + +type DomRect struct { + Top float64 `json:"top"` + Left float64 `json:"left"` + Right float64 `json:"right"` + Bottom float64 `json:"bottom"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type VDomRefPosition struct { + OffsetHeight int `json:"offsetheight"` + OffsetWidth int `json:"offsetwidth"` + ScrollHeight int `json:"scrollheight"` + ScrollWidth int `json:"scrollwidth"` + ScrollTop int `json:"scrolltop"` + BoundingClientRect DomRect `json:"boundingclientrect"` +} + +///// subbordinate protocol types + +type VDomEvent struct { + WaveId string `json:"waveid"` + PropName string `json:"propname"` + EventData any `json:"eventdata"` +} + +type VDomRenderContext struct { + BlockId string `json:"blockid"` + Focused bool `json:"focused"` + Width int `json:"width"` + Height int `json:"height"` + RootRefId string `json:"rootrefid"` + Background bool `json:"background,omitempty"` +} + +type VDomStateSync struct { + Atom string `json:"atom"` + Value any `json:"value"` +} + +type VDomRefUpdate struct { + RefId string `json:"refid"` + HasCurrent bool `json:"hascurrent"` + Position *VDomRefPosition `json:"position,omitempty"` +} + +type VDomBackendOpts struct { + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` +} + +type VDomRenderUpdate struct { + UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` + WaveId string `json:"waveid,omitempty"` + VDom VDomElem `json:"vdom"` + Index *int `json:"index,omitempty"` +} + +type VDomRefOperation struct { + RefId string `json:"refid"` + Op string `json:"op" tsype:"\"focus\""` + Params []any `json:"params,omitempty"` +} + +type VDomMessage struct { + MessageType string `json:"messagetype"` + Message string `json:"message"` + StackTrace string `json:"stacktrace,omitempty"` + Params []any `json:"params,omitempty"` +} + +// matches WaveKeyboardEvent +type VDomKeyboardEvent struct { + Type string `json:"type"` + Key string `json:"key"` + Code string `json:"code"` + Shift bool `json:"shift,omitempty"` + Control bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` + Option bool `json:"option,omitempty"` + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` +} diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go new file mode 100644 index 000000000..740802c1d --- /dev/null +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -0,0 +1,199 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdomclient + +import ( + "context" + "fmt" + "log" + "os" + "sync" + "time" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +type Client struct { + Root *vdom.RootElem + RootElem *vdom.VDomElem + RpcClient *wshutil.WshRpc + RpcContext *wshrpc.RpcContext + ServerImpl *VDomServerImpl + IsDone bool + RouteId string + DoneReason string + DoneOnce *sync.Once + DoneCh chan struct{} + Opts vdom.VDomBackendOpts + GlobalEventHandler func(client *Client, event vdom.VDomEvent) +} + +type VDomServerImpl struct { + Client *Client + BlockId string +} + +func (*VDomServerImpl) WshServerImpl() {} + +func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) { + if feUpdate.Dispose { + log.Printf("got dispose from frontend\n") + impl.Client.doShutdown("got dispose from frontend") + return nil, nil + } + if impl.Client.IsDone { + return nil, nil + } + // set atoms + for _, ss := range feUpdate.StateSync { + impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.WaveId == "" { + if impl.Client.GlobalEventHandler != nil { + impl.Client.GlobalEventHandler(impl.Client, event) + } + } else { + impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData) + } + } + if feUpdate.Initialize || feUpdate.Resync { + return impl.Client.fullRender() + } + return impl.Client.incrementalRender() +} + +func (c *Client) doShutdown(reason string) { + c.DoneOnce.Do(func() { + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) + }) +} + +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { + c.GlobalEventHandler = handler +} + +func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { + client := &Client{ + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), + DoneOnce: &sync.Once{}, + } + if opts != nil { + client.Opts = *opts + } + jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) + if jwtToken == "" { + return nil, fmt.Errorf("no %s env var set", wshutil.WaveJwtTokenVarName) + } + rpcCtx, err := wshutil.ExtractUnverifiedRpcContext(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting rpc context from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + client.RpcContext = rpcCtx + if client.RpcContext == nil || client.RpcContext.BlockId == "" { + return nil, fmt.Errorf("no block id in rpc context") + } + client.ServerImpl = &VDomServerImpl{BlockId: client.RpcContext.BlockId, Client: client} + sockName, err := wshutil.ExtractUnverifiedSocketName(jwtToken) + if err != nil { + return nil, fmt.Errorf("error extracting socket name from %s: %v", wshutil.WaveJwtTokenVarName, err) + } + rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockName, client.ServerImpl) + if err != nil { + return nil, fmt.Errorf("error setting up domain socket rpc client: %v", err) + } + client.RpcClient = rpcClient + authRtn, err := wshclient.AuthenticateCommand(client.RpcClient, jwtToken, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return nil, fmt.Errorf("error authenticating rpc connection: %v", err) + } + client.RouteId = authRtn.RouteId + return client, nil +} + +func (c *Client) SetRootElem(elem *vdom.VDomElem) { + c.RootElem = elem +} + +func (c *Client) CreateVDomContext() error { + err := wshclient.VDomCreateContextCommand(c.RpcClient, vdom.VDomCreateContext{}, &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) + if err != nil { + return err + } + wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{ + waveobj.MakeORef("block", c.RpcContext.BlockId).String(), + }}, nil) + c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + c.doShutdown("got blockclose event") + }) + return nil +} + +func (c *Client) SendAsyncInitiation() { + wshclient.VDomAsyncInitiationCommand(c.RpcClient, vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.RpcContext.BlockId)}) +} + +func (c *Client) SetAtomVals(m map[string]any) { + for k, v := range m { + c.Root.SetAtomVal(k, v, true) + } +} + +func (c *Client) SetAtomVal(name string, val any) { + c.Root.SetAtomVal(name, val, true) +} + +func (c *Client) GetAtomVal(name string) any { + return c.Root.GetAtomVal(name) +} + +func makeNullVDom() *vdom.VDomElem { + return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} +} + +func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + c.Root.Render(c.RootElem) + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + Opts: &c.Opts, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(true), + }, nil +} + +func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: *renderedVDom}, + }, + StateSync: c.Root.GetStateSync(false), + }, nil +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 41fdce4dd..21fa3b2f0 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -78,6 +78,10 @@ const ( MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" + MetaKey_VDomClear = "vdom:*" + MetaKey_VDomInitialized = "vdom:initialized" + MetaKey_VDomCorrelationId = "vdom:correlationid" + MetaKey_Count = "count" ) diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2b52e897a..d6dc35f59 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -79,6 +79,10 @@ type MetaTSType struct { TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` + VDomClear bool `json:"vdom:*,omitempty"` + VDomInitialized bool `json:"vdom:initialized,omitempty"` + VDomCorrelationId string `json:"vdom:correlationid,omitempty"` + Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index d925f3eeb..d0e6d4202 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -11,6 +11,7 @@ const ( Event_BlockFile = "blockfile" Event_Config = "config" Event_UserInput = "userinput" + Event_RouteGone = "route:gone" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 382f1041a..e8743ade3 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -11,6 +11,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/vdom" ) // command "authenticate", wshserver.AuthenticateCommand @@ -260,6 +261,24 @@ func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { return err } +// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand +func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts) + return err +} + +// command "vdomcreatecontext", wshserver.VDomCreateContextCommand +func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "vdomcreatecontext", data, opts) + return err +} + +// command "vdomrender", wshserver.VDomRenderCommand +func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *wshrpc.RpcOpts) (*vdom.VDomBackendUpdate, error) { + resp, err := sendRpcRequestCallHelper[*vdom.VDomBackendUpdate](w, "vdomrender", data, opts) + return resp, err +} + // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index fbd06a27d..55dbc18ce 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -11,6 +11,7 @@ import ( "reflect" "github.com/wavetermdev/waveterm/pkg/ijson" + "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" @@ -69,6 +70,10 @@ const ( Command_WebSelector = "webselector" Command_Notify = "notify" + + Command_VDomCreateContext = "vdomcreatecontext" + Command_VDomAsyncInitiation = "vdomasyncinitiation" + Command_VDomRender = "vdomrender" ) type RespOrErrorUnion[T any] struct { @@ -126,8 +131,16 @@ type WshRpcInterface interface { RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] + // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error + + // terminal + VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error + VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error + + // proc + VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) } // for frontend diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 63a53ef0d..5a30ac6e2 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -60,6 +60,10 @@ func MakeTabRouteId(tabId string) string { return "tab:" + tabId } +func MakeFeBlockRouteId(blockId string) string { + return "feblock:" + blockId +} + var DefaultRouter = NewWshRouter() func NewWshRouter() *WshRouter { @@ -322,6 +326,7 @@ func (router *WshRouter) UnregisterRoute(routeId string) { } go func() { wps.Broker.UnsubscribeAll(routeId) + wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_RouteGone, Scopes: []string{routeId}}) }() }