From 6c2ef6cb99ab1a66098ada28839ff4cd5d1a81d5 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 23 Jul 2024 13:16:53 -0700 Subject: [PATCH] working on vdom implementation, other fixes (#136) --- cmd/test/test-main.go | 80 +++--- cmd/wsh/cmd/wshcmd-deleteblock.go | 3 +- cmd/wsh/cmd/wshcmd-getmeta.go | 3 +- cmd/wsh/cmd/wshcmd-html.go | 9 +- cmd/wsh/cmd/wshcmd-readfile.go | 57 +++++ cmd/wsh/cmd/wshcmd-root.go | 82 ++----- cmd/wsh/cmd/wshcmd-setmeta.go | 3 +- cmd/wsh/cmd/wshcmd-view.go | 3 +- frontend/app/store/wshserver.ts | 12 +- frontend/app/view/term/term.tsx | 18 +- frontend/app/view/term/vdom.tsx | 129 ++++++++++ frontend/types/gotypes.d.ts | 37 ++- go.mod | 6 +- go.sum | 4 + pkg/blockcontroller/blockcontroller.go | 10 +- pkg/eventbus/eventbus.go | 5 +- pkg/shellexec/shellexec.go | 6 + pkg/tsgen/tsgen.go | 5 + pkg/vdom/vdom.go | 270 ++++++++++++++++++++ pkg/vdom/vdom_comp.go | 40 +++ pkg/vdom/vdom_html.go | 253 +++++++++++++++++++ pkg/vdom/vdom_root.go | 328 +++++++++++++++++++++++++ pkg/vdom/vdom_test.go | 120 +++++++++ pkg/waveobj/waveobj.go | 8 + pkg/wshrpc/wshclient/wshclient.go | 14 +- pkg/wshrpc/wshrpctypes.go | 6 +- pkg/wshrpc/wshserver/wshserver.go | 35 ++- pkg/wshutil/wshutil.go | 95 +++++++ 28 files changed, 1503 insertions(+), 138 deletions(-) create mode 100644 cmd/wsh/cmd/wshcmd-readfile.go create mode 100644 frontend/app/view/term/vdom.tsx create mode 100644 pkg/vdom/vdom.go create mode 100644 pkg/vdom/vdom_comp.go create mode 100644 pkg/vdom/vdom_html.go create mode 100644 pkg/vdom/vdom_root.go create mode 100644 pkg/vdom/vdom_test.go diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go index 502da149b..15264ec33 100644 --- a/cmd/test/test-main.go +++ b/cmd/test/test-main.go @@ -3,47 +3,53 @@ package main -type WaveAppStyle struct { - BackgroundColor string `json:"backgroundColor,omitempty"` - Color string `json:"color,omitempty"` - Border string `json:"border,omitempty"` - FontSize string `json:"fontSize,omitempty"` - FontFamily string `json:"fontFamily,omitempty"` - FontWeight string `json:"fontWeight,omitempty"` - FontStyle string `json:"fontStyle,omitempty"` - TextDecoration string `json:"textDecoration,omitempty"` -} +import ( + "context" + "fmt" + "log" -type WaveAppMouseEvent struct { - TargetId string `json:"targetid"` -} + "github.com/wavetermdev/thenextwave/pkg/vdom" + "github.com/wavetermdev/thenextwave/pkg/wshutil" +) -type WaveAppChangeEvent struct { - TargetId string `json:"targetid"` - Value string `json:"value"` -} - -type WaveAppElement struct { - WaveId string `json:"waveid"` - Elem string `json:"elem"` - Props map[string]any `json:"props,omitempty"` - Handlers map[string]string `json:"handlers,omitempty"` - Children []*WaveAppElement `json:"children,omitempty"` -} - -func (e *WaveAppElement) AddChild(child *WaveAppElement) { - e.Children = append(e.Children, child) -} - -func (e *WaveAppElement) Style() *WaveAppStyle { - style, ok := e.Props["style"].(*WaveAppStyle) - if !ok { - style := &WaveAppStyle{} - e.Props["style"] = style +func Page(ctx context.Context, props map[string]any) any { + clicked, setClicked := vdom.UseState(ctx, false) + var clickedDiv *vdom.Elem + if clicked { + clickedDiv = vdom.Bind(`
clicked
`, nil) } - return style + clickFn := func() { + log.Printf("run clickFn\n") + setClicked(true) + } + return vdom.Bind( + ` +
+

hello world

+ + +
+`, + map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, + ) +} + +func Button(ctx context.Context, props map[string]any) any { + ref := vdom.UseRef(ctx, nil) + clName, setClName := vdom.UseState(ctx, "button") + vdom.UseEffect(ctx, func() func() { + fmt.Printf("Button useEffect\n") + setClName("button mounted") + return nil + }, nil) + return vdom.Bind(` +
+ +
+ `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) } func main() { - + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) + defer wshutil.RestoreTermState() } diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go index b8c4315d9..a1a52307f 100644 --- a/cmd/wsh/cmd/wshcmd-deleteblock.go +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/thenextwave/pkg/wshrpc" + "github.com/wavetermdev/thenextwave/pkg/wshutil" ) var deleteBlockCmd = &cobra.Command{ @@ -32,7 +33,7 @@ func deleteBlockRun(cmd *cobra.Command, args []string) { fmt.Printf("%v\n", err) return } - setTermRawMode() + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) fullORef, err := resolveSimpleId(oref) if err != nil { fmt.Printf("error resolving oref: %v\r\n", err) diff --git a/cmd/wsh/cmd/wshcmd-getmeta.go b/cmd/wsh/cmd/wshcmd-getmeta.go index dc50b65d4..a7a5a892d 100644 --- a/cmd/wsh/cmd/wshcmd-getmeta.go +++ b/cmd/wsh/cmd/wshcmd-getmeta.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/thenextwave/pkg/wshrpc" "github.com/wavetermdev/thenextwave/pkg/wshrpc/wshclient" + "github.com/wavetermdev/thenextwave/pkg/wshutil" ) var getMetaCmd = &cobra.Command{ @@ -37,7 +38,7 @@ func getMetaRun(cmd *cobra.Command, args []string) { return } - setTermRawMode() + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) fullORef, err := resolveSimpleId(oref) if err != nil { fmt.Printf("error resolving oref: %v\r\n", err) diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 7d5a97dda..912a3b289 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/wavetermdev/thenextwave/pkg/wshutil" ) func init() { @@ -20,20 +21,20 @@ var htmlCmd = &cobra.Command{ } func htmlRun(cmd *cobra.Command, args []string) { - defer doShutdown("normal exit", 0) + defer wshutil.DoShutdown("normal exit", 0, true) setTermHtmlMode() for { var buf [1]byte _, err := WrappedStdin.Read(buf[:]) if err != nil { - doShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1) + wshutil.DoShutdown(fmt.Sprintf("stdin closed/error (%v)", err), 1, true) } if buf[0] == 0x03 { - doShutdown("read Ctrl-C from stdin", 1) + wshutil.DoShutdown("read Ctrl-C from stdin", 1, true) break } if buf[0] == 'x' { - doShutdown("read 'x' from stdin", 0) + wshutil.DoShutdown("read 'x' from stdin", 0, true) break } } diff --git a/cmd/wsh/cmd/wshcmd-readfile.go b/cmd/wsh/cmd/wshcmd-readfile.go new file mode 100644 index 000000000..6c6129eb7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-readfile.go @@ -0,0 +1,57 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wavetermdev/thenextwave/pkg/wshrpc" + "github.com/wavetermdev/thenextwave/pkg/wshrpc/wshclient" + "github.com/wavetermdev/thenextwave/pkg/wshutil" +) + +var readFileCmd = &cobra.Command{ + Use: "readfile", + Short: "read a blockfile", + Args: cobra.ExactArgs(2), + Run: runReadFile, +} + +func init() { + rootCmd.AddCommand(readFileCmd) +} + +func runReadFile(cmd *cobra.Command, args []string) { + oref := args[0] + if oref == "" { + fmt.Fprintf(os.Stderr, "oref is required\r\n") + return + } + err := validateEasyORef(oref) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return + } + + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) + fullORef, err := resolveSimpleId(oref) + if err != nil { + fmt.Fprintf(os.Stderr, "error resolving oref: %v\r\n", err) + return + } + resp64, err := wshclient.ReadFile(RpcClient, wshrpc.CommandFileData{ZoneId: fullORef.OID, FileName: args[1]}, &wshrpc.WshRpcCommandOpts{Timeout: 5000}) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading file: %v\r\n", err) + return + } + resp, err := base64.StdEncoding.DecodeString(resp64) + if err != nil { + fmt.Fprintf(os.Stderr, "error decoding file: %v\r\n", err) + return + } + fmt.Print(string(resp)) +} diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index a1fa5844b..2ca5275a5 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -8,11 +8,8 @@ import ( "io" "log" "os" - "os/signal" "regexp" "strings" - "sync" - "syscall" "time" "github.com/google/uuid" @@ -21,7 +18,6 @@ import ( "github.com/wavetermdev/thenextwave/pkg/wshrpc" "github.com/wavetermdev/thenextwave/pkg/wshrpc/wshclient" "github.com/wavetermdev/thenextwave/pkg/wshutil" - "golang.org/x/term" ) var ( @@ -32,85 +28,37 @@ var ( } ) -var shutdownOnce sync.Once -var origTermState *term.State -var madeRaw bool var usingHtmlMode bool -var shutdownSignalHandlersInstalled bool var WrappedStdin io.Reader var RpcClient *wshutil.WshRpc -func doShutdown(reason string, exitCode int) { - shutdownOnce.Do(func() { - defer os.Exit(exitCode) - if reason != "" { - log.Printf("shutting down: %s\r\n", reason) +func extraShutdownFn() { + if usingHtmlMode { + cmd := &wshrpc.CommandSetMetaData{ + Meta: map[string]any{"term:mode": nil}, } - if usingHtmlMode { - cmd := &wshrpc.CommandSetMetaData{ - Meta: map[string]any{"term:mode": nil}, - } - RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd) - time.Sleep(10 * time.Millisecond) - } - if origTermState != nil { - term.Restore(int(os.Stdin.Fd()), origTermState) - } - }) + RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd) + time.Sleep(10 * time.Millisecond) + } } // returns the wrapped stdin and a new rpc client (that wraps the stdin input and stdout output) func setupRpcClient(handlerFn wshutil.CommandHandlerFnType) { log.Printf("setup rpc client\r\n") - messageCh := make(chan []byte, 32) - outputCh := make(chan []byte, 32) - ptyBuf := wshutil.MakePtyBuffer(wshutil.WaveServerOSCPrefix, os.Stdin, messageCh) - rpcClient := wshutil.MakeWshRpc(messageCh, outputCh, wshutil.RpcContext{}, handlerFn) - go func() { - for msg := range outputCh { - barr := wshutil.EncodeWaveOSCBytes(wshutil.WaveOSC, msg) - os.Stdout.Write(barr) - } - }() - WrappedStdin = ptyBuf - RpcClient = rpcClient -} - -func setTermRawMode() { - if madeRaw { - return - } - origState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - fmt.Fprintf(os.Stderr, "Error setting raw mode: %v\n", err) - return - } - origTermState = origState - madeRaw = true + RpcClient, WrappedStdin = wshutil.SetupTerminalRpcClient(handlerFn) } func setTermHtmlMode() { - installShutdownSignalHandlers() - setTermRawMode() + wshutil.SetExtraShutdownFunc(extraShutdownFn) + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) cmd := &wshrpc.CommandSetMetaData{ Meta: map[string]any{"term:mode": "html"}, } - RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd) - usingHtmlMode = true -} - -func installShutdownSignalHandlers() { - if shutdownSignalHandlersInstalled { - return + err := RpcClient.SendCommand(wshrpc.Command_SetMeta, cmd) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting html mode: %v\r\n", err) } - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) - go func() { - for sig := range sigCh { - doShutdown(fmt.Sprintf("got signal %v", sig), 1) - break - } - }() + usingHtmlMode = true } var oidRe = regexp.MustCompile(`^[0-9a-f]{8}$`) @@ -162,7 +110,7 @@ func resolveSimpleId(id string) (*waveobj.ORef, error) { // Execute executes the root command. func Execute() error { - defer doShutdown("", 0) + defer wshutil.DoShutdown("", 0, false) setupRpcClient(nil) return rootCmd.Execute() } diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go index 84b1018e7..1804beaa6 100644 --- a/cmd/wsh/cmd/wshcmd-setmeta.go +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/thenextwave/pkg/wshrpc" + "github.com/wavetermdev/thenextwave/pkg/wshutil" ) var setMetaCmd = &cobra.Command{ @@ -74,7 +75,7 @@ func setMetaRun(cmd *cobra.Command, args []string) { fmt.Printf("%v\n", err) return } - setTermRawMode() + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) fullORef, err := resolveSimpleId(oref) if err != nil { fmt.Printf("error resolving oref: %v\n", err) diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go index 2ba2b6b55..2e198f6b1 100644 --- a/cmd/wsh/cmd/wshcmd-view.go +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/thenextwave/pkg/wshrpc" + "github.com/wavetermdev/thenextwave/pkg/wshutil" "github.com/wavetermdev/thenextwave/pkg/wstore" ) @@ -43,7 +44,7 @@ func viewRun(cmd *cobra.Command, args []string) { if err != nil { log.Printf("error getting file info: %v\n", err) } - setTermRawMode() + wshutil.SetTermRawModeAndInstallShutdownHandlers(true) viewWshCmd := &wshrpc.CommandCreateBlockData{ BlockDef: &wstore.BlockDef{ View: "preview", diff --git a/frontend/app/store/wshserver.ts b/frontend/app/store/wshserver.ts index 221173246..ac45fcdc4 100644 --- a/frontend/app/store/wshserver.ts +++ b/frontend/app/store/wshserver.ts @@ -28,7 +28,7 @@ class WshServerType { } // command "file:append" [call] - AppendFileCommand(data: CommandAppendFileData, opts?: WshRpcCommandOpts): Promise { + AppendFileCommand(data: CommandFileData, opts?: WshRpcCommandOpts): Promise { return WOS.wshServerRpcHelper_call("file:append", data, opts); } @@ -37,6 +37,16 @@ class WshServerType { return WOS.wshServerRpcHelper_call("file:appendijson", data, opts); } + // command "file:read" [call] + ReadFile(data: CommandFileData, opts?: WshRpcCommandOpts): Promise { + return WOS.wshServerRpcHelper_call("file:read", data, opts); + } + + // command "file:write" [call] + WriteFile(data: CommandFileData, opts?: WshRpcCommandOpts): Promise { + return WOS.wshServerRpcHelper_call("file:write", data, opts); + } + // command "getmeta" [call] GetMetaCommand(data: CommandGetMetaData, opts?: WshRpcCommandOpts): Promise { return WOS.wshServerRpcHelper_call("getmeta", data, opts); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 5ecf24bfd..62f770032 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -9,11 +9,11 @@ import clsx from "clsx"; import { produce } from "immer"; import * as jotai from "jotai"; import * as React from "react"; -import { IJsonView } from "./ijson"; import { TermStickers } from "./termsticker"; import { TermWrap } from "./termwrap"; import { WshServer } from "@/app/store/wshserver"; +import { VDomView } from "@/app/view/term/vdom"; import "public/xterm.css"; import "./term.less"; @@ -100,16 +100,24 @@ type InitialLoadDataType = { heldData: Uint8Array[]; }; -const IJSONConst = { +function vdomText(text: string): VDomElem { + return { + tag: "#text", + text: text, + }; +} + +const testVDom: VDomElem = { + id: "testid1", tag: "div", children: [ { tag: "h1", - children: ["Hello World"], + children: [vdomText("Hello World")], }, { tag: "p", - children: ["This is a paragraph"], + children: [vdomText("This is a paragraph (from VDOM)")], }, ], }; @@ -343,7 +351,7 @@ const TerminalView = ({ blockId, model }: { blockId: string; model: TermViewMode />
- +
diff --git a/frontend/app/view/term/vdom.tsx b/frontend/app/view/term/vdom.tsx new file mode 100644 index 000000000..4e87ed46d --- /dev/null +++ b/frontend/app/view/term/vdom.tsx @@ -0,0 +1,129 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import * as React from "react"; + +const AllowedTags: { [tagName: string]: boolean } = { + div: true, + b: true, + i: true, + p: true, + s: true, + span: true, + a: true, + img: true, + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + li: true, + input: true, + button: true, + textarea: true, + select: true, + option: true, + form: true, +}; + +function convertVDomFunc(fnDecl: VDomFuncType, 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"]) { + if (checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + e.stopPropagation(); + callFunc(e, compId, propName); + return; + } + } + return; + } + if (fnDecl["#preventDefault"]) { + e.preventDefault(); + } + if (fnDecl["#stopPropagation"]) { + e.stopPropagation(); + } + callFunc(e, compId, propName); + }; +} + +function convertElemToTag(elem: VDomElem): JSX.Element | string { + if (elem == null) { + return null; + } + if (elem.tag == "#text") { + return elem.text; + } + return React.createElement(VDomTag, { elem: elem, key: elem.id }); +} + +function isObject(v: any): boolean { + return v != null && !Array.isArray(v) && typeof v === "object"; +} + +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 + ">"}
; + } + let props = {}; + for (let key in elem.props) { + let val = elem.props[key]; + if (val == null) { + continue; + } + if (key == "ref") { + if (val == null) { + continue; + } + if (isObject(val) && "#ref" in val) { + props[key] = (elem: HTMLElement) => { + updateRefFunc(elem, val); + }; + } + continue; + } + if (isObject(val) && "#func" in val) { + props[key] = convertVDomFunc(val, elem.id, key); + continue; + } + } + 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") { + return childrenComps; + } + return React.createElement(elem.tag, props, childrenComps); +} + +function VDomView({ rootNode }: { rootNode: VDomElem }) { + let rtn = convertElemToTag(rootNode); + return
{rtn}
; +} + +export { VDomView }; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index fb8152be2..34cc5e187 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -55,13 +55,6 @@ declare global { meta: MetaType; }; - // wshrpc.CommandAppendFileData - type CommandAppendFileData = { - zoneid: string; - filename: string; - data64: string; - }; - // wshrpc.CommandAppendIJsonData type CommandAppendIJsonData = { zoneid: string; @@ -100,6 +93,13 @@ declare global { blockid: string; }; + // wshrpc.CommandFileData + type CommandFileData = { + zoneid: string; + filename: string; + data64?: string; + }; + // wshrpc.CommandGetMetaData type CommandGetMetaData = { oref: ORef; @@ -297,6 +297,29 @@ declare global { checkboxstat?: boolean; }; + // vdom.Elem + type VDomElem = { + id?: string; + tag: string; + props?: MetaType; + children?: VDomElem[]; + text?: string; + }; + + // vdom.VDomFuncType + type VDomFuncType = { + #func: string; + #stopPropagation?: boolean; + #preventDefault?: boolean; + #keys?: string[]; + }; + + // vdom.VDomRefType + type VDomRefType = { + #ref: string; + current: any; + }; + type WSCommandType = { wscommand: string; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); diff --git a/go.mod b/go.mod index 6aba40813..a876d056f 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/wavetermdev/thenextwave -go 1.22 - -toolchain go1.22.1 +go 1.22.4 require ( github.com/alexflint/go-filemutex v1.3.0 @@ -18,6 +16,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.22 github.com/mitchellh/mapstructure v1.5.0 github.com/sawka/txwrap v0.2.0 + github.com/wavetermdev/htmltoken v0.1.0 github.com/spf13/cobra v1.8.1 github.com/wavetermdev/waveterm/wavesrv v0.0.0-20240508181017-d07068c09d94 golang.org/x/crypto v0.25.0 @@ -32,6 +31,7 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.8.4 // indirect go.uber.org/atomic v1.7.0 // indirect + golang.org/x/net v0.27.0 // indirect golang.org/x/sys v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index f209f5f62..f5b9bc7d3 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= +github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY= github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/wavetermdev/waveterm/wavesrv v0.0.0-20240508181017-d07068c09d94 h1:/SPCxd4KHlS4eRTreYEXWFRr8WfRFBcChlV5cgkaO58= @@ -61,6 +63,8 @@ go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index a04f04509..7c1ba7158 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -233,9 +233,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta map[str return shellProcErr } var cmdStr string - var cmdOpts shellexec.CommandOptsType + cmdOpts := shellexec.CommandOptsType{ + Env: make(map[string]string), + } + // temporary for blockid (will switch to a JWT at some point) + cmdOpts.Env["LC_WAVETERM_BLOCKID"] = bc.BlockId if bc.ControllerType == BlockController_Shell { - cmdOpts = shellexec.CommandOptsType{Interactive: true, Login: true} + cmdOpts.Interactive = true + cmdOpts.Login = true } else if bc.ControllerType == BlockController_Cmd { if _, ok := blockMeta["cmd"].(string); ok { cmdStr = blockMeta["cmd"].(string) @@ -260,7 +265,6 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta map[str } if _, ok := blockMeta["cmd:env"].(map[string]any); ok { cmdEnv := blockMeta["cmd:env"].(map[string]any) - cmdOpts.Env = make(map[string]string) for k, v := range cmdEnv { if v == nil { continue diff --git a/pkg/eventbus/eventbus.go b/pkg/eventbus/eventbus.go index b7b97544c..1e5b14ece 100644 --- a/pkg/eventbus/eventbus.go +++ b/pkg/eventbus/eventbus.go @@ -33,8 +33,9 @@ type WSEventType struct { } const ( - FileOp_Append = "append" - FileOp_Truncate = "truncate" + FileOp_Append = "append" + FileOp_Truncate = "truncate" + FileOp_Invalidate = "invalidate" ) type WSFileEventData struct { diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 5ea6fd5a6..4a9e729af 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -176,6 +176,11 @@ func StartRemoteShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsT session.Stdin = cmdTty session.Stdout = cmdTty session.Stderr = cmdTty + for envKey, envVal := range cmdOpts.Env { + // note these might fail depending on server settings, but we still try + session.Setenv(envKey, envVal) + } + session.RequestPty("xterm-256color", termSize.Rows, termSize.Cols, nil) sessionWrap := SessionWrap{session, cmdCombined, cmdTty} @@ -216,6 +221,7 @@ func StartShellProc(termSize TermSize, cmdStr string, cmdOpts CommandOptsType) ( envToAdd["LANG"] = wavebase.DetermineLang() } shellutil.UpdateCmdEnv(ecmd, envToAdd) + shellutil.UpdateCmdEnv(ecmd, cmdOpts.Env) cmdPty, cmdTty, err := pty.Open() if err != nil { return nil, fmt.Errorf("opening new pty: %w", err) diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index a69c2d928..f6a244187 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -15,6 +15,7 @@ import ( "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/tsgen/tsgenmeta" "github.com/wavetermdev/thenextwave/pkg/userinput" + "github.com/wavetermdev/thenextwave/pkg/vdom" "github.com/wavetermdev/thenextwave/pkg/waveobj" "github.com/wavetermdev/thenextwave/pkg/wconfig" "github.com/wavetermdev/thenextwave/pkg/web/webcmd" @@ -41,6 +42,9 @@ var ExtraTypes = []any{ wshutil.RpcMessage{}, wshrpc.WshServerCommandMeta{}, userinput.UserInputRequest{}, + vdom.Elem{}, + vdom.VDomFuncType{}, + vdom.VDomRefType{}, } // add extra type unions to generate here @@ -149,6 +153,7 @@ func TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, [ var tsRenameMap = map[string]string{ "Window": "WaveWindow", + "Elem": "VDomElem", } func generateTSTypeInternal(rtype reflect.Type, tsTypesMap map[reflect.Type]string) (string, []reflect.Type) { diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go new file mode 100644 index 000000000..4cab277b7 --- /dev/null +++ b/pkg/vdom/vdom.go @@ -0,0 +1,270 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + "unicode" +) + +// 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 + Idx int // index in the hook array + Fn func() func() // for useEffect + UnmountFn func() // for useEffect + Val any // for useState, useMemo, useRef + Deps []any +} + +type CFunc = func(ctx context.Context, props map[string]any) any + +func (e *Elem) Key() string { + keyVal, ok := e.Props[KeyPropKey] + if !ok { + return "" + } + keyStr, ok := keyVal.(string) + if ok { + return keyStr + } + return "" +} + +func TextElem(text string) Elem { + return Elem{Tag: TextTag, Text: text} +} + +func mergeProps(props *map[string]any, newProps map[string]any) { + if *props == nil { + *props = make(map[string]any) + } + for k, v := range newProps { + if v == nil { + delete(*props, k) + continue + } + (*props)[k] = v + } +} + +func E(tag string, parts ...any) *Elem { + rtn := &Elem{Tag: tag} + for _, part := range parts { + if part == nil { + continue + } + props, ok := part.(map[string]any) + if ok { + mergeProps(&rtn.Props, props) + continue + } + elems := partToElems(part) + rtn.Children = append(rtn.Children, elems...) + } + return rtn +} + +func P(propName string, propVal any) map[string]any { + return map[string]any{propName: propVal} +} + +func getHookFromCtx(ctx context.Context) (*VDomContextVal, *Hook) { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseState must be called within a component (no context)") + } + if vc.Comp == nil { + panic("UseState must be called within a component (vc.Comp is nil)") + } + for len(vc.Comp.Hooks) <= vc.HookIdx { + vc.Comp.Hooks = append(vc.Comp.Hooks, &Hook{Idx: len(vc.Comp.Hooks)}) + } + hookVal := vc.Comp.Hooks[vc.HookIdx] + vc.HookIdx++ + return vc, hookVal +} + +func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + hookVal.Val = initialVal + } + var rtnVal T + rtnVal, ok := hookVal.Val.(T) + if !ok { + panic("UseState hook value is not a state (possible out of order or conditional hooks)") + } + setVal := func(newVal T) { + hookVal.Val = newVal + vc.Root.AddRenderWork(vc.Comp.Id) + } + return rtnVal, setVal +} + +func UseRef(ctx context.Context, initialVal any) *VDomRefType { + 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} + } + refVal, ok := hookVal.Val.(*VDomRefType) + if !ok { + panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") + } + return refVal +} + +func UseId(ctx context.Context) string { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseId must be called within a component (no context)") + } + return vc.Comp.Id +} + +func depsEqual(deps1 []any, deps2 []any) bool { + if len(deps1) != len(deps2) { + return false + } + for i := range deps1 { + if deps1[i] != deps2[i] { + return false + } + } + return true +} + +func UseEffect(ctx context.Context, fn func() func(), deps []any) { + // note UseEffect never actually runs anything, it just queues the effect to run later + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + hookVal.Fn = fn + hookVal.Deps = deps + vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) + return + } + if depsEqual(hookVal.Deps, deps) { + return + } + hookVal.Fn = fn + hookVal.Deps = deps + vc.Root.AddEffectWork(vc.Comp.Id, hookVal.Idx) +} + +func numToString[T any](value T) (string, bool) { + switch v := any(value).(type) { + case int, int8, int16, int32, int64: + return strconv.FormatInt(v.(int64), 10), true + case uint, uint8, uint16, uint32, uint64: + return strconv.FormatUint(v.(uint64), 10), true + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32), true + case float64: + return strconv.FormatFloat(v, 'f', -1, 64), true + default: + return "", false + } +} + +func partToElems(part any) []Elem { + if part == nil { + return nil + } + switch part := part.(type) { + case string: + return []Elem{TextElem(part)} + case *Elem: + if part == nil { + return nil + } + return []Elem{*part} + case Elem: + return []Elem{part} + case []Elem: + return part + case []*Elem: + var rtn []Elem + for _, e := range part { + if e == nil { + continue + } + rtn = append(rtn, *e) + } + return rtn + } + sval, ok := numToString(part) + if ok { + return []Elem{TextElem(sval)} + } + partVal := reflect.ValueOf(part) + if partVal.Kind() == reflect.Slice { + var rtn []Elem + for i := 0; i < partVal.Len(); i++ { + subPart := partVal.Index(i).Interface() + rtn = append(rtn, partToElems(subPart)...) + } + return rtn + } + stringer, ok := part.(fmt.Stringer) + if ok { + return []Elem{TextElem(stringer.String())} + } + jsonStr, jsonErr := json.Marshal(part) + if jsonErr == nil { + return []Elem{TextElem(string(jsonStr))} + } + typeText := "invalid:" + reflect.TypeOf(part).String() + return []Elem{TextElem(typeText)} +} + +func isWaveTag(tag string) bool { + return strings.HasPrefix(tag, "wave:") || strings.HasPrefix(tag, "w:") +} + +func isBaseTag(tag string) bool { + if len(tag) == 0 { + return false + } + return tag[0] == '#' || unicode.IsLower(rune(tag[0])) || isWaveTag(tag) +} diff --git a/pkg/vdom/vdom_comp.go b/pkg/vdom/vdom_comp.go new file mode 100644 index 000000000..3b51701a5 --- /dev/null +++ b/pkg/vdom/vdom_comp.go @@ -0,0 +1,40 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +// so components either render to another component (or fragment) +// or to a base element (text or vdom). base elements can then render children + +type ChildKey struct { + Tag string + Idx int + Key string +} + +type Component struct { + Id string + Tag string + Key string + Elem *Elem + Mounted bool + + // hooks + Hooks []*Hook + + // #text component + Text string + + // base component -- vdom, wave elem, or #fragment + Children []*Component + + // component -> component + Comp *Component +} + +func (c *Component) compMatch(tag string, key string) bool { + if c == nil { + return false + } + return c.Tag == tag && c.Key == key +} diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go new file mode 100644 index 000000000..6e8586d2e --- /dev/null +++ b/pkg/vdom/vdom_html.go @@ -0,0 +1,253 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/wavetermdev/htmltoken" +) + +// can tokenize and bind HTML to Elems + +func appendChildToStack(stack []*Elem, child *Elem) { + if child == nil { + return + } + if len(stack) == 0 { + return + } + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, *child) +} + +func pushElemStack(stack []*Elem, elem *Elem) []*Elem { + if elem == nil { + return stack + } + return append(stack, elem) +} + +func popElemStack(stack []*Elem) []*Elem { + if len(stack) <= 1 { + return stack + } + curElem := stack[len(stack)-1] + appendChildToStack(stack[:len(stack)-1], curElem) + return stack[:len(stack)-1] +} + +func curElemTag(stack []*Elem) string { + if len(stack) == 0 { + return "" + } + return stack[len(stack)-1].Tag +} + +func finalizeStack(stack []*Elem) *Elem { + if len(stack) == 0 { + return nil + } + for len(stack) > 1 { + stack = popElemStack(stack) + } + rtnElem := stack[0] + if len(rtnElem.Children) == 0 { + return nil + } + if len(rtnElem.Children) == 1 { + return &rtnElem.Children[0] + } + return rtnElem +} + +func getAttr(token htmltoken.Token, key string) string { + for _, attr := range token.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func tokenToElem(token htmltoken.Token, data map[string]any) *Elem { + elem := &Elem{Tag: token.Data} + if len(token.Attr) > 0 { + elem.Props = make(map[string]any) + } + for _, attr := range token.Attr { + 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 + } + return elem +} + +func isWsChar(char rune) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} + +func isWsByte(char byte) bool { + return char == ' ' || char == '\t' || char == '\n' || char == '\r' +} + +func isFirstCharLt(s string) bool { + for _, char := range s { + if isWsChar(char) { + continue + } + return char == '<' + } + return false +} + +func isLastCharGt(s string) bool { + for i := len(s) - 1; i >= 0; i-- { + char := s[i] + if isWsByte(char) { + continue + } + return char == '>' + } + return false +} + +func isAllWhitespace(s string) bool { + for _, char := range s { + if !isWsChar(char) { + return false + } + } + return true +} + +func trimWhitespaceConditionally(s string) string { + // Trim leading whitespace if the first non-whitespace character is '<' + if isAllWhitespace(s) { + return "" + } + if isFirstCharLt(s) { + s = strings.TrimLeftFunc(s, func(r rune) bool { + return isWsChar(r) + }) + } + // Trim trailing whitespace if the last non-whitespace character is '>' + if isLastCharGt(s) { + s = strings.TrimRightFunc(s, func(r rune) bool { + return isWsChar(r) + }) + } + return s +} + +func processWhitespace(htmlStr string) string { + lines := strings.Split(htmlStr, "\n") + var newLines []string + for _, line := range lines { + trimmedLine := trimWhitespaceConditionally(line + "\n") + if trimmedLine == "" { + continue + } + newLines = append(newLines, trimmedLine) + } + return strings.Join(newLines, "") +} + +func processTextStr(s string) string { + if s == "" { + return "" + } + if isAllWhitespace(s) { + return " " + } + return strings.TrimSpace(s) +} + +func Bind(htmlStr string, data map[string]any) *Elem { + htmlStr = processWhitespace(htmlStr) + r := strings.NewReader(htmlStr) + iter := htmltoken.NewTokenizer(r) + var elemStack []*Elem + elemStack = append(elemStack, &Elem{Tag: FragmentTag}) + var tokenErr error +outer: + for { + tokenType := iter.Next() + token := iter.Token() + switch tokenType { + case htmltoken.StartTagToken: + if token.Data == "bind" { + tokenErr = errors.New("bind tag must be self closing") + break outer + } + elem := tokenToElem(token, data) + elemStack = pushElemStack(elemStack, elem) + case htmltoken.EndTagToken: + if token.Data == "bind" { + tokenErr = errors.New("bind tag must be self closing") + break outer + } + if len(elemStack) <= 1 { + tokenErr = fmt.Errorf("end tag %q without start tag", token.Data) + break outer + } + if curElemTag(elemStack) != token.Data { + tokenErr = fmt.Errorf("end tag %q does not match start tag %q", token.Data, curElemTag(elemStack)) + break outer + } + elemStack = popElemStack(elemStack) + case htmltoken.SelfClosingTagToken: + if token.Data == "bind" { + keyAttr := getAttr(token, "key") + dataVal := data[keyAttr] + elemList := partToElems(dataVal) + for _, elem := range elemList { + appendChildToStack(elemStack, &elem) + } + continue + } + elem := tokenToElem(token, data) + appendChildToStack(elemStack, elem) + case htmltoken.TextToken: + if token.Data == "" { + continue + } + textStr := processTextStr(token.Data) + if textStr == "" { + continue + } + elem := TextElem(textStr) + appendChildToStack(elemStack, &elem) + case htmltoken.CommentToken: + continue + case htmltoken.DoctypeToken: + tokenErr = errors.New("doctype not supported") + break outer + case htmltoken.ErrorToken: + if iter.Err() == io.EOF { + break outer + } + tokenErr = iter.Err() + break outer + } + } + if tokenErr != nil { + errTextElem := TextElem(tokenErr.Error()) + appendChildToStack(elemStack, &errTextElem) + } + return finalizeStack(elemStack) +} diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go new file mode 100644 index 000000000..898ab61f8 --- /dev/null +++ b/pkg/vdom/vdom_root.go @@ -0,0 +1,328 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "fmt" + "log" + "reflect" + + "github.com/google/uuid" +) + +type vdomContextKeyType struct{} + +var vdomContextKey = vdomContextKeyType{} + +type VDomContextVal struct { + Root *RootElem + Comp *Component + HookIdx int +} + +type RootElem struct { + OuterCtx context.Context + Root *Component + CFuncs map[string]CFunc + CompMap map[string]*Component // component id -> component + EffectWorkQueue []*EffectWorkElem + NeedsRenderMap map[string]bool +} + +const ( + WorkType_Render = "render" + WorkType_Effect = "effect" +) + +type EffectWorkElem struct { + Id string + EffectIndex int +} + +func (r *RootElem) AddRenderWork(id string) { + if r.NeedsRenderMap == nil { + r.NeedsRenderMap = make(map[string]bool) + } + r.NeedsRenderMap[id] = true +} + +func (r *RootElem) AddEffectWork(id string, effectIndex int) { + r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{Id: id, EffectIndex: effectIndex}) +} + +func MakeRoot() *RootElem { + return &RootElem{ + Root: nil, + CFuncs: make(map[string]CFunc), + CompMap: make(map[string]*Component), + } +} + +func (r *RootElem) SetOuterCtx(ctx context.Context) { + r.OuterCtx = ctx +} + +func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { + r.CFuncs[name] = cfunc +} + +func (r *RootElem) Render(elem *Elem) { + log.Printf("Render %s\n", elem.Tag) + r.render(elem, &r.Root) +} + +func (r *RootElem) Event(id string, propName string) { + 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() +} + +// 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() { + workQueue := r.EffectWorkQueue + r.EffectWorkQueue = nil + // first, run effect cleanups + for _, work := range workQueue { + comp := r.CompMap[work.Id] + if comp == nil { + continue + } + hook := comp.Hooks[work.EffectIndex] + if hook.UnmountFn != nil { + hook.UnmountFn() + } + } + // now run, new effects + for _, work := range workQueue { + comp := r.CompMap[work.Id] + if comp == nil { + continue + } + hook := comp.Hooks[work.EffectIndex] + if hook.Fn != nil { + hook.UnmountFn = hook.Fn() + } + } + // now check if we need a render + if len(r.NeedsRenderMap) > 0 { + r.NeedsRenderMap = nil + r.render(r.Root.Elem, &r.Root) + } +} + +func (r *RootElem) render(elem *Elem, comp **Component) { + if elem == nil || elem.Tag == "" { + r.unmount(comp) + return + } + elemKey := elem.Key() + if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) { + r.unmount(comp) + r.createComp(elem.Tag, elemKey, comp) + } + (*comp).Elem = elem + if elem.Tag == TextTag { + r.renderText(elem.Text, comp) + return + } + if isBaseTag(elem.Tag) { + // simple vdom, fragment, wave element + r.renderSimple(elem, comp) + return + } + cfunc := r.CFuncs[elem.Tag] + if cfunc == nil { + text := fmt.Sprintf("<%s>", elem.Tag) + r.renderText(text, comp) + return + } + r.renderComponent(cfunc, elem, comp) +} + +func (r *RootElem) unmount(comp **Component) { + if *comp == nil { + return + } + // parent clean up happens first + for _, hook := range (*comp).Hooks { + if hook.UnmountFn != nil { + hook.UnmountFn() + } + } + // clean up any children + if (*comp).Comp != nil { + r.unmount(&(*comp).Comp) + } + if (*comp).Children != nil { + for _, child := range (*comp).Children { + r.unmount(&child) + } + } + delete(r.CompMap, (*comp).Id) + *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 +} + +func (r *RootElem) renderText(text string, comp **Component) { + if (*comp).Text != text { + (*comp).Text = text + } +} + +func (r *RootElem) renderChildren(elems []Elem, curChildren []*Component) []*Component { + newChildren := make([]*Component, len(elems)) + curCM := make(map[ChildKey]*Component) + usedMap := make(map[*Component]bool) + for idx, child := range curChildren { + if child.Key != "" { + curCM[ChildKey{Tag: child.Tag, Idx: 0, Key: child.Key}] = child + } else { + curCM[ChildKey{Tag: child.Tag, Idx: idx, Key: ""}] = child + } + } + for idx, elem := range elems { + elemKey := elem.Key() + var curChild *Component + if elemKey != "" { + curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}] + } else { + curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}] + } + usedMap[curChild] = true + newChildren[idx] = curChild + r.render(&elem, &newChildren[idx]) + } + for _, child := range curChildren { + if !usedMap[child] { + r.unmount(&child) + } + } + return newChildren +} + +func (r *RootElem) renderSimple(elem *Elem, comp **Component) { + if (*comp).Comp != nil { + r.unmount(&(*comp).Comp) + } + (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) +} + +func (r *RootElem) makeRenderContext(comp *Component) context.Context { + var ctx context.Context + if r.OuterCtx != nil { + ctx = r.OuterCtx + } else { + ctx = context.Background() + } + ctx = context.WithValue(ctx, vdomContextKey, &VDomContextVal{Root: r, Comp: comp, HookIdx: 0}) + return ctx +} + +func getRenderContext(ctx context.Context) *VDomContextVal { + v := ctx.Value(vdomContextKey) + if v == nil { + return nil + } + return v.(*VDomContextVal) +} + +func (r *RootElem) renderComponent(cfunc CFunc, elem *Elem, comp **Component) { + if (*comp).Children != nil { + for _, child := range (*comp).Children { + r.unmount(&child) + } + (*comp).Children = nil + } + props := make(map[string]any) + for k, v := range elem.Props { + props[k] = v + } + props[ChildrenPropKey] = elem.Children + ctx := r.makeRenderContext(*comp) + renderedElem := cfunc(ctx, props) + rtnElemArr := partToElems(renderedElem) + if len(rtnElemArr) == 0 { + r.unmount(&(*comp).Comp) + return + } + var rtnElem *Elem + if len(rtnElemArr) == 1 { + rtnElem = &rtnElemArr[0] + } else { + rtnElem = &Elem{Tag: FragmentTag, Children: rtnElemArr} + } + r.render(rtnElem, &(*comp).Comp) +} + +func convertPropsToVDom(props map[string]any) map[string]any { + if len(props) == 0 { + return nil + } + vdomProps := make(map[string]any) + for k, v := range props { + if v == nil { + continue + } + val := reflect.ValueOf(v) + if val.Kind() == reflect.Func { + vdomProps[k] = VDomFuncType{FuncType: "server"} + continue + } + vdomProps[k] = v + } + return vdomProps +} + +func convertBaseToVDom(c *Component) *Elem { + elem := &Elem{Id: c.Id, Tag: c.Tag} + if c.Elem != nil { + elem.Props = convertPropsToVDom(c.Elem.Props) + } + for _, child := range c.Children { + childVDom := convertToVDom(child) + if childVDom != nil { + elem.Children = append(elem.Children, *childVDom) + } + } + return elem +} + +func convertToVDom(c *Component) *Elem { + if c == nil { + return nil + } + if c.Tag == TextTag { + return &Elem{Tag: TextTag, Text: c.Text} + } + if isBaseTag(c.Tag) { + return convertBaseToVDom(c) + } else { + return convertToVDom(c.Comp) + } +} + +func (r *RootElem) makeVDom(comp *Component) *Elem { + vdomElem := convertToVDom(comp) + return vdomElem +} + +func (r *RootElem) MakeVDom() *Elem { + return r.makeVDom(r.Root) +} diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go new file mode 100644 index 000000000..430e07ff3 --- /dev/null +++ b/pkg/vdom/vdom_test.go @@ -0,0 +1,120 @@ +package vdom + +import ( + "context" + "encoding/json" + "fmt" + "log" + "testing" +) + +type renderContextKeyType struct{} + +var renderContextKey = renderContextKeyType{} + +type TestContext struct { + ButtonId string +} + +func Page(ctx context.Context, props map[string]any) any { + clicked, setClicked := UseState(ctx, false) + var clickedDiv *Elem + if clicked { + clickedDiv = Bind(`
clicked
`, nil) + } + clickFn := func() { + log.Printf("run clickFn\n") + setClicked(true) + } + return Bind( + ` +
+

hello world

+ + +
+`, + map[string]any{"clickFn": clickFn, "clickedDiv": clickedDiv}, + ) +} + +func Button(ctx context.Context, props map[string]any) any { + ref := UseRef(ctx, nil) + clName, setClName := UseState(ctx, "button") + UseEffect(ctx, func() func() { + fmt.Printf("Button useEffect\n") + setClName("button mounted") + return nil + }, nil) + compId := UseId(ctx) + testContext := getTestContext(ctx) + if testContext != nil { + testContext.ButtonId = compId + } + return Bind(` +
+ +
+ `, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]}) +} + +func printVDom(root *RootElem) { + vd := root.MakeVDom() + jsonBytes, _ := json.MarshalIndent(vd, "", " ") + fmt.Printf("%s\n", string(jsonBytes)) +} + +func getTestContext(ctx context.Context) *TestContext { + val := ctx.Value(renderContextKey) + if val == nil { + return nil + } + return val.(*TestContext) +} + +func Test1(t *testing.T) { + log.Printf("hello!\n") + testContext := &TestContext{ButtonId: ""} + ctx := context.WithValue(context.Background(), renderContextKey, testContext) + root := MakeRoot() + root.SetOuterCtx(ctx) + root.RegisterComponent("Page", Page) + root.RegisterComponent("Button", Button) + root.Render(E("Page")) + if root.Root == nil { + t.Fatalf("root.Root is nil") + } + printVDom(root) + root.runWork() + printVDom(root) + root.Event(testContext.ButtonId, "onClick") + root.runWork() + printVDom(root) +} + +func TestBind(t *testing.T) { + elem := Bind(`
clicked
`, nil) + jsonBytes, _ := json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(` +
+ clicked +
`, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(``, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) + + elem = Bind(` +
+

hello world

+ + +
+`, nil) + jsonBytes, _ = json.MarshalIndent(elem, "", " ") + log.Printf("%s\n", string(jsonBytes)) +} diff --git a/pkg/waveobj/waveobj.go b/pkg/waveobj/waveobj.go index 2c3ddc261..799c35710 100644 --- a/pkg/waveobj/waveobj.go +++ b/pkg/waveobj/waveobj.go @@ -33,6 +33,9 @@ type ORef struct { } func (oref ORef) String() string { + if oref.OType == "" || oref.OID == "" { + return "" + } return fmt.Sprintf("%s:%s", oref.OType, oref.OID) } @@ -51,6 +54,11 @@ func (oref *ORef) UnmarshalJSON(data []byte) error { if err != nil { return err } + if len(orefStr) == 0 { + oref.OType = "" + oref.OID = "" + return nil + } parsed, err := ParseORef(orefStr) if err != nil { return err diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index ace45d172..197f96df5 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -36,7 +36,7 @@ func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, o } // command "file:append", wshserver.AppendFileCommand -func AppendFileCommand(w *wshutil.WshRpc, data wshrpc.CommandAppendFileData, opts *wshrpc.WshRpcCommandOpts) error { +func AppendFileCommand(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.WshRpcCommandOpts) error { _, err := sendRpcRequestCallHelper[any](w, "file:append", data, opts) return err } @@ -47,6 +47,18 @@ func AppendIJsonCommand(w *wshutil.WshRpc, data wshrpc.CommandAppendIJsonData, o return err } +// command "file:read", wshserver.ReadFile +func ReadFile(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.WshRpcCommandOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "file:read", data, opts) + return resp, err +} + +// command "file:write", wshserver.WriteFile +func WriteFile(w *wshutil.WshRpc, data wshrpc.CommandFileData, opts *wshrpc.WshRpcCommandOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "file:write", data, opts) + return err +} + // command "getmeta", wshserver.GetMetaCommand func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wshrpc.WshRpcCommandOpts) (map[string]interface {}, error) { resp, err := sendRpcRequestCallHelper[map[string]interface {}](w, "getmeta", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 008af941d..a0d224783 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -26,6 +26,8 @@ const ( Command_ResolveIds = "resolveids" Command_CreateBlock = "createblock" Command_DeleteBlock = "deleteblock" + Command_WriteFile = "file:write" + Command_ReadFile = "file:read" ) type MetaDataType = map[string]any @@ -123,10 +125,10 @@ type CommandBlockInputData struct { TermSize *shellexec.TermSize `json:"termsize,omitempty"` } -type CommandAppendFileData struct { +type CommandFileData struct { ZoneId string `json:"zoneid" wshcontext:"BlockId"` FileName string `json:"filename"` - Data64 string `json:"data64"` + Data64 string `json:"data64,omitempty"` } type CommandAppendIJsonData struct { diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 80ae49858..18706238f 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -45,6 +45,8 @@ var WshServerCommandToDeclMap = map[string]*WshServerMethodDecl{ wshrpc.Command_AppendFile: GetWshServerMethod(wshrpc.Command_AppendFile, wshutil.RpcType_Call, "AppendFileCommand", WshServerImpl.AppendFileCommand), wshrpc.Command_AppendIJson: GetWshServerMethod(wshrpc.Command_AppendIJson, wshutil.RpcType_Call, "AppendIJsonCommand", WshServerImpl.AppendIJsonCommand), wshrpc.Command_DeleteBlock: GetWshServerMethod(wshrpc.Command_DeleteBlock, wshutil.RpcType_Call, "DeleteBlockCommand", WshServerImpl.DeleteBlockCommand), + wshrpc.Command_WriteFile: GetWshServerMethod(wshrpc.Command_WriteFile, wshutil.RpcType_Call, "WriteFile", WshServerImpl.WriteFile), + wshrpc.Command_ReadFile: GetWshServerMethod(wshrpc.Command_ReadFile, wshutil.RpcType_Call, "ReadFile", WshServerImpl.ReadFile), "streamtest": RespStreamTest_MethodDecl, } @@ -80,11 +82,11 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM } func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error { + log.Printf("SETMETA: %s | %v\n", data.ORef, data.Meta) oref := data.ORef if oref.IsEmpty() { return fmt.Errorf("no oref") } - log.Printf("SETMETA: %s | %v\n", oref, data.Meta) obj, err := wstore.DBGetORef(ctx, oref) if err != nil { return fmt.Errorf("error getting object: %w", err) @@ -249,7 +251,36 @@ func (ws *WshServer) BlockInputCommand(ctx context.Context, data wshrpc.CommandB return bc.SendInput(inputUnion) } -func (ws *WshServer) AppendFileCommand(ctx context.Context, data wshrpc.CommandAppendFileData) error { +func (ws *WshServer) WriteFile(ctx context.Context, data wshrpc.CommandFileData) error { + dataBuf, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return fmt.Errorf("error decoding data64: %w", err) + } + err = filestore.WFS.WriteFile(ctx, data.ZoneId, data.FileName, dataBuf) + if err != nil { + return fmt.Errorf("error writing to blockfile: %w", err) + } + eventbus.SendEvent(eventbus.WSEventType{ + EventType: eventbus.WSEvent_BlockFile, + ORef: waveobj.MakeORef(wstore.OType_Block, data.ZoneId).String(), + Data: &eventbus.WSFileEventData{ + ZoneId: data.ZoneId, + FileName: data.FileName, + FileOp: eventbus.FileOp_Invalidate, + }, + }) + return nil +} + +func (ws *WshServer) ReadFile(ctx context.Context, data wshrpc.CommandFileData) (string, error) { + _, dataBuf, err := filestore.WFS.ReadFile(ctx, data.ZoneId, data.FileName) + if err != nil { + return "", fmt.Errorf("error reading blockfile: %w", err) + } + return base64.StdEncoding.EncodeToString(dataBuf), nil +} + +func (ws *WshServer) AppendFileCommand(ctx context.Context, data wshrpc.CommandFileData) error { dataBuf, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return fmt.Errorf("error decoding data64: %w", err) diff --git a/pkg/wshutil/wshutil.go b/pkg/wshutil/wshutil.go index 16703eaee..adf8b5cb8 100644 --- a/pkg/wshutil/wshutil.go +++ b/pkg/wshutil/wshutil.go @@ -7,6 +7,15 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "log" + "os" + "os/signal" + "sync" + "sync/atomic" + "syscall" + + "golang.org/x/term" ) // these should both be 5 characters @@ -94,3 +103,89 @@ func EncodeWaveOSCMessageEx(oscNum string, msg *RpcMessage) ([]byte, error) { } return EncodeWaveOSCBytes(oscNum, barr), nil } + +var termModeLock = sync.Mutex{} +var termIsRaw bool +var origTermState *term.State +var shutdownSignalHandlersInstalled bool +var shutdownOnce sync.Once +var extraShutdownFunc atomic.Pointer[func()] + +func DoShutdown(reason string, exitCode int, quiet bool) { + shutdownOnce.Do(func() { + defer os.Exit(exitCode) + RestoreTermState() + extraFn := extraShutdownFunc.Load() + if extraFn != nil { + (*extraFn)() + } + if !quiet && reason != "" { + log.Printf("shutting down: %s\r\n", reason) + } + }) +} + +func installShutdownSignalHandlers(quiet bool) { + termModeLock.Lock() + defer termModeLock.Unlock() + if shutdownSignalHandlersInstalled { + return + } + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) + go func() { + for sig := range sigCh { + DoShutdown(fmt.Sprintf("got signal %v", sig), 1, quiet) + break + } + }() +} + +func SetTermRawModeAndInstallShutdownHandlers(quietShutdown bool) { + SetTermRawMode() + installShutdownSignalHandlers(quietShutdown) +} + +func SetExtraShutdownFunc(fn func()) { + extraShutdownFunc.Store(&fn) +} + +func SetTermRawMode() { + termModeLock.Lock() + defer termModeLock.Unlock() + if termIsRaw { + return + } + origState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + fmt.Fprintf(os.Stderr, "Error setting raw mode: %v\n", err) + return + } + origTermState = origState + termIsRaw = true +} + +func RestoreTermState() { + termModeLock.Lock() + defer termModeLock.Unlock() + if !termIsRaw || origTermState == nil { + return + } + term.Restore(int(os.Stdin.Fd()), origTermState) + termIsRaw = false +} + +// returns (wshRpc, wrappedStdin) +func SetupTerminalRpcClient(handlerFn func(*RpcResponseHandler) bool) (*WshRpc, io.Reader) { + messageCh := make(chan []byte, 32) + outputCh := make(chan []byte, 32) + ptyBuf := MakePtyBuffer(WaveServerOSCPrefix, os.Stdin, messageCh) + rpcClient := MakeWshRpc(messageCh, outputCh, RpcContext{}, handlerFn) + go func() { + for msg := range outputCh { + barr := EncodeWaveOSCBytes(WaveOSC, msg) + os.Stdout.Write(barr) + } + }() + return rpcClient, ptyBuf +}