diff --git a/Taskfile.yml b/Taskfile.yml index 7f6056a62..ead74e104 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -159,7 +159,7 @@ tasks: vars: GOOS: darwin GOARCH: arm64 - - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/.waveterm-dev/bin/wsh + - cp dist/bin/wsh-{{.VERSION}}-darwin.arm64 ~/Library/Application\ Support/waveterm-dev/bin/wsh build:wsh:internal: vars: diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index b30ad3603..cd48216ef 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -4,16 +4,21 @@ package cmd import ( + "context" "log" "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/vdom" "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) var htmlCmdNewBlock bool +var GlobalVDomClient *vdomclient.Client func init() { htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block") @@ -27,16 +32,116 @@ var htmlCmd = &cobra.Command{ RunE: htmlRun, } +func StyleTag(ctx context.Context, props map[string]any) any { + return vdom.Bind(` + + `, nil) +} + +func BgItemTag(ctx context.Context, props map[string]any) any { + clickFn := func() { + log.Printf("bg item clicked %q\n", props["bg"]) + blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil) + if err != nil { + log.Printf("error getting block info: %v\n", err) + return + } + log.Printf("block info: tabid=%q\n", blockInfo.TabId) + err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{ + ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId}, + Meta: map[string]any{"bg": props["bg"]}, + }, nil) + if err != nil { + log.Printf("error setting meta: %v\n", err) + } + // wshclient.SetMetaCommand(GlobalVDomClient.RpcClient) + } + params := map[string]any{ + "bg": props["bg"], + "label": props["label"], + "clickHandler": clickFn, + } + return vdom.Bind(` +
+
+
+
`, params) +} + +func AllBgItemsTag(ctx context.Context, props map[string]any) any { + items := []map[string]any{ + {"bg": nil, "label": "default"}, + {"bg": "#ff0000", "label": "red"}, + {"bg": "#00ff00", "label": "green"}, + {"bg": "#0000ff", "label": "blue"}, + } + bgElems := make([]*vdom.VDomElem, 0) + for _, item := range items { + elem := vdom.E("BgItemTag", item) + bgElems = append(bgElems, elem) + } + return vdom.Bind(` +
+
+ +
+
+ `, map[string]any{"bgElems": bgElems}) +} + func MakeVDom() *vdom.VDomElem { vdomStr := ` -
-

hello vdom world

-
| num[]
+
+ +

Set Background

