working on vdom implementation, other fixes (#136)

This commit is contained in:
Mike Sawka 2024-07-23 13:16:53 -07:00 committed by GitHub
parent e6f60ff210
commit 6c2ef6cb99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1503 additions and 138 deletions

View File

@ -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(`<div>clicked</div>`, nil)
}
return style
clickFn := func() {
log.Printf("run clickFn\n")
setClicked(true)
}
return vdom.Bind(
`
<div>
<h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button>
<bind key="clickedDiv"/>
</div>
`,
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(`
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick">
<bind key="children"/>
</div>
`, map[string]any{"clName": clName, "ref": ref, "onClick": props["onClick"], "children": props["children"]})
}
func main() {
wshutil.SetTermRawModeAndInstallShutdownHandlers(true)
defer wshutil.RestoreTermState()
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}
}

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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)

View File

@ -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",

View File

@ -28,7 +28,7 @@ class WshServerType {
}
// command "file:append" [call]
AppendFileCommand(data: CommandAppendFileData, opts?: WshRpcCommandOpts): Promise<void> {
AppendFileCommand(data: CommandFileData, opts?: WshRpcCommandOpts): Promise<void> {
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<string> {
return WOS.wshServerRpcHelper_call("file:read", data, opts);
}
// command "file:write" [call]
WriteFile(data: CommandFileData, opts?: WshRpcCommandOpts): Promise<void> {
return WOS.wshServerRpcHelper_call("file:write", data, opts);
}
// command "getmeta" [call]
GetMetaCommand(data: CommandGetMetaData, opts?: WshRpcCommandOpts): Promise<MetaType> {
return WOS.wshServerRpcHelper_call("getmeta", data, opts);

View File

@ -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
/>
</div>
<div key="htmlElemContent" className="term-htmlelem-content">
<IJsonView rootNode={IJSONConst} />
<VDomView rootNode={testVDom} />
</div>
</div>
</div>

View File

@ -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 <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
}
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 <div className="vdom">{rtn}</div>;
}
export { VDomView };

View File

@ -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 );

6
go.mod
View File

@ -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
)

4
go.sum
View File

@ -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=

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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) {

270
pkg/vdom/vdom.go Normal file
View File

@ -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)
}

40
pkg/vdom/vdom_comp.go Normal file
View File

@ -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
}

253
pkg/vdom/vdom_html.go Normal file
View File

@ -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)
}

328
pkg/vdom/vdom_root.go Normal file
View File

@ -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)
}

120
pkg/vdom/vdom_test.go Normal file
View File

@ -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(`<div>clicked</div>`, nil)
}
clickFn := func() {
log.Printf("run clickFn\n")
setClicked(true)
}
return Bind(
`
<div>
<h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button>
<bind key="clickedDiv"/>
</div>
`,
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(`
<div className="#bind:clName" ref="#bind:ref" onClick="#bind:onClick">
<bind key="children"/>
</div>
`, 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(`<div>clicked</div>`, nil)
jsonBytes, _ := json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
elem = Bind(`
<div>
clicked
</div>`, nil)
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
elem = Bind(`<Button>foo</Button>`, nil)
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
elem = Bind(`
<div>
<h1>hello world</h1>
<Button onClick="#bind:clickFn">hello</Button>
<bind key="clickedDiv"/>
</div>
`, nil)
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
}

View File

@ -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

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -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
}