From 701d93884dd66f300f3d37b965a12a2d4049aa5e Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Wed, 23 Oct 2024 22:47:29 -0700 Subject: [PATCH] vdom 4 (#1110) --- cmd/wsh/cmd/wshcmd-debug.go | 47 +++++ cmd/wsh/cmd/wshcmd-editor.go | 4 +- cmd/wsh/cmd/wshcmd-html.go | 18 +- cmd/wsh/cmd/wshcmd-root.go | 18 +- cmd/wsh/cmd/wshcmd-web.go | 4 +- .../000005_blockparent.down.sql | 1 + .../000005_blockparent.up.sql | 4 + frontend/app/block/block.tsx | 32 ++++ frontend/app/block/blockframe.tsx | 23 ++- frontend/app/block/blocktypes.ts | 1 + frontend/app/store/wos.ts | 1 + frontend/app/store/wshclientapi.ts | 17 +- frontend/app/view/term/term-wsh.tsx | 76 +++++--- frontend/app/view/term/term.less | 2 +- frontend/app/view/term/term.tsx | 163 ++++++++++++++++-- frontend/app/view/term/vdom-model.tsx | 119 ++++++++++--- frontend/app/view/term/vdom.tsx | 46 +++-- frontend/app/view/vdom/vdom-view.tsx | 28 +++ frontend/types/gotypes.d.ts | 30 +++- pkg/blockcontroller/blockcontroller.go | 2 +- pkg/service/objectservice/objectservice.go | 2 +- pkg/vdom/vdom_html.go | 26 ++- pkg/vdom/vdom_types.go | 26 +-- pkg/vdom/vdomclient/vdomclient.go | 73 ++++++-- pkg/waveobj/metaconsts.go | 3 + pkg/waveobj/waveobj.go | 8 + pkg/waveobj/wtype.go | 2 + pkg/waveobj/wtypemeta.go | 3 + pkg/wcore/wcore.go | 40 ++++- pkg/wshrpc/wshclient/wshclient.go | 24 ++- pkg/wshrpc/wshrpctypes.go | 23 ++- pkg/wshrpc/wshserver/wshserver.go | 33 +++- pkg/wshutil/wshrouter.go | 3 + pkg/wstore/wstore.go | 75 ++++++-- 34 files changed, 787 insertions(+), 190 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-debug.go create mode 100644 db/migrations-wstore/000005_blockparent.down.sql create mode 100644 db/migrations-wstore/000005_blockparent.up.sql create mode 100644 frontend/app/view/vdom/vdom-view.tsx diff --git a/cmd/wsh/cmd/wshcmd-debug.go b/cmd/wsh/cmd/wshcmd-debug.go new file mode 100644 index 000000000..48379d3e4 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-debug.go @@ -0,0 +1,47 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "debug commands", + PersistentPreRunE: preRunSetupRpcClient, + Hidden: true, +} + +var debugBlockIdsCmd = &cobra.Command{ + Use: "block", + Short: "list sub-blockids for block", + RunE: debugBlockIdsRun, + Hidden: true, +} + +func init() { + debugCmd.AddCommand(debugBlockIdsCmd) + rootCmd.AddCommand(debugCmd) +} + +func debugBlockIdsRun(cmd *cobra.Command, args []string) error { + oref, err := resolveBlockArg() + if err != nil { + return err + } + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return err + } + barr, err := json.MarshalIndent(blockInfo, "", " ") + if err != nil { + return err + } + WriteStdout("%s\n", string(barr)) + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-editor.go b/cmd/wsh/cmd/wshcmd-editor.go index 0a32af9fd..16a346591 100644 --- a/cmd/wsh/cmd/wshcmd-editor.go +++ b/cmd/wsh/cmd/wshcmd-editor.go @@ -65,11 +65,11 @@ func editorRun(cmd *cobra.Command, args []string) { return } doneCh := make(chan bool) - RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { + RpcClient.EventListener.On(wps.Event_BlockClose, func(event *wps.WaveEvent) { if event.HasScope(blockRef.String()) { close(doneCh) } }) - wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: "blockclose", Scopes: []string{blockRef.String()}}, nil) + wshclient.EventSubCommand(RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{blockRef.String()}}, nil) <-doneCh } diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 968995aa8..b30ad3603 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -13,7 +13,10 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshutil" ) +var htmlCmdNewBlock bool + func init() { + htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block") rootCmd.AddCommand(htmlCmd) } @@ -30,7 +33,10 @@ func MakeVDom() *vdom.VDomElem {

hello vdom world

| num[]
- + +
+
+
` @@ -39,7 +45,7 @@ func MakeVDom() *vdom.VDomElem { } func GlobalEventHandler(client *vdomclient.Client, event vdom.VDomEvent) { - if event.PropName == "clickinc" { + if event.EventType == "clickinc" { client.SetAtomVal("num", client.GetAtomVal("num").(int)+1) return } @@ -58,7 +64,7 @@ func htmlRun(cmd *cobra.Command, args []string) error { client.SetAtomVal("text", "initial text") client.SetAtomVal("num", 0) client.SetRootElem(MakeVDom()) - err = client.CreateVDomContext() + err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) if err != nil { return err } @@ -70,8 +76,12 @@ func htmlRun(cmd *cobra.Command, args []string) error { log.Printf("created vdom context\n") go func() { time.Sleep(5 * time.Second) + log.Printf("updating text\n") client.SetAtomVal("text", "updated text") - client.SendAsyncInitiation() + err := client.SendAsyncInitiation() + if err != nil { + log.Printf("error sending async initiation: %v\n", err) + } }() <-client.DoneCh return nil diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 77cc2ee30..6f29f65a5 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -71,6 +71,22 @@ func preRunSetupRpcClient(cmd *cobra.Command, args []string) error { return nil } +func resolveBlockArg() (*waveobj.ORef, error) { + oref := blockArg + if oref == "" { + return nil, fmt.Errorf("blockid is required") + } + err := validateEasyORef(oref) + if err != nil { + return nil, err + } + fullORef, err := resolveSimpleId(oref) + if err != nil { + return nil, fmt.Errorf("resolving blockid: %w", err) + } + return fullORef, nil +} + // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) func setupRpcClient(serverImpl wshutil.ServerImpl) error { jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName) @@ -101,7 +117,7 @@ func setupRpcClient(serverImpl wshutil.ServerImpl) error { func setTermHtmlMode() { wshutil.SetExtraShutdownFunc(extraShutdownFn) cmd := &wshrpc.CommandSetMetaData{ - Meta: map[string]any{"term:mode": "html"}, + Meta: map[string]any{"term:mode": "vdom"}, } err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd, nil) if err != nil { diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index 91e1e8945..775a0cfef 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -28,7 +28,7 @@ var webOpenCmd = &cobra.Command{ } var webGetCmd = &cobra.Command{ - Use: "get [--inner] [--all] [--json] blockid css-selector", + Use: "get [--inner] [--all] [--json] css-selector", Short: "get the html for a css selector", Args: cobra.ExactArgs(1), Hidden: true, @@ -67,7 +67,7 @@ func webGetRun(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("getting block info: %w", err) } - if blockInfo.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { return fmt.Errorf("block %s is not a web block", fullORef.OID) } data := wshrpc.CommandWebSelectorData{ diff --git a/db/migrations-wstore/000005_blockparent.down.sql b/db/migrations-wstore/000005_blockparent.down.sql new file mode 100644 index 000000000..5aed013ca --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.down.sql @@ -0,0 +1 @@ +-- we don't need to remove parentoref \ No newline at end of file diff --git a/db/migrations-wstore/000005_blockparent.up.sql b/db/migrations-wstore/000005_blockparent.up.sql new file mode 100644 index 000000000..f81864ff8 --- /dev/null +++ b/db/migrations-wstore/000005_blockparent.up.sql @@ -0,0 +1,4 @@ +UPDATE db_block +SET data = json_set(db_block.data, '$.parentoref', 'tab:' || db_tab.oid) +FROM db_tab +WHERE db_block.oid IN (SELECT value FROM json_each(db_tab.data, '$.blockids')); diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f785e6c28..741fa66ef 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -5,6 +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 { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { NodeModel, useDebouncedNodeInnerRect } from "@/layout/index"; @@ -29,6 +31,7 @@ import { BlockFrame } from "./blockframe"; import { blockViewToIcon, blockViewToName } from "./blockutil"; type FullBlockProps = { + isSubBlock?: boolean; preview: boolean; nodeModel: NodeModel; viewModel: ViewModel; @@ -51,6 +54,9 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel) // "cpuplot" is for backwards compatibility with already-opened widgets return makeSysinfoViewModel(blockId, blockView); } + if (blockView == "vdom") { + return makeVDomModel(blockId, nodeModel); + } if (blockView === "help") { return makeHelpViewModel(blockId, nodeModel); } @@ -100,6 +106,9 @@ function getViewElem( if (blockView == "tips") { return ; } + if (blockView == "vdom") { + return ; + } return Invalid View "{blockView}"; } @@ -137,6 +146,26 @@ const BlockPreview = memo(({ nodeModel, viewModel }: FullBlockProps) => { ); }); +const BlockSubBlock = memo(({ nodeModel, viewModel }: FullBlockProps) => { + const [blockData] = useWaveObjectValue(makeORef("block", nodeModel.blockId)); + const blockRef = useRef(null); + const contentRef = useRef(null); + const viewElem = useMemo( + () => getViewElem(nodeModel.blockId, blockRef, contentRef, blockData?.meta?.view, viewModel), + [nodeModel.blockId, blockData?.meta?.view, viewModel] + ); + if (!blockData) { + return null; + } + return ( +
+ + Loading...}>{viewElem} + +
+ ); +}); + const BlockFull = memo(({ nodeModel, viewModel }: FullBlockProps) => { counterInc("render-BlockFull"); const focusElemRef = useRef(null); @@ -275,6 +304,9 @@ const Block = memo((props: BlockProps) => { if (props.preview) { return ; } + if (props.isSubBlock) { + return ; + } return ; }); diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index b713bc88c..199544727 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -26,7 +26,6 @@ import { useBlockAtom, WOS, } from "@/app/store/global"; -import * as services from "@/app/store/services"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { ErrorBoundary } from "@/element/errorboundary"; @@ -60,17 +59,17 @@ function handleHeaderContextMenu( onMagnifyToggle(); }, }, - { - label: "Move to New Window", - click: () => { - const currentTabId = globalStore.get(atoms.staticTabId); - try { - services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); - } catch (e) { - console.error("error moving block to new window", e); - } - }, - }, + // { + // label: "Move to New Window", + // click: () => { + // const currentTabId = globalStore.get(atoms.staticTabId); + // try { + // services.WindowService.MoveBlockToNewWindow(currentTabId, blockData.oid); + // } catch (e) { + // console.error("error moving block to new window", e); + // } + // }, + // }, { type: "separator" }, { label: "Copy BlockId", diff --git a/frontend/app/block/blocktypes.ts b/frontend/app/block/blocktypes.ts index 2340f9d17..03322348b 100644 --- a/frontend/app/block/blocktypes.ts +++ b/frontend/app/block/blocktypes.ts @@ -3,6 +3,7 @@ import { NodeModel } from "@/layout/index"; export interface BlockProps { + isSubBlock?: boolean; preview: boolean; nodeModel: NodeModel; } diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 97fe3259f..dadb43d49 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -316,6 +316,7 @@ export { makeORef, reloadWaveObject, setObjectValue, + splitORef, updateWaveObject, updateWaveObjects, useWaveObjectValue, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index f4a949ce0..a9b4950b5 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -67,11 +67,21 @@ class RpcApiType { return client.wshRpcCall("createblock", data, opts); } + // command "createsubblock" [call] + CreateSubBlockCommand(client: WshClient, data: CommandCreateSubBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("createsubblock", data, opts); + } + // command "deleteblock" [call] DeleteBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deleteblock", data, opts); } + // command "deletesubblock" [call] + DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { + return client.wshRpcCall("deletesubblock", data, opts); + } + // command "dispose" [call] DisposeCommand(client: WshClient, data: CommandDisposeData, opts?: RpcOpts): Promise { return client.wshRpcCall("dispose", data, opts); @@ -228,7 +238,7 @@ class RpcApiType { } // command "vdomcreatecontext" [call] - VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { + VDomCreateContextCommand(client: WshClient, data: VDomCreateContext, opts?: RpcOpts): Promise { return client.wshRpcCall("vdomcreatecontext", data, opts); } @@ -237,6 +247,11 @@ class RpcApiType { return client.wshRpcCall("vdomrender", data, opts); } + // command "waitforroute" [call] + WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { + return client.wshRpcCall("waitforroute", 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 index 1eaca2b92..275abd3ce 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -1,12 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { WOS } from "@/app/store/global"; -import { waveEventSubscribe } from "@/app/store/wps"; +import { atoms, globalStore } from "@/app/store/global"; +import { makeORef, splitORef } from "@/app/store/wos"; 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 { isBlank } from "@/util/util"; import debug from "debug"; const dlog = debug("wave:vdom"); @@ -21,32 +22,55 @@ export class TermWshClient extends WshClient { 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(); + async handle_vdomcreatecontext(rh: RpcResponseHelper, data: VDomCreateContext) { + const source = rh.getSource(); + if (isBlank(source)) { + throw new Error("source cannot be blank"); + } + console.log("vdom-create", source, data); + const tabId = globalStore.get(atoms.staticTabId); + if (data.target?.newblock) { + const oref = await RpcApi.CreateBlockCommand(this, { + tabid: tabId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, + }, + magnified: data.target?.magnified, + }); + return oref; + } else { + // in the terminal + // check if there is a current active vdom block + const oldVDomBlockId = globalStore.get(this.model.vdomBlockId); + const oref = await RpcApi.CreateSubBlockCommand(this, { + parentblockid: this.blockId, + blockdef: { + meta: { + view: "vdom", + "vdom:route": rh.getSource(), + }, }, }); + const [_, newVDomBlockId] = splitORef(oref); + if (!isBlank(oldVDomBlockId)) { + // dispose of the old vdom block + setTimeout(() => { + RpcApi.DeleteSubBlockCommand(this, { blockid: oldVDomBlockId }); + }, 500); + } + setTimeout(() => { + RpcApi.SetMetaCommand(this, { + oref: makeORef("block", this.model.blockId), + meta: { + "term:mode": "vdom", + "term:vdomblockid": newVDomBlockId, + }, + }); + }, 50); + return oref; } - 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.less b/frontend/app/view/term/term.less index 18e1f26b5..4351ca11c 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -76,7 +76,7 @@ } } - &.term-mode-html { + &.term-mode-vdom { .term-connectelem { display: none; } diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d68c0e6da..b7e5a4c47 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -1,19 +1,28 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Block } from "@/app/block/block"; import { getAllGlobalKeyBindings } from "@/app/store/keymodel"; import { waveEventSubscribe } from "@/app/store/wps"; 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 { 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 { + WOS, + atoms, + getBlockComponentModel, + getConnStatusAtom, + getSettingsKeyAtom, + globalStore, + useSettingsPrefixAtom, +} from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import clsx from "clsx"; +import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; import { TermStickers } from "./termsticker"; @@ -22,6 +31,8 @@ import { computeTheme } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; +const dlog = debug("wave:term"); + type InitialLoadDataType = { loaded: boolean; heldData: Uint8Array[]; @@ -37,12 +48,13 @@ class TermViewModel { blockId: string; viewIcon: jotai.Atom; viewName: jotai.Atom; + viewText: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; connStatus: jotai.Atom; termWshClient: TermWshClient; shellProcStatusRef: React.MutableRefObject; - vdomModel: VDomModel; + vdomBlockId: jotai.Atom; constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "term"; @@ -50,23 +62,70 @@ class TermViewModel { 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.vdomBlockId = jotai.atom((get) => { + const blockData = get(this.blockAtom); + return blockData?.meta?.["term:vdomblockid"]; + }); this.termMode = jotai.atom((get) => { const blockData = get(this.blockAtom); return blockData?.meta?.["term:mode"] ?? "term"; }); this.viewIcon = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "bolt"; + } return "terminal"; }); this.viewName = jotai.atom((get) => { const blockData = get(this.blockAtom); + const termMode = get(this.termMode); + if (termMode == "vdom") { + return "Wave App"; + } if (blockData?.meta?.controller == "cmd") { return "Command"; } return "Terminal"; }); - this.manageConnection = jotai.atom(true); + this.viewText = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return [ + { + elemtype: "iconbutton", + icon: "square-terminal", + title: "Switch back to Terminal", + click: () => { + this.setTermMode("term"); + }, + }, + ]; + } else { + const vdomBlockId = get(this.vdomBlockId); + if (vdomBlockId) { + return [ + { + elemtype: "iconbutton", + icon: "bolt", + title: "Switch to Wave App", + click: () => { + this.setTermMode("vdom"); + }, + }, + ]; + } + } + return null; + }); + this.manageConnection = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return false; + } + return true; + }); this.blockBg = jotai.atom((get) => { const blockData = get(this.blockAtom); const fullConfig = get(atoms.fullConfigAtom); @@ -88,6 +147,28 @@ class TermViewModel { }); } + setTermMode(mode: "term" | "vdom") { + if (mode == "term") { + mode = null; + } + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:mode": mode }, + }); + } + + getVDomModel(): VDomModel { + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (!vdomBlockId) { + return null; + } + const bcm = getBlockComponentModel(vdomBlockId); + if (!bcm) { + return null; + } + return bcm.viewModel as VDomModel; + } + dispose() { DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); } @@ -107,16 +188,18 @@ class TermViewModel { 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 }, - }); + const newTermMode = blockData?.meta?.["term:mode"] == "vdom" ? null : "vdom"; + const vdomBlockId = globalStore.get(this.vdomBlockId); + if (newTermMode == "vdom" && !vdomBlockId) { + return; + } + this.setTermMode(newTermMode); return true; } const blockData = globalStore.get(this.blockAtom); - if (blockData.meta?.["term:mode"] == "html") { - return this.vdomModel?.globalKeydownHandler(waveEvent); + if (blockData.meta?.["term:mode"] == "vdom") { + const vdomModel = this.getVDomModel(); + return vdomModel?.keyDownHandler(waveEvent); } return false; } @@ -241,6 +324,52 @@ const TermResyncHandler = React.memo(({ blockId, model }: TerminalViewProps) => return null; }); +const TermVDomNodeSingleId = ({ vdomBlockId, blockId, model }: TerminalViewProps & { vdomBlockId: string }) => { + React.useEffect(() => { + const unsub = waveEventSubscribe({ + eventType: "blockclose", + scope: WOS.makeORef("block", vdomBlockId), + handler: (event) => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta: { + "term:mode": null, + "term:vdomblockid": null, + }, + }); + }, + }); + return () => { + unsub(); + }; + }, []); + const isFocusedAtom = jotai.atom((get) => { + return get(model.nodeModel.isFocused) && get(model.termMode) == "vdom"; + }); + let vdomNodeModel = { + blockId: vdomBlockId, + isFocused: isFocusedAtom, + onClose: () => { + if (vdomBlockId != null) { + RpcApi.DeleteSubBlockCommand(TabRpcClient, { blockid: vdomBlockId }); + } + }, + }; + return ( +
+ +
+ ); +}; + +const TermVDomNode = ({ blockId, model }: TerminalViewProps) => { + const vdomBlockId = jotai.useAtomValue(model.vdomBlockId); + if (vdomBlockId == null) { + return null; + } + return ; +}; + const TerminalView = ({ blockId, model }: TerminalViewProps) => { const viewRef = React.useRef(null); const connectElemRef = React.useRef(null); @@ -252,7 +381,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const termSettingsAtom = useSettingsPrefixAtom("term"); const termSettings = jotai.useAtomValue(termSettingsAtom); let termMode = blockData?.meta?.["term:mode"] ?? "term"; - if (termMode != "term" && termMode != "html") { + if (termMode != "term" && termMode != "vdom") { termMode = "term"; } const termModeRef = React.useRef(termMode); @@ -307,7 +436,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { }, [blockId, termSettings]); React.useEffect(() => { - if (termModeRef.current == "html" && termMode == "term") { + if (termModeRef.current == "vdom" && termMode == "term") { // focus the terminal model.giveFocus(); } @@ -356,11 +485,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
-
-
- -
-
+ ); }; diff --git a/frontend/app/view/term/vdom-model.tsx b/frontend/app/view/term/vdom-model.tsx index 6eaf71858..c4ce4d974 100644 --- a/frontend/app/view/term/vdom-model.tsx +++ b/frontend/app/view/term/vdom-model.tsx @@ -1,11 +1,13 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { globalStore, WOS } from "@/app/store/global"; +import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; import { makeORef } from "@/app/store/wos"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { TermWshClient } from "@/app/view/term/term-wsh"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; import { NodeModel } from "@/layout/index"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import debug from "debug"; @@ -61,22 +63,37 @@ function convertEvent(e: React.SyntheticEvent, fromProp: string): any { return { type: "unknown" }; } +class VDomWshClient extends WshClient { + model: VDomModel; + + constructor(model: VDomModel) { + super(makeFeBlockRouteId(model.blockId)); + this.model = model; + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + console.log("async-initiation", rh.getSource(), data); + this.model.queueUpdate(true); + } +} + export class VDomModel { blockId: string; nodeModel: NodeModel; - viewRef: React.RefObject; + viewType: string; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewRef: React.RefObject = { current: null }; 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; + backendRoute: jotai.Atom; backendOpts: VDomBackendOpts; shouldDispose: boolean; disposed: boolean; @@ -86,18 +103,61 @@ export class VDomModel { needsImmediateUpdate: boolean; lastUpdateTs: number = 0; queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + contextActive: jotai.PrimitiveAtom; + wshClient: VDomWshClient; + persist: jotai.Atom; + routeGoneUnsub: () => void; + routeConfirmed: boolean = false; - constructor( - blockId: string, - nodeModel: NodeModel, - viewRef: React.RefObject, - termWshClient: TermWshClient - ) { + constructor(blockId: string, nodeModel: NodeModel) { + this.viewType = "vdom"; this.blockId = blockId; this.nodeModel = nodeModel; - this.viewRef = viewRef; - this.termWshClient = termWshClient; + this.contextActive = jotai.atom(false); this.reset(); + this.viewIcon = jotai.atom("bolt"); + this.viewName = jotai.atom("Wave App"); + this.backendRoute = jotai.atom((get) => { + const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + }); + this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); + this.wshClient = new VDomWshClient(this); + DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); + const curBackendRoute = globalStore.get(this.backendRoute); + if (curBackendRoute) { + this.queueUpdate(true); + } + this.routeGoneUnsub = waveEventSubscribe({ + eventType: "route:gone", + scope: curBackendRoute, + handler: (event: WaveEvent) => { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + }, + }); + RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( + (routeOk: boolean) => { + if (routeOk) { + this.routeConfirmed = true; + this.queueUpdate(true); + } else { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + } + } + ); + } + + dispose() { + DefaultRouter.unregisterRoute(this.wshClient.routeId); + this.routeGoneUnsub?.(); } reset() { @@ -107,11 +167,9 @@ export class VDomModel { 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; @@ -121,9 +179,15 @@ export class VDomModel { this.needsImmediateUpdate = false; this.lastUpdateTs = 0; this.queuedUpdate = null; + globalStore.set(this.contextActive, false); } - globalKeydownHandler(e: WaveKeyboardEvent): boolean { + getBackendRoute(): string { + const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { this.shouldDispose = true; this.queueUpdate(true); @@ -135,7 +199,7 @@ export class VDomModel { } this.batchedEvents.push({ waveid: null, - propname: "onKeyDown", + eventtype: "onKeyDown", eventdata: e, }); this.queueUpdate(); @@ -179,6 +243,9 @@ export class VDomModel { } queueUpdate(quick: boolean = false, delay: number = 10) { + if (this.disposed) { + return; + } this.needsUpdate = true; let nowTs = Date.now(); if (delay > this.maxNormalUpdateIntervalMs) { @@ -220,7 +287,7 @@ export class VDomModel { async _sendRenderRequest(force: boolean) { this.queuedUpdate = null; - if (this.disposed) { + if (this.disposed || !this.routeConfirmed) { return; } if (this.hasPendingRequest) { @@ -232,7 +299,8 @@ export class VDomModel { if (!force && !this.needsUpdate) { return; } - if (this.backendRoute == null) { + const backendRoute = globalStore.get(this.backendRoute); + if (backendRoute == null) { console.log("vdom-model", "no backend route"); return; } @@ -241,7 +309,7 @@ export class VDomModel { try { const feUpdate = this.createFeUpdate(); dlog("fe-update", feUpdate); - const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: this.backendRoute }); + const beUpdate = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); this.handleBackendUpdate(beUpdate); } finally { this.lastUpdateTs = Date.now(); @@ -454,6 +522,7 @@ export class VDomModel { if (update == null) { return; } + globalStore.set(this.contextActive, true); const idMap = new Map(); const vdomRoot = globalStore.get(this.vdomRoot); if (update.opts != null) { @@ -478,14 +547,14 @@ export class VDomModel { if (fnDecl.globalevent) { const waveEvent: VDomEvent = { waveid: null, - propname: fnDecl.globalevent, + eventtype: fnDecl.globalevent, eventdata: eventData, }; this.batchedEvents.push(waveEvent); } else { const vdomEvent: VDomEvent = { waveid: compId, - propname: propName, + eventtype: propName, eventdata: eventData, }; this.batchedEvents.push(vdomEvent); @@ -510,7 +579,6 @@ export class VDomModel { type: "frontendupdate", ts: Date.now(), blockid: this.blockId, - initialize: this.needsInitialization, rendercontext: renderContext, dispose: this.shouldDispose, resync: this.needsResync, @@ -518,7 +586,6 @@ export class VDomModel { refupdates: this.getRefUpdates(), }; this.needsResync = false; - this.needsInitialization = false; this.batchedEvents = []; if (this.shouldDispose) { this.disposed = true; diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx index 718a392ab..fcea0714e 100644 --- a/frontend/app/view/term/vdom.tsx +++ b/frontend/app/view/term/vdom.tsx @@ -1,10 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { Markdown } from "@/app/element/markdown"; 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"; @@ -20,6 +19,12 @@ const VDomObjType_Func = "func"; const dlog = debug("wave:vdom"); +type VDomReactTagType = (props: { elem: VDomElem; model: VDomModel }) => JSX.Element; + +const WaveTagMap: Record = { + "wave:markdown": WaveMarkdown, +}; + const AllowedTags: { [tagName: string]: boolean } = { div: true, b: true, @@ -191,7 +196,7 @@ function stringSetsEqual(set1: Set, set2: Set): boolean { return true; } -function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { +function useVDom(model: VDomModel, elem: VDomElem): GenericPropsType { const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); let [props, atomKeys] = convertProps(elem, model); @@ -208,18 +213,32 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { model.tagUnuseAtoms(elem.waveid, oldAtomKeys); }; }, []); + return props; +} +function WaveMarkdown({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); + return ( + + ); +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { + const props = useVDom(model, elem); if (elem.tag == WaveNullTag) { return null; } if (elem.tag == WaveTextTag) { return props.text; } + const waveTag = WaveTagMap[elem.tag]; + if (waveTag) { + return waveTag({ elem, model }); + } if (!AllowedTags[elem.tag]) { return
{"Invalid Tag <" + elem.tag + ">"}
; } let childrenComps = convertChildren(elem, model); - dlog("children", childrenComps); if (elem.tag == FragmentTag) { return childrenComps; } @@ -251,25 +270,14 @@ const testVDom: VDomElem = { ], }; -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) { +function VDomRoot({ model }: { model: VDomModel }) { + let rootNode = jotai.useAtomValue(model.vdomRoot); + if (model.viewRef.current == null || rootNode == null) { return null; } dlog("render", rootNode); - model.viewRef = viewRef; let rtn = convertElemToTag(rootNode, model); return
{rtn}
; } -export { VDomView }; +export { VDomRoot }; diff --git a/frontend/app/view/vdom/vdom-view.tsx b/frontend/app/view/vdom/vdom-view.tsx new file mode 100644 index 000000000..124149078 --- /dev/null +++ b/frontend/app/view/vdom/vdom-view.tsx @@ -0,0 +1,28 @@ +// 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/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 0a8652b36..2e35e8ac4 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -7,9 +7,11 @@ declare global { // waveobj.Block type Block = WaveObj & { + parentoref?: string; blockdef: BlockDef; runtimeopts?: RuntimeOpts; stickers?: StickerType[]; + subblockids?: string[]; }; // blockcontroller.BlockControllerRuntimeStatus @@ -30,7 +32,7 @@ declare global { blockid: string; tabid: string; windowid: string; - meta: MetaType; + block: Block; }; // webcmd.BlockInputWSCommand @@ -96,6 +98,12 @@ declare global { magnified?: boolean; }; + // wshrpc.CommandCreateSubBlockData + type CommandCreateSubBlockData = { + parentblockid: string; + blockdef: BlockDef; + }; + // wshrpc.CommandDeleteBlockData type CommandDeleteBlockData = { blockid: string; @@ -167,6 +175,12 @@ declare global { meta: MetaType; }; + // wshrpc.CommandWaitForRouteData + type CommandWaitForRouteData = { + routeid: string; + waitms: number; + }; + // wshrpc.CommandWebSelectorData type CommandWebSelectorData = { windowid: string; @@ -341,9 +355,12 @@ declare global { "term:localshellpath"?: string; "term:localshellopts"?: string[]; "term:scrollback"?: number; + "term:vdomblockid"?: string; "vdom:*"?: boolean; "vdom:initialized"?: boolean; "vdom:correlationid"?: string; + "vdom:route"?: string; + "vdom:persist"?: boolean; count?: number; }; @@ -645,7 +662,7 @@ declare global { type: "createcontext"; ts: number; meta?: MetaType; - newblock?: boolean; + target?: VDomTarget; persist?: boolean; }; @@ -661,7 +678,7 @@ declare global { // vdom.VDomEvent type VDomEvent = { waveid: string; - propname: string; + eventtype: string; eventdata: any; }; @@ -671,7 +688,6 @@ declare global { ts: number; blockid: string; correlationid?: string; - initialize?: boolean; dispose?: boolean; resync?: boolean; rendercontext?: VDomRenderContext; @@ -755,6 +771,12 @@ declare global { value: any; }; + // vdom.VDomTarget + type VDomTarget = { + newblock?: boolean; + magnified?: boolean; + }; + type WSCommandType = { wscommand: string; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 09a86b2c4..f2d6a2751 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -36,7 +36,7 @@ const ( const ( BlockFile_Term = "term" // used for main pty output - BlockFile_Html = "html" // used for alt html layout + BlockFile_VDom = "vdom" // used for alt html layout ) const ( diff --git a/pkg/service/objectservice/objectservice.go b/pkg/service/objectservice/objectservice.go index 2584d142d..08338da1f 100644 --- a/pkg/service/objectservice/objectservice.go +++ b/pkg/service/objectservice/objectservice.go @@ -201,7 +201,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() ctx = waveobj.ContextWithUpdates(ctx) - err := wcore.DeleteBlock(ctx, uiContext.ActiveTabId, blockId) + err := wcore.DeleteBlock(ctx, blockId) if err != nil { return nil, fmt.Errorf("error deleting block: %w", err) } diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index ca06658d1..f6ab228bb 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -4,6 +4,7 @@ package vdom import ( + "encoding/json" "errors" "fmt" "io" @@ -72,7 +73,20 @@ func finalizeStack(stack []*VDomElem) *VDomElem { return rtnElem } -func getAttr(token htmltoken.Token, key string) string { +func attrVal(attr htmltoken.Attribute) (any, error) { + // if !attr.IsJson { + // return attr.Val, nil + // } + var val any + err := json.Unmarshal([]byte(attr.Val), &val) + if err != nil { + return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err) + } + return val, nil +} + +// returns value, isjson +func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { if attr.Key == key { return attr.Val @@ -81,7 +95,7 @@ func getAttr(token htmltoken.Token, key string) string { return "" } -func attrToProp(attrVal string, params map[string]any) any { +func attrToProp(attrVal string, isJson bool, params map[string]any) any { if strings.HasPrefix(attrVal, Html_ParamPrefix) { bindKey := attrVal[len(Html_ParamPrefix):] bindVal, ok := params[bindKey] @@ -120,7 +134,7 @@ func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { if attr.Key == "" || attr.Val == "" { continue } - propVal := attrToProp(attr.Val, params) + propVal := attrToProp(attr.Val, false, params) elem.Props[attr.Key] = propVal } return elem @@ -253,7 +267,7 @@ func convertStyleToReactStyles(styleMap map[string]string, params map[string]any } rtn := make(map[string]any) for key, val := range styleMap { - rtn[toReactName(key)] = attrToProp(val, params) + rtn[toReactName(key)] = attrToProp(val, false, params) } return rtn } @@ -330,7 +344,7 @@ outer: elemStack = popElemStack(elemStack) case htmltoken.SelfClosingTagToken: if token.Data == Html_BindParamTagName { - keyAttr := getAttr(token, "key") + keyAttr := getAttrString(token, "key") dataVal := params[keyAttr] elemList := partToElems(dataVal) for _, elem := range elemList { @@ -339,7 +353,7 @@ outer: continue } if token.Data == Html_BindTagName { - keyAttr := getAttr(token, "key") + keyAttr := getAttrString(token, "key") binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) continue diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index 2230e8f21..1c09d2817 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -34,11 +34,11 @@ type VDomElem struct { //// 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 string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta waveobj.MetaMapType `json:"meta,omitempty"` + Target *VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` } type VDomAsyncInitiationRequest struct { @@ -60,9 +60,8 @@ type VDomFrontendUpdate struct { 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 + 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"` @@ -129,8 +128,8 @@ type VDomRefPosition struct { ///// subbordinate protocol types type VDomEvent struct { - WaveId string `json:"waveid"` - PropName string `json:"propname"` + WaveId string `json:"waveid"` // empty for global events + EventType string `json:"eventtype"` EventData any `json:"eventdata"` } @@ -179,6 +178,13 @@ type VDomMessage struct { Params []any `json:"params,omitempty"` } +// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. +// default is vdom context inside of a terminal block +type VDomTarget struct { + NewBlock bool `json:"newblock,omitempty"` + Magnified bool `json:"magnified,omitempty"` +} + // matches WaveKeyboardEvent type VDomKeyboardEvent struct { Type string `json:"type"` diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index 740802c1d..b0a0c9761 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -13,7 +13,6 @@ import ( "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" @@ -21,6 +20,7 @@ import ( ) type Client struct { + Lock *sync.Mutex Root *vdom.RootElem RootElem *vdom.VDomElem RpcClient *wshutil.WshRpc @@ -28,8 +28,8 @@ type Client struct { ServerImpl *VDomServerImpl IsDone bool RouteId string + VDomContextBlockId string DoneReason string - DoneOnce *sync.Once DoneCh chan struct{} Opts vdom.VDomBackendOpts GlobalEventHandler func(client *Client, event vdom.VDomEvent) @@ -48,7 +48,7 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom impl.Client.doShutdown("got dispose from frontend") return nil, nil } - if impl.Client.IsDone { + if impl.Client.GetIsDone() { return nil, nil } // set atoms @@ -62,21 +62,30 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom impl.Client.GlobalEventHandler(impl.Client, event) } } else { - impl.Client.Root.Event(event.WaveId, event.PropName, event.EventData) + impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) } } - if feUpdate.Initialize || feUpdate.Resync { + if feUpdate.Resync { return impl.Client.fullRender() } return impl.Client.incrementalRender() } +func (c *Client) GetIsDone() bool { + c.Lock.Lock() + defer c.Lock.Unlock() + return c.IsDone +} + func (c *Client) doShutdown(reason string) { - c.DoneOnce.Do(func() { - c.DoneReason = reason - c.IsDone = true - close(c.DoneCh) - }) + c.Lock.Lock() + defer c.Lock.Unlock() + if c.IsDone { + return + } + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) } func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { @@ -85,9 +94,9 @@ func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.V func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { client := &Client{ - Root: vdom.MakeRoot(), - DoneCh: make(chan struct{}), - DoneOnce: &sync.Once{}, + Lock: &sync.Mutex{}, + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), } if opts != nil { client.Opts = *opts @@ -126,13 +135,29 @@ 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)}) +func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { + blockORef, err := wshclient.VDomCreateContextCommand( + c.RpcClient, + vdom.VDomCreateContext{Target: target}, + &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(), + c.VDomContextBlockId = blockORef.OID + log.Printf("created vdom context: %v\n", blockORef) + gotRoute, err := wshclient.WaitForRouteCommand(c.RpcClient, wshrpc.CommandWaitForRouteData{ + RouteId: wshutil.MakeFeBlockRouteId(blockORef.OID), + WaitMs: 4000, + }, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("error waiting for vdom context route: %v", err) + } + if !gotRoute { + return fmt.Errorf("vdom context route could not be established") + } + wshclient.EventSubCommand(c.RpcClient, wps.SubscriptionRequest{Event: wps.Event_BlockClose, Scopes: []string{ + blockORef.String(), }}, nil) c.RpcClient.EventListener.On("blockclose", func(event *wps.WaveEvent) { c.doShutdown("got blockclose event") @@ -140,8 +165,18 @@ func (c *Client) CreateVDomContext() error { 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) SendAsyncInitiation() error { + if c.VDomContextBlockId == "" { + return fmt.Errorf("no vdom context block id") + } + if c.GetIsDone() { + return fmt.Errorf("client is done") + } + return wshclient.VDomAsyncInitiationCommand( + c.RpcClient, + vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), + &wshrpc.RpcOpts{Route: wshutil.MakeFeBlockRouteId(c.VDomContextBlockId)}, + ) } func (c *Client) SetAtomVals(m map[string]any) { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0aba6694c..fc3a73584 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -79,10 +79,13 @@ const ( MetaKey_TermLocalShellPath = "term:localshellpath" MetaKey_TermLocalShellOpts = "term:localshellopts" MetaKey_TermScrollback = "term:scrollback" + MetaKey_TermVDomSubBlockId = "term:vdomblockid" MetaKey_VDomClear = "vdom:*" MetaKey_VDomInitialized = "vdom:initialized" MetaKey_VDomCorrelationId = "vdom:correlationid" + MetaKey_VDomRoute = "vdom:route" + MetaKey_VDomPersist = "vdom:persist" MetaKey_Count = "count" ) diff --git a/pkg/waveobj/waveobj.go b/pkg/waveobj/waveobj.go index ba5931316..13111b54a 100644 --- a/pkg/waveobj/waveobj.go +++ b/pkg/waveobj/waveobj.go @@ -94,6 +94,14 @@ func ParseORef(orefStr string) (ORef, error) { return ORef{OType: otype, OID: oid}, nil } +func ParseORefNoErr(orefStr string) *ORef { + oref, err := ParseORef(orefStr) + if err != nil { + return nil + } + return &oref +} + type WaveObj interface { GetOType() string // should not depend on object state (should work with nil value) } diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 2f3e87717..3e1df9e00 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -252,11 +252,13 @@ type WinSize struct { type Block struct { OID string `json:"oid"` + ParentORef string `json:"parentoref,omitempty"` Version int `json:"version"` BlockDef *BlockDef `json:"blockdef"` RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"` Stickers []*StickerType `json:"stickers,omitempty"` Meta MetaMapType `json:"meta"` + SubBlockIds []string `json:"subblockids,omitempty"` } func (*Block) GetOType() string { diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index d983942b4..367f44bc4 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -80,10 +80,13 @@ type MetaTSType struct { TermLocalShellPath string `json:"term:localshellpath,omitempty"` // matches settings TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` // matches settings TermScrollback *int `json:"term:scrollback,omitempty"` + TermVDomSubBlockId string `json:"term:vdomblockid,omitempty"` VDomClear bool `json:"vdom:*,omitempty"` VDomInitialized bool `json:"vdom:initialized,omitempty"` VDomCorrelationId string `json:"vdom:correlationid,omitempty"` + VDomRoute string `json:"vdom:route,omitempty"` + VDomPersist bool `json:"vdom:persist,omitempty"` Count int `json:"count,omitempty"` // temp for cpu plot. will remove later } diff --git a/pkg/wcore/wcore.go b/pkg/wcore/wcore.go index f282b4f53..784a735d8 100644 --- a/pkg/wcore/wcore.go +++ b/pkg/wcore/wcore.go @@ -26,21 +26,35 @@ import ( const DefaultTimeout = 2 * time.Second const DefaultActivateBlockTimeout = 60 * time.Second -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { - err := wstore.DeleteBlock(ctx, tabId, blockId) +func DeleteBlock(ctx context.Context, blockId string) error { + block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) + } + if block == nil { + return nil + } + if len(block.SubBlockIds) > 0 { + for _, subBlockId := range block.SubBlockIds { + err := DeleteBlock(ctx, subBlockId) + if err != nil { + return fmt.Errorf("error deleting subblock %s: %w", subBlockId, err) + } + } + } + err = wstore.DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } go blockcontroller.StopBlockController(blockId) - sendBlockCloseEvent(tabId, blockId) + sendBlockCloseEvent(blockId) return nil } -func sendBlockCloseEvent(tabId string, blockId string) { +func sendBlockCloseEvent(blockId string) { waveEvent := wps.WaveEvent{ Event: wps.Event_BlockClose, Scopes: []string{ - waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), waveobj.MakeORef(waveobj.OType_Block, blockId).String(), }, Data: blockId, @@ -58,7 +72,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { } // close blocks (sends events + stops block controllers) for _, blockId := range tabData.BlockIds { - err := DeleteBlock(ctx, tabId, blockId) + err := DeleteBlock(ctx, blockId) if err != nil { return fmt.Errorf("error deleting block %s: %w", blockId, err) } @@ -205,6 +219,20 @@ func CreateClient(ctx context.Context) (*waveobj.Client, error) { return client, nil } +func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + if blockDef == nil { + return nil, fmt.Errorf("blockDef is nil") + } + if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" { + return nil, fmt.Errorf("no view provided for new block") + } + blockData, err := wstore.CreateSubBlock(ctx, blockId, blockDef) + if err != nil { + return nil, fmt.Errorf("error creating sub block: %w", err) + } + return blockData, nil +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { if blockDef == nil { return nil, fmt.Errorf("blockDef is nil") diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 5a8d553df..2539742e7 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -86,12 +86,24 @@ func CreateBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateBlockData, o return resp, err } +// command "createsubblock", wshserver.CreateSubBlockCommand +func CreateSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandCreateSubBlockData, opts *wshrpc.RpcOpts) (waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[waveobj.ORef](w, "createsubblock", data, opts) + return resp, err +} + // command "deleteblock", wshserver.DeleteBlockCommand func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deleteblock", data, opts) return err } +// command "deletesubblock", wshserver.DeleteSubBlockCommand +func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) + return err +} + // command "dispose", wshserver.DisposeCommand func DisposeCommand(w *wshutil.WshRpc, data wshrpc.CommandDisposeData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "dispose", data, opts) @@ -274,9 +286,9 @@ func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiation } // 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 +func VDomCreateContextCommand(w *wshutil.WshRpc, data vdom.VDomCreateContext, opts *wshrpc.RpcOpts) (*waveobj.ORef, error) { + resp, err := sendRpcRequestCallHelper[*waveobj.ORef](w, "vdomcreatecontext", data, opts) + return resp, err } // command "vdomrender", wshserver.VDomRenderCommand @@ -285,6 +297,12 @@ func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *ws return resp, err } +// command "waitforroute", wshserver.WaitForRouteCommand +func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) { + resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", 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 40ae627de..19754bec7 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -103,7 +103,10 @@ type WshRpcInterface interface { FileAppendIJsonCommand(ctx context.Context, data CommandAppendIJsonData) error ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) + CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) DeleteBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + DeleteSubBlockCommand(ctx context.Context, data CommandDeleteBlockData) error + WaitForRouteCommand(ctx context.Context, data CommandWaitForRouteData) (bool, error) FileWriteCommand(ctx context.Context, data CommandFileData) error FileReadCommand(ctx context.Context, data CommandFileData) (string, error) EventPublishCommand(ctx context.Context, data wps.WaveEvent) error @@ -145,7 +148,7 @@ type WshRpcInterface interface { NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error // terminal - VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) error + VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error) VDomAsyncInitiationCommand(ctx context.Context, data vdom.VDomAsyncInitiationRequest) error // proc @@ -248,6 +251,11 @@ type CommandCreateBlockData struct { Magnified bool `json:"magnified,omitempty"` } +type CommandCreateSubBlockData struct { + ParentBlockId string `json:"parentblockid"` + BlockDef *waveobj.BlockDef `json:"blockdef"` +} + type CommandBlockSetViewData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` View string `json:"view"` @@ -279,6 +287,11 @@ type CommandAppendIJsonData struct { Data ijson.Command `json:"data"` } +type CommandWaitForRouteData struct { + RouteId string `json:"routeid"` + WaitMs int `json:"waitms"` +} + type CommandDeleteBlockData struct { BlockId string `json:"blockid" wshcontext:"BlockId"` } @@ -402,10 +415,10 @@ type CommandWebSelectorData struct { } type BlockInfoData struct { - BlockId string `json:"blockid"` - TabId string `json:"tabid"` - WindowId string `json:"windowid"` - Meta waveobj.MetaMapType `json:"meta"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + WindowId string `json:"windowid"` + Block *waveobj.Block `json:"block"` } type WaveNotificationOptions struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index df1670b19..b08de1f45 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -250,6 +250,16 @@ func (ws *WshServer) CreateBlockCommand(ctx context.Context, data wshrpc.Command return &waveobj.ORef{OType: waveobj.OType_Block, OID: blockRef.OID}, nil } +func (ws *WshServer) CreateSubBlockCommand(ctx context.Context, data wshrpc.CommandCreateSubBlockData) (*waveobj.ORef, error) { + parentBlockId := data.ParentBlockId + blockData, err := wcore.CreateSubBlock(ctx, parentBlockId, data.BlockDef) + if err != nil { + return nil, fmt.Errorf("error creating block: %w", err) + } + blockRef := &waveobj.ORef{OType: waveobj.OType_Block, OID: blockData.OID} + return blockRef, nil +} + func (ws *WshServer) SetViewCommand(ctx context.Context, data wshrpc.CommandBlockSetViewData) error { log.Printf("SETVIEW: %s | %q\n", data.BlockId, data.View) ctx = waveobj.ContextWithUpdates(ctx) @@ -356,10 +366,10 @@ func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.CommandF func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.CommandAppendIJsonData) error { tryCreate := true - if data.FileName == blockcontroller.BlockFile_Html && tryCreate { + if data.FileName == blockcontroller.BlockFile_VDom && tryCreate { err := filestore.WFS.MakeFile(ctx, data.ZoneId, data.FileName, nil, filestore.FileOptsType{MaxSize: blockcontroller.DefaultHtmlMaxFileSize, IJson: true}) if err != nil && err != fs.ErrExist { - return fmt.Errorf("error creating blockfile[html]: %w", err) + return fmt.Errorf("error creating blockfile[vdom]: %w", err) } } err := filestore.WFS.AppendIJson(ctx, data.ZoneId, data.FileName, data.Data) @@ -379,6 +389,14 @@ func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.Com return nil } +func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { + err := wcore.DeleteBlock(ctx, data.BlockId) + if err != nil { + return fmt.Errorf("error deleting block: %w", err) + } + return nil +} + func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error { ctx = waveobj.ContextWithUpdates(ctx) tabId, err := wstore.DBFindTabForBlockId(ctx, data.BlockId) @@ -395,7 +413,7 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command if windowId == "" { return fmt.Errorf("no window found for tab") } - err = wcore.DeleteBlock(ctx, tabId, data.BlockId) + err = wcore.DeleteBlock(ctx, data.BlockId) if err != nil { return fmt.Errorf("error deleting block: %w", err) } @@ -408,6 +426,13 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command return nil } +func (ws *WshServer) WaitForRouteCommand(ctx context.Context, data wshrpc.CommandWaitForRouteData) (bool, error) { + waitCtx, cancelFn := context.WithTimeout(ctx, time.Duration(data.WaitMs)*time.Millisecond) + defer cancelFn() + err := wshutil.DefaultRouter.WaitForRegister(waitCtx, data.RouteId) + return err == nil, nil +} + func (ws *WshServer) EventRecvCommand(ctx context.Context, data wps.WaveEvent) error { return nil } @@ -587,6 +612,6 @@ func (ws *WshServer) BlockInfoCommand(ctx context.Context, blockId string) (*wsh BlockId: blockId, TabId: tabId, WindowId: windowId, - Meta: blockData.Meta, + Block: blockData, }, nil } diff --git a/pkg/wshutil/wshrouter.go b/pkg/wshutil/wshrouter.go index 64479f498..10b5df517 100644 --- a/pkg/wshutil/wshrouter.go +++ b/pkg/wshutil/wshrouter.go @@ -268,6 +268,9 @@ func (router *WshRouter) WaitForRegister(ctx context.Context, routeId string) er if router.GetRpc(routeId) != nil { return nil } + if router.getAnnouncedRoute(routeId) != "" { + return nil + } select { case <-ctx.Done(): return ctx.Err() diff --git a/pkg/wstore/wstore.go b/pkg/wstore/wstore.go index 1c9824350..0872ec45b 100644 --- a/pkg/wstore/wstore.go +++ b/pkg/wstore/wstore.go @@ -95,6 +95,27 @@ func UpdateTabName(ctx context.Context, tabId, name string) error { }) } +func CreateSubBlock(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) { + return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentBlockId) + if parentBlock == nil { + return nil, fmt.Errorf("parent block not found: %q", parentBlockId) + } + blockId := uuid.NewString() + blockData := &waveobj.Block{ + OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(), + BlockDef: blockDef, + RuntimeOpts: nil, + Meta: blockDef.Meta, + } + DBInsert(tx.Context(), blockData) + parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + return blockData, nil + }) +} + func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) { return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) { tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) @@ -104,6 +125,7 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, blockId := uuid.NewString() blockData := &waveobj.Block{ OID: blockId, + ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(), BlockDef: blockDef, RuntimeOpts: rtOpts, Meta: blockDef.Meta, @@ -124,18 +146,34 @@ func findStringInSlice(slice []string, val string) int { return -1 } -func DeleteBlock(ctx context.Context, tabId string, blockId string) error { +func DeleteBlock(ctx context.Context, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { - tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) - if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + block, err := DBGet[*waveobj.Block](tx.Context(), blockId) + if err != nil { + return fmt.Errorf("error getting block: %w", err) } - blockIdx := findStringInSlice(tab.BlockIds, blockId) - if blockIdx == -1 { + if block == nil { return nil } - tab.BlockIds = append(tab.BlockIds[:blockIdx], tab.BlockIds[blockIdx+1:]...) - DBUpdate(tx.Context(), tab) + if len(block.SubBlockIds) > 0 { + return fmt.Errorf("block has subblocks, must delete subblocks first") + } + parentORef := waveobj.ParseORefNoErr(block.ParentORef) + if parentORef != nil { + if parentORef.OType == waveobj.OType_Tab { + tab, _ := DBGet[*waveobj.Tab](tx.Context(), parentORef.OID) + if tab != nil { + tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId) + DBUpdate(tx.Context(), tab) + } + } else if parentORef.OType == waveobj.OType_Block { + parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentORef.OID) + if parentBlock != nil { + parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId) + DBUpdate(tx.Context(), parentBlock) + } + } + } DBDelete(tx.Context(), waveobj.OType_Block, blockId) return nil }) @@ -145,23 +183,18 @@ func DeleteBlock(ctx context.Context, tabId string, blockId string) error { // also deletes LayoutState func DeleteTab(ctx context.Context, workspaceId string, tabId string) error { return WithTx(ctx, func(tx *TxWrap) error { - ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) - if ws == nil { - return fmt.Errorf("workspace not found: %q", workspaceId) - } tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId) if tab == nil { - return fmt.Errorf("tab not found: %q", tabId) + return nil } if len(tab.BlockIds) != 0 { return fmt.Errorf("tab has blocks, must delete blocks first") } - tabIdx := findStringInSlice(ws.TabIds, tabId) - if tabIdx == -1 { - return nil + ws, _ := DBGet[*waveobj.Workspace](tx.Context(), workspaceId) + if ws != nil { + ws.TabIds = utilfn.RemoveElemFromSlice(ws.TabIds, tabId) + DBUpdate(tx.Context(), ws) } - ws.TabIds = append(ws.TabIds[:tabIdx], ws.TabIds[tabIdx+1:]...) - DBUpdate(tx.Context(), ws) DBDelete(tx.Context(), waveobj.OType_Tab, tabId) DBDelete(tx.Context(), waveobj.OType_LayoutState, tab.LayoutState) return nil @@ -190,6 +223,10 @@ func UpdateObjectMeta(ctx context.Context, oref waveobj.ORef, meta waveobj.MetaM func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, blockId string) error { return WithTx(ctx, func(tx *TxWrap) error { + block, _ := DBGet[*waveobj.Block](tx.Context(), blockId) + if block == nil { + return fmt.Errorf("block not found: %q", blockId) + } currentTab, _ := DBGet[*waveobj.Tab](tx.Context(), currentTabId) if currentTab == nil { return fmt.Errorf("current tab not found: %q", currentTabId) @@ -204,6 +241,8 @@ func MoveBlockToTab(ctx context.Context, currentTabId string, newTabId string, b } currentTab.BlockIds = utilfn.RemoveElemFromSlice(currentTab.BlockIds, blockId) newTab.BlockIds = append(newTab.BlockIds, blockId) + block.ParentORef = waveobj.MakeORef(waveobj.OType_Tab, newTabId).String() + DBUpdate(tx.Context(), block) DBUpdate(tx.Context(), currentTab) DBUpdate(tx.Context(), newTab) return nil