- +
- +
` @@ -58,11 +163,15 @@ func htmlRun(cmd *cobra.Command, args []string) error { if err != nil { return err } + GlobalVDomClient = client client.SetGlobalEventHandler(GlobalEventHandler) log.Printf("created client: %v\n", client) client.SetAtomVal("bgcolor", "#0000ff77") client.SetAtomVal("text", "initial text") client.SetAtomVal("num", 0) + client.RegisterComponent("StyleTag", StyleTag) + client.RegisterComponent("BgItemTag", BgItemTag) + client.RegisterComponent("AllBgItemsTag", AllBgItemsTag) client.SetRootElem(MakeVDom()) err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) if err != nil { diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 741fa66ef..7b70678a1 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -5,8 +5,8 @@ import { BlockComponentModel2, BlockProps } from "@/app/block/blocktypes"; import { PlotView } from "@/app/view/plotview/plotview"; import { PreviewModel, PreviewView, makePreviewModel } from "@/app/view/preview/preview"; import { SysinfoView, SysinfoViewModel, makeSysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; -import { VDomModel } from "@/app/view/term/vdom-model"; -import { VDomView, makeVDomModel } from "@/app/view/vdom/vdom-view"; +import { VDomView, makeVDomModel } from "@/app/view/vdom/vdom"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 2b8c6ad01..eab67e915 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -8,7 +8,7 @@ import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { TermWshClient } from "@/app/view/term/term-wsh"; -import { VDomModel } from "@/app/view/term/vdom-model"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; import { NodeModel } from "@/layout/index"; import { WOS, diff --git a/frontend/app/view/term/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx similarity index 100% rename from frontend/app/view/term/vdom-model.tsx rename to frontend/app/view/vdom/vdom-model.tsx diff --git a/frontend/app/view/vdom/vdom-utils.tsx b/frontend/app/view/vdom/vdom-utils.tsx new file mode 100644 index 000000000..4df721a63 --- /dev/null +++ b/frontend/app/view/vdom/vdom-utils.tsx @@ -0,0 +1,58 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { VDomModel } from "@/app/view/vdom/vdom-model"; +import type { CssNode, List, ListItem } from "css-tree"; +import * as csstree from "css-tree"; + +const TextTag = "#text"; + +// TODO support binding +export function getTextChildren(elem: VDomElem): string { + if (elem.tag == TextTag) { + return elem.text; + } + if (!elem.children) { + return null; + } + const textArr = elem.children.map((child) => { + return getTextChildren(child); + }); + return textArr.join(""); +} + +export function convertVDomId(model: VDomModel, id: string): string { + return model.blockId + "::" + id; +} + +export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperClassName: string) { + try { + const ast = csstree.parse(cssText); + csstree.walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + // Remove disallowed @rules + const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"]; + if (node.type === "Atrule" && blockedRules.includes(node.name)) { + list.remove(item); + } + // Remove :root selectors + if ( + node.type === "Selector" && + node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root") + ) { + list.remove(item); + } + + if (node.type === "IdSelector") { + node.name = convertVDomId(model, node.name); + } + }, + }); + const sanitizedCss = csstree.generate(ast); + return `.${wrapperClassName} { ${sanitizedCss} }`; + } catch (error) { + // TODO better error handling + console.error("CSS processing error:", error); + return null; + } +} diff --git a/frontend/app/view/vdom/vdom-view.tsx b/frontend/app/view/vdom/vdom-view.tsx deleted file mode 100644 index 124149078..000000000 --- a/frontend/app/view/vdom/vdom-view.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { VDomRoot } from "@/app/view/term/vdom"; -import { VDomModel } from "@/app/view/term/vdom-model"; -import { NodeModel } from "@/layout/index"; -import { useRef } from "react"; - -function makeVDomModel(blockId: string, nodeModel: NodeModel): VDomModel { - return new VDomModel(blockId, nodeModel); -} - -type VDomViewProps = { - model: VDomModel; - blockId: string; -}; - -function VDomView({ blockId, model }: VDomViewProps) { - let viewRef = useRef(null); - model.viewRef = viewRef; - return ( -
- -
- ); -} - -export { makeVDomModel, VDomView }; diff --git a/frontend/app/view/vdom/vdom.less b/frontend/app/view/vdom/vdom.less new file mode 100644 index 000000000..3e959889d --- /dev/null +++ b/frontend/app/view/vdom/vdom.less @@ -0,0 +1,5 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +.view-vdom { +} diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/vdom/vdom.tsx similarity index 80% rename from frontend/app/view/term/vdom.tsx rename to frontend/app/view/vdom/vdom.tsx index fcea0714e..8f8706c05 100644 --- a/frontend/app/view/term/vdom.tsx +++ b/frontend/app/view/vdom/vdom.tsx @@ -2,16 +2,22 @@ // SPDX-License-Identifier: Apache-2.0 import { Markdown } from "@/app/element/markdown"; -import { VDomModel } from "@/app/view/term/vdom-model"; +import { VDomModel } from "@/app/view/vdom/vdom-model"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import clsx from "clsx"; import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; +import { convertVDomId, getTextChildren, validateAndWrapCss } from "@/app/view/vdom/vdom-utils"; +import { NodeModel } from "@/layout/index"; +import "./vdom.less"; + const TextTag = "#text"; const FragmentTag = "#fragment"; const WaveTextTag = "wave:text"; const WaveNullTag = "wave:null"; +const StyleTagName = "style"; const VDomObjType_Ref = "ref"; const VDomObjType_Binding = "binding"; @@ -25,7 +31,7 @@ const WaveTagMap: Record = { "wave:markdown": WaveMarkdown, }; -const AllowedTags: { [tagName: string]: boolean } = { +const AllowedSimpleTags: { [tagName: string]: boolean } = { div: true, b: true, i: true, @@ -49,6 +55,30 @@ const AllowedTags: { [tagName: string]: boolean } = { select: true, option: true, form: true, + label: true, + table: true, + thead: true, + tbody: true, + tr: true, + th: true, + td: true, + hr: true, + br: true, + pre: true, + code: true, +}; + +const IdAttributes = { + id: true, + for: true, + "aria-labelledby": true, + "aria-describedby": true, + "aria-controls": true, + "aria-owns": true, + form: true, + headers: true, + usemap: true, + list: true, }; function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { @@ -165,6 +195,10 @@ function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set< } // fallthrough to set props[key] = val } + if (IdAttributes[key]) { + props[key] = convertVDomId(model, val); + continue; + } props[key] = val; } return [props, atomKeys]; @@ -223,6 +257,20 @@ function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { ); } +function StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const styleText = getTextChildren(elem); + if (styleText == null) { + return null; + } + const wrapperClassName = "vdom-" + model.blockId; + // TODO handle errors + const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName); + if (sanitizedCss == null) { + return null; + } + return ; +} + function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { const props = useVDom(model, elem); if (elem.tag == WaveNullTag) { @@ -235,7 +283,10 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { if (waveTag) { return waveTag({ elem, model }); } - if (!AllowedTags[elem.tag]) { + if (elem.tag == StyleTagName) { + return ; + } + if (!AllowedSimpleTags[elem.tag]) { return
{"Invalid Tag <" + elem.tag + ">"}
; } let childrenComps = convertChildren(elem, model); @@ -280,4 +331,24 @@ function VDomRoot({ model }: { model: VDomModel }) { return
{rtn}
; } -export { VDomRoot }; +function makeVDomModel(blockId: string, nodeModel: NodeModel): VDomModel { + return new VDomModel(blockId, nodeModel); +} + +type VDomViewProps = { + model: VDomModel; + blockId: string; +}; + +function VDomView({ blockId, model }: VDomViewProps) { + let viewRef = React.useRef(null); + model.viewRef = viewRef; + const vdomClass = "vdom-" + blockId; + return ( +
+ +
+ ); +} + +export { makeVDomModel, VDomView }; diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index b0a0c9761..79ee5d743 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -197,6 +197,10 @@ func makeNullVDom() *vdom.VDomElem { return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } +func (c *Client) RegisterComponent(name string, cfunc vdom.CFunc) { + c.Root.RegisterComponent(name, cfunc) +} + func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { c.Root.RunWork() c.Root.Render(c.RootElem) diff --git a/pkg/wstore/wstore_dbops.go b/pkg/wstore/wstore_dbops.go index 602842320..de7aa5c59 100644 --- a/pkg/wstore/wstore_dbops.go +++ b/pkg/wstore/wstore_dbops.go @@ -270,11 +270,30 @@ func DBFindWindowForTabId(ctx context.Context, tabId string) (string, error) { func DBFindTabForBlockId(ctx context.Context, blockId string) (string, error) { return WithTxRtn(ctx, func(tx *TxWrap) (string, error) { - query := ` - SELECT t.oid - FROM db_tab t, json_each(data->'blockids') je - WHERE je.value = ?;` - return tx.GetString(query, blockId), nil + iterNum := 1 + for { + if iterNum > 5 { + return "", fmt.Errorf("too many iterations looking for tab in block parents") + } + query := ` + SELECT json_extract(b.data, '$.parentoref') AS parentoref + FROM db_block b + WHERE b.oid = ?;` + parentORef := tx.GetString(query, blockId) + oref, err := waveobj.ParseORef(parentORef) + if err != nil { + return "", fmt.Errorf("bad block parent oref: %v", err) + } + if oref.OType == "tab" { + return oref.OID, nil + } + if oref.OType == "block" { + blockId = oref.OID + iterNum++ + continue + } + return "", fmt.Errorf("bad parent oref type: %v", oref.OType) + } }) }