diff --git a/cmd/test/test-main.go b/cmd/test/test-main.go
index 502da149..15264ec3 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 b8c4315d..a1a52307 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 dc50b65d..a7a5a892 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 7d5a97dd..912a3b28 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 00000000..6c6129eb
--- /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 a1fa5844..2ca5275a 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 84b1018e..1804beaa 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 2ba2b6b5..2e198f6b 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 22117324..ac45fcdc 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 5ecf24bf..62f77003 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 00000000..4e87ed46
--- /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 fb8152be..34cc5e18 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 6aba4081..a876d056 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 f209f5f6..f5b9bc7d 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 a04f0450..7c1ba715 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 b7b97544..1e5b14ec 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 5ea6fd5a..4a9e729a 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 a69c2d92..f6a24418 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 00000000..4cab277b
--- /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 00000000..3b51701a
--- /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 00000000..6e8586d2
--- /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 00000000..898ab61f
--- /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 00000000..430e07ff
--- /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 2c3ddc26..799c3571 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 ace45d17..197f96df 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 008af941..a0d22478 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 80ae4985..18706238 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 16703eae..adf8b5cb 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
+}