From a4def074174a77a10a5a35618a00b0ca7cf6d0f5 Mon Sep 17 00:00:00 2001 From: sawka Date: Sat, 30 Aug 2025 21:03:34 -0700 Subject: [PATCH 001/134] copy vdom to tsunami/vdom --- tsunami/go.mod | 12 + tsunami/go.sum | 6 + tsunami/util/compare.go | 166 +++++++ tsunami/util/marshal.go | 121 +++++ tsunami/vdom/cssparser/cssparser.go | 159 ++++++ tsunami/vdom/cssparser/cssparser_test.go | 81 ++++ tsunami/vdom/vdom.go | 521 ++++++++++++++++++++ tsunami/vdom/vdom_comp.go | 40 ++ tsunami/vdom/vdom_html.go | 403 +++++++++++++++ tsunami/vdom/vdom_root.go | 594 +++++++++++++++++++++++ tsunami/vdom/vdom_test.go | 185 +++++++ tsunami/vdom/vdom_types.go | 316 ++++++++++++ 12 files changed, 2604 insertions(+) create mode 100644 tsunami/go.mod create mode 100644 tsunami/go.sum create mode 100644 tsunami/util/compare.go create mode 100644 tsunami/util/marshal.go create mode 100644 tsunami/vdom/cssparser/cssparser.go create mode 100644 tsunami/vdom/cssparser/cssparser_test.go create mode 100644 tsunami/vdom/vdom.go create mode 100644 tsunami/vdom/vdom_comp.go create mode 100644 tsunami/vdom/vdom_html.go create mode 100644 tsunami/vdom/vdom_root.go create mode 100644 tsunami/vdom/vdom_test.go create mode 100644 tsunami/vdom/vdom_types.go diff --git a/tsunami/go.mod b/tsunami/go.mod new file mode 100644 index 0000000000..1a7e3b9b91 --- /dev/null +++ b/tsunami/go.mod @@ -0,0 +1,12 @@ +module tsunami + +go 1.22.4 + +toolchain go1.24.6 + +require ( + github.com/google/uuid v1.6.0 + github.com/wavetermdev/htmltoken v0.2.0 +) + +require golang.org/x/net v0.27.0 // indirect diff --git a/tsunami/go.sum b/tsunami/go.sum new file mode 100644 index 0000000000..338224a4d8 --- /dev/null +++ b/tsunami/go.sum @@ -0,0 +1,6 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= +github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= diff --git a/tsunami/util/compare.go b/tsunami/util/compare.go new file mode 100644 index 0000000000..41299af5e2 --- /dev/null +++ b/tsunami/util/compare.go @@ -0,0 +1,166 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "reflect" + "strconv" +) + +// this is a shallow equal, but with special handling for numeric types +// it will up convert to float64 and compare +func JsonValEqual(a, b any) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + typeA := reflect.TypeOf(a) + typeB := reflect.TypeOf(b) + if typeA == typeB && typeA.Comparable() { + return a == b + } + if IsNumericType(a) && IsNumericType(b) { + return CompareAsFloat64(a, b) + } + if typeA != typeB { + return false + } + // for slices and maps, compare their pointers + valA := reflect.ValueOf(a) + valB := reflect.ValueOf(b) + switch valA.Kind() { + case reflect.Slice, reflect.Map: + return valA.Pointer() == valB.Pointer() + } + return false +} + +// Helper to check if a value is a numeric type +func IsNumericType(val any) bool { + switch val.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } +} + +// Helper to handle numeric comparisons as float64 +func CompareAsFloat64(a, b any) bool { + valA, okA := ToFloat64(a) + valB, okB := ToFloat64(b) + return okA && okB && valA == valB +} + +// Convert various numeric types to float64 for comparison +func ToFloat64(val any) (float64, bool) { + if val == nil { + return 0, false + } + switch v := val.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + default: + return 0, false + } +} + +func ToInt64(val any) (int64, bool) { + if val == nil { + return 0, false + } + switch v := val.(type) { + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return int64(v), true + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return int64(v), true + case float32: + return int64(v), true + case float64: + return int64(v), true + default: + return 0, false + } +} + +func ToInt(val any) (int, bool) { + i, ok := ToInt64(val) + if !ok { + return 0, false + } + return int(i), true +} + +func NumToString[T any](value T) (string, bool) { + switch v := any(value).(type) { + case int: + return strconv.FormatInt(int64(v), 10), true + case int8: + return strconv.FormatInt(int64(v), 10), true + case int16: + return strconv.FormatInt(int64(v), 10), true + case int32: + return strconv.FormatInt(int64(v), 10), true + case int64: + return strconv.FormatInt(v, 10), true + case uint: + return strconv.FormatUint(uint64(v), 10), true + case uint8: + return strconv.FormatUint(uint64(v), 10), true + case uint16: + return strconv.FormatUint(uint64(v), 10), true + case uint32: + return strconv.FormatUint(uint64(v), 10), true + case uint64: + return strconv.FormatUint(v, 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 + } +} \ No newline at end of file diff --git a/tsunami/util/marshal.go b/tsunami/util/marshal.go new file mode 100644 index 0000000000..7ae33f42d7 --- /dev/null +++ b/tsunami/util/marshal.go @@ -0,0 +1,121 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "reflect" + "strings" +) + +func MapToStruct(in map[string]any, out any) error { + // Check that out is a pointer + outValue := reflect.ValueOf(out) + if outValue.Kind() != reflect.Ptr { + return fmt.Errorf("out parameter must be a pointer, got %v", outValue.Kind()) + } + + // Get the struct it points to + elem := outValue.Elem() + if elem.Kind() != reflect.Struct { + return fmt.Errorf("out parameter must be a pointer to struct, got pointer to %v", elem.Kind()) + } + + // Get type information + typ := elem.Type() + + // For each field in the struct + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + name := getJSONName(field) + if value, ok := in[name]; ok { + if err := setValue(elem.Field(i), value); err != nil { + return fmt.Errorf("error setting field %s: %w", name, err) + } + } + } + + return nil +} + +func StructToMap(in any) (map[string]any, error) { + // Get value and handle pointer + val := reflect.ValueOf(in) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + // Check that we have a struct + if val.Kind() != reflect.Struct { + return nil, fmt.Errorf("input must be a struct or pointer to struct, got %v", val.Kind()) + } + + // Get type information + typ := val.Type() + out := make(map[string]any) + + // For each field in the struct + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + + // Skip unexported fields + if !field.IsExported() { + continue + } + + name := getJSONName(field) + out[name] = val.Field(i).Interface() + } + + return out, nil +} + +// getJSONName returns the field name to use for JSON mapping +func getJSONName(field reflect.StructField) string { + tag := field.Tag.Get("json") + if tag == "" || tag == "-" { + return field.Name + } + return strings.Split(tag, ",")[0] +} + +// setValue attempts to set a reflect.Value with a given interface{} value +func setValue(field reflect.Value, value any) error { + if value == nil { + return nil + } + + valueRef := reflect.ValueOf(value) + + // Direct assignment if types are exactly equal + if valueRef.Type() == field.Type() { + field.Set(valueRef) + return nil + } + + // Check if types are assignable + if valueRef.Type().AssignableTo(field.Type()) { + field.Set(valueRef) + return nil + } + + // If field is pointer and value isn't already a pointer, try address + if field.Kind() == reflect.Ptr && valueRef.Kind() != reflect.Ptr { + return setValue(field, valueRef.Addr().Interface()) + } + + // Try conversion if types are convertible + if valueRef.Type().ConvertibleTo(field.Type()) { + field.Set(valueRef.Convert(field.Type())) + return nil + } + + return fmt.Errorf("cannot set value of type %v to field of type %v", valueRef.Type(), field.Type()) +} \ No newline at end of file diff --git a/tsunami/vdom/cssparser/cssparser.go b/tsunami/vdom/cssparser/cssparser.go new file mode 100644 index 0000000000..a430624d5f --- /dev/null +++ b/tsunami/vdom/cssparser/cssparser.go @@ -0,0 +1,159 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "strings" + "unicode" +) + +type Parser struct { + Input string + Pos int + Length int + InQuote bool + QuoteChar rune + OpenParens int + Debug bool +} + +func MakeParser(input string) *Parser { + return &Parser{ + Input: input, + Length: len(input), + } +} + +func (p *Parser) Parse() (map[string]string, error) { + result := make(map[string]string) + lastProp := "" + for { + p.skipWhitespace() + if p.eof() { + break + } + propName, err := p.parseIdentifierColon(lastProp) + if err != nil { + return nil, err + } + lastProp = propName + p.skipWhitespace() + value, err := p.parseValue(propName) + if err != nil { + return nil, err + } + result[propName] = value + p.skipWhitespace() + if p.eof() { + break + } + if !p.expectChar(';') { + break + } + } + p.skipWhitespace() + if !p.eof() { + return nil, fmt.Errorf("bad style attribute, unexpected character %q at pos %d", string(p.Input[p.Pos]), p.Pos+1) + } + return result, nil +} + +func (p *Parser) parseIdentifierColon(lastProp string) (string, error) { + start := p.Pos + for !p.eof() { + c := p.peekChar() + if isIdentChar(c) || c == '-' { + p.advance() + } else { + break + } + } + attrName := p.Input[start:p.Pos] + p.skipWhitespace() + if p.eof() { + return "", fmt.Errorf("bad style attribute, expected colon after property %q, got EOF, at pos %d", attrName, p.Pos+1) + } + if attrName == "" { + return "", fmt.Errorf("bad style attribute, invalid property name after property %q, at pos %d", lastProp, p.Pos+1) + } + if !p.expectChar(':') { + return "", fmt.Errorf("bad style attribute, bad property name starting with %q, expected colon, got %q, at pos %d", attrName, string(p.Input[p.Pos]), p.Pos+1) + } + return attrName, nil +} + +func (p *Parser) parseValue(propName string) (string, error) { + start := p.Pos + quotePos := 0 + parenPosStack := make([]int, 0) + for !p.eof() { + c := p.peekChar() + if p.InQuote { + if c == p.QuoteChar { + p.InQuote = false + } else if c == '\\' { + p.advance() + } + } else { + if c == '"' || c == '\'' { + p.InQuote = true + p.QuoteChar = c + quotePos = p.Pos + } else if c == '(' { + p.OpenParens++ + parenPosStack = append(parenPosStack, p.Pos) + } else if c == ')' { + if p.OpenParens == 0 { + return "", fmt.Errorf("unmatched ')' at pos %d", p.Pos+1) + } + p.OpenParens-- + parenPosStack = parenPosStack[:len(parenPosStack)-1] + } else if c == ';' && p.OpenParens == 0 { + break + } + } + p.advance() + } + if p.eof() && p.InQuote { + return "", fmt.Errorf("bad style attribute, while parsing attribute %q, unmatched quote at pos %d", propName, quotePos+1) + } + if p.eof() && p.OpenParens > 0 { + return "", fmt.Errorf("bad style attribute, while parsing property %q, unmatched '(' at pos %d", propName, parenPosStack[len(parenPosStack)-1]+1) + } + return strings.TrimSpace(p.Input[start:p.Pos]), nil +} + +func isIdentChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func (p *Parser) skipWhitespace() { + for !p.eof() && unicode.IsSpace(p.peekChar()) { + p.advance() + } +} + +func (p *Parser) expectChar(expected rune) bool { + if !p.eof() && p.peekChar() == expected { + p.advance() + return true + } + return false +} + +func (p *Parser) peekChar() rune { + if p.Pos >= p.Length { + return 0 + } + return rune(p.Input[p.Pos]) +} + +func (p *Parser) advance() { + p.Pos++ +} + +func (p *Parser) eof() bool { + return p.Pos >= p.Length +} \ No newline at end of file diff --git a/tsunami/vdom/cssparser/cssparser_test.go b/tsunami/vdom/cssparser/cssparser_test.go new file mode 100644 index 0000000000..a2ea46bd66 --- /dev/null +++ b/tsunami/vdom/cssparser/cssparser_test.go @@ -0,0 +1,81 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cssparser + +import ( + "fmt" + "log" + "testing" +) + +func compareMaps(a, b map[string]string) error { + if len(a) != len(b) { + return fmt.Errorf("map length mismatch: %d != %d", len(a), len(b)) + } + for k, v := range a { + if b[k] != v { + return fmt.Errorf("value mismatch for key %s: %q != %q", k, v, b[k]) + } + } + return nil +} + +func TestParse1(t *testing.T) { + style := `background: url("example;with;semicolons.jpg"); color: red; margin-right: 5px; content: "hello;world";` + p := MakeParser(style) + parsed, err := p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected := map[string]string{ + "background": `url("example;with;semicolons.jpg")`, + "color": "red", + "margin-right": "5px", + "content": `"hello;world"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } + + style = `margin-right: calc(10px + 5px); color: red; font-family: "Arial";` + p = MakeParser(style) + parsed, err = p.Parse() + if err != nil { + t.Fatalf("Parse failed: %v", err) + return + } + expected = map[string]string{ + "margin-right": `calc(10px + 5px)`, + "color": "red", + "font-family": `"Arial"`, + } + if err := compareMaps(parsed, expected); err != nil { + t.Fatalf("Parsed map does not match expected: %v", err) + } +} + +func TestParserErrors(t *testing.T) { + style := `hello more: bad;` + p := MakeParser(style) + _, err := p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `background: url("example.jpg` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) + style = `foo: url(...` + p = MakeParser(style) + _, err = p.Parse() + if err == nil { + t.Fatalf("expected error, got nil") + } + log.Printf("got expected error: %v\n", err) +} \ No newline at end of file diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go new file mode 100644 index 0000000000..f49743730b --- /dev/null +++ b/tsunami/vdom/vdom.go @@ -0,0 +1,521 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "fmt" + "log" + "reflect" + "strconv" + "strings" + "unicode" + + "tsunami/util" +) + +// ReactNode types = nil | string | Elem + +type Component[P any] func(props P) *VDomElem + +type styleAttrWrapper struct { + StyleAttr string + Val any +} + +type classAttrWrapper struct { + ClassName string + Cond bool +} + +type styleAttrMapWrapper struct { + StyleAttrMap map[string]any +} + +func (e *VDomElem) Key() string { + keyVal, ok := e.Props[KeyPropKey] + if !ok { + return "" + } + keyStr, ok := keyVal.(string) + if ok { + return keyStr + } + return "" +} + +func (e *VDomElem) WithKey(key string) *VDomElem { + if e == nil { + return nil + } + if e.Props == nil { + e.Props = make(map[string]any) + } + e.Props[KeyPropKey] = key + return e +} + +func TextElem(text string) VDomElem { + return VDomElem{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 mergeStyleAttr(props *map[string]any, styleAttr styleAttrWrapper) { + if *props == nil { + *props = make(map[string]any) + } + if (*props)["style"] == nil { + (*props)["style"] = make(map[string]any) + } + styleMap, ok := (*props)["style"].(map[string]any) + if !ok { + return + } + styleMap[styleAttr.StyleAttr] = styleAttr.Val +} + +func mergeClassAttr(props *map[string]any, classAttr classAttrWrapper) { + if *props == nil { + *props = make(map[string]any) + } + if classAttr.Cond { + if (*props)["className"] == nil { + (*props)["className"] = classAttr.ClassName + return + } + classVal, ok := (*props)["className"].(string) + if !ok { + return + } + // check if class already exists (must split, contains won't work) + splitArr := strings.Split(classVal, " ") + for _, class := range splitArr { + if class == classAttr.ClassName { + return + } + } + (*props)["className"] = classVal + " " + classAttr.ClassName + } else { + classVal, ok := (*props)["className"].(string) + if !ok { + return + } + splitArr := strings.Split(classVal, " ") + for i, class := range splitArr { + if class == classAttr.ClassName { + splitArr = append(splitArr[:i], splitArr[i+1:]...) + break + } + } + if len(splitArr) == 0 { + delete(*props, "className") + } else { + (*props)["className"] = strings.Join(splitArr, " ") + } + } +} + +func Classes(classes ...any) string { + var parts []string + for _, class := range classes { + switch c := class.(type) { + case nil: + continue + case string: + if c != "" { + parts = append(parts, c) + } + } + // Ignore any other types + } + return strings.Join(parts, " ") +} + +func H(tag string, props map[string]any, children ...any) *VDomElem { + rtn := &VDomElem{Tag: tag, Props: props} + if len(children) > 0 { + for _, part := range children { + elems := partToElems(part) + rtn.Children = append(rtn.Children, elems...) + } + } + return rtn +} + +func E(tag string, parts ...any) *VDomElem { + rtn := &VDomElem{Tag: tag} + for _, part := range parts { + if part == nil { + continue + } + props, ok := part.(map[string]any) + if ok { + mergeProps(&rtn.Props, props) + continue + } + if styleAttr, ok := part.(styleAttrWrapper); ok { + mergeStyleAttr(&rtn.Props, styleAttr) + continue + } + if styleAttrMap, ok := part.(styleAttrMapWrapper); ok { + for k, v := range styleAttrMap.StyleAttrMap { + mergeStyleAttr(&rtn.Props, styleAttrWrapper{StyleAttr: k, Val: v}) + } + continue + } + if classAttr, ok := part.(classAttrWrapper); ok { + mergeClassAttr(&rtn.Props, classAttr) + continue + } + elems := partToElems(part) + rtn.Children = append(rtn.Children, elems...) + } + return rtn +} + +func Class(name string) classAttrWrapper { + return classAttrWrapper{ClassName: name, Cond: true} +} + +func ClassIf(cond bool, name string) classAttrWrapper { + return classAttrWrapper{ClassName: name, Cond: cond} +} + +func ClassIfElse(cond bool, name string, elseName string) classAttrWrapper { + if cond { + return classAttrWrapper{ClassName: name, Cond: true} + } + return classAttrWrapper{ClassName: elseName, Cond: true} +} + +func If(cond bool, part any) any { + if cond { + return part + } + return nil +} + +func IfElse(cond bool, part any, elsePart any) any { + if cond { + return part + } + return elsePart +} + +func ForEach[T any](items []T, fn func(T) any) []any { + var elems []any + for _, item := range items { + fnResult := fn(item) + elems = append(elems, fnResult) + } + return elems +} + +func ForEachIdx[T any](items []T, fn func(T, int) any) []any { + var elems []any + for idx, item := range items { + fnResult := fn(item, idx) + elems = append(elems, fnResult) + } + return elems +} + +func Filter[T any](items []T, fn func(T) bool) []T { + var elems []T + for _, item := range items { + if fn(item) { + elems = append(elems, item) + } + } + return elems +} + +func FilterIdx[T any](items []T, fn func(T, int) bool) []T { + var elems []T + for idx, item := range items { + if fn(item, idx) { + elems = append(elems, item) + } + } + return elems +} + +func Props(props any) map[string]any { + m, err := util.StructToMap(props) + if err != nil { + return nil + } + return m +} + +func PStyle(styleAttr string, propVal any) any { + return styleAttrWrapper{StyleAttr: styleAttr, Val: propVal} +} + +func Fragment(parts ...any) any { + return parts +} + +func P(propName string, propVal any) any { + if propVal == nil { + return map[string]any{propName: nil} + } + if propName == "style" { + strVal, ok := propVal.(string) + if ok { + styleMap, err := styleAttrStrToStyleMap(strVal, nil) + if err == nil { + return styleAttrMapWrapper{StyleAttrMap: styleMap} + } + log.Printf("Error parsing style attribute: %v\n", err) + return nil + } + } + 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.WaveId) + } + return rtnVal, setVal +} + +func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) 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.WaveId) + } + + setFuncVal := func(updateFunc func(T) T) { + hookVal.Val = updateFunc(hookVal.Val.(T)) + vc.Root.AddRenderWork(vc.Comp.WaveId) + } + + return rtnVal, setVal, setFuncVal +} + +func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + closedWaveId := vc.Comp.WaveId + hookVal.UnmountFn = func() { + atom := vc.Root.GetAtom(atomName) + delete(atom.UsedBy, closedWaveId) + } + } + atom := vc.Root.GetAtom(atomName) + atom.UsedBy[vc.Comp.WaveId] = true + atomVal, ok := atom.Val.(T) + if !ok { + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) + } + setVal := func(newVal T) { + atom.Val = newVal + for waveId := range atom.UsedBy { + vc.Root.AddRenderWork(waveId) + } + } + return atomVal, setVal +} + +func UseVDomRef(ctx context.Context) *VDomRef { + vc, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) + hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} + } + refVal, ok := hookVal.Val.(*VDomRef) + if !ok { + panic("UseRef hook value is not a ref (possible out of order or conditional hooks)") + } + return refVal +} + +func UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] { + _, hookVal := getHookFromCtx(ctx) + if !hookVal.Init { + hookVal.Init = true + hookVal.Val = &VDomSimpleRef[T]{Current: val} + } + refVal, ok := hookVal.Val.(*VDomSimpleRef[T]) + 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.WaveId +} + +func UseRenderTs(ctx context.Context) int64 { + vc := getRenderContext(ctx) + if vc == nil { + panic("UseRenderTs must be called within a component (no context)") + } + return vc.Root.RenderTs +} + +func QueueRefOp(ctx context.Context, ref *VDomRef, op VDomRefOperation) { + if ref == nil || !ref.HasCurrent { + return + } + vc := getRenderContext(ctx) + if vc == nil { + panic("QueueRefOp must be called within a component (no context)") + } + if op.RefId == "" { + op.RefId = ref.RefId + } + vc.Root.QueueRefOp(op) +} + +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.WaveId, hookVal.Idx) + return + } + if depsEqual(hookVal.Deps, deps) { + return + } + hookVal.Fn = fn + hookVal.Deps = deps + vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) +} + + +func partToElems(part any) []VDomElem { + if part == nil { + return nil + } + switch partTyped := part.(type) { + case string: + return []VDomElem{TextElem(partTyped)} + case VDomElem: + return []VDomElem{partTyped} + case *VDomElem: + if partTyped == nil { + return nil + } + return []VDomElem{*partTyped} + case []VDomElem: + return partTyped + case []*VDomElem: + var rtn []VDomElem + for _, elem := range partTyped { + if elem != nil { + rtn = append(rtn, *elem) + } + } + return rtn + case []any: + var rtn []VDomElem + for _, subPart := range partTyped { + rtn = append(rtn, partToElems(subPart)...) + } + return rtn + default: + partVal := reflect.ValueOf(part) + if partVal.Kind() == reflect.Slice { + var rtn []VDomElem + for i := 0; i < partVal.Len(); i++ { + rtn = append(rtn, partToElems(partVal.Index(i).Interface())...) + } + return rtn + } + strVal, ok := util.NumToString(part) + if ok { + return []VDomElem{TextElem(strVal)} + } + return nil + } +} + +func isBaseTag(tag string) bool { + if tag == "" { + return false + } + if tag == TextTag || tag == WaveTextTag || tag == WaveNullTag || tag == FragmentTag { + return true + } + if tag[0] == '#' { + return true + } + firstChar := rune(tag[0]) + return unicode.IsLower(firstChar) +} diff --git a/tsunami/vdom/vdom_comp.go b/tsunami/vdom/vdom_comp.go new file mode 100644 index 0000000000..37b973951f --- /dev/null +++ b/tsunami/vdom/vdom_comp.go @@ -0,0 +1,40 @@ +// Copyright 2025, 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 ComponentImpl struct { + WaveId string + Tag string + Key string + Elem *VDomElem + Mounted bool + + // hooks + Hooks []*Hook + + // #text component + Text string + + // base component -- vdom, wave elem, or #fragment + Children []*ComponentImpl + + // component -> component + Comp *ComponentImpl +} + +func (c *ComponentImpl) compMatch(tag string, key string) bool { + if c == nil { + return false + } + return c.Tag == tag && c.Key == key +} \ No newline at end of file diff --git a/tsunami/vdom/vdom_html.go b/tsunami/vdom/vdom_html.go new file mode 100644 index 0000000000..fa7ec8a27c --- /dev/null +++ b/tsunami/vdom/vdom_html.go @@ -0,0 +1,403 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "tsunami/vdom/cssparser" + + "github.com/wavetermdev/htmltoken" +) + +// can tokenize and bind HTML to Elems + +const Html_BindPrefix = "#bind:" +const Html_ParamPrefix = "#param:" +const Html_GlobalEventPrefix = "#globalevent" +const Html_BindParamTagName = "bindparam" +const Html_BindTagName = "bind" + +func appendChildToStack(stack []*VDomElem, child *VDomElem) { + if child == nil { + return + } + if len(stack) == 0 { + return + } + parent := stack[len(stack)-1] + parent.Children = append(parent.Children, *child) +} + +func pushElemStack(stack []*VDomElem, elem *VDomElem) []*VDomElem { + if elem == nil { + return stack + } + return append(stack, elem) +} + +func popElemStack(stack []*VDomElem) []*VDomElem { + 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 []*VDomElem) string { + if len(stack) == 0 { + return "" + } + return stack[len(stack)-1].Tag +} + +func finalizeStack(stack []*VDomElem) *VDomElem { + 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 +} + +// returns value, isjson +func getAttrString(token htmltoken.Token, key string) string { + for _, attr := range token.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func attrToProp(attrVal string, isJson bool, params map[string]any) any { + if isJson { + var val any + err := json.Unmarshal([]byte(attrVal), &val) + if err != nil { + return nil + } + unmStrVal, ok := val.(string) + if !ok { + return val + } + attrVal = unmStrVal + // fallthrough using the json str val + } + if strings.HasPrefix(attrVal, Html_ParamPrefix) { + bindKey := attrVal[len(Html_ParamPrefix):] + bindVal, ok := params[bindKey] + if !ok { + return nil + } + return bindVal + } + if strings.HasPrefix(attrVal, Html_BindPrefix) { + bindKey := attrVal[len(Html_BindPrefix):] + if bindKey == "" { + return nil + } + return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} + } + if strings.HasPrefix(attrVal, Html_GlobalEventPrefix) { + splitArr := strings.Split(attrVal, ":") + if len(splitArr) < 2 { + return nil + } + eventName := splitArr[1] + if eventName == "" { + return nil + } + return &VDomFunc{Type: ObjectType_Func, GlobalEvent: eventName} + } + return attrVal +} + +func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { + elem := &VDomElem{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 + } + propVal := attrToProp(attr.Val, attr.IsJson, params) + elem.Props[attr.Key] = propVal + } + 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 makePathStr(elemPath []string) string { + return strings.Join(elemPath, " ") +} + +func capitalizeAscii(s string) string { + if s == "" || s[0] < 'a' || s[0] > 'z' { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +func toReactName(input string) string { + // Check for CSS custom properties (variables) which start with '--' + if strings.HasPrefix(input, "--") { + return input + } + parts := strings.Split(input, "-") + result := "" + index := 0 + if parts[0] == "" && len(parts) > 1 { + // handle vendor prefixes + prefix := parts[1] + if prefix == "ms" { + result += "ms" + } else { + result += capitalizeAscii(prefix) + } + index = 2 // Skip the empty string and prefix + } else { + result += parts[0] + index = 1 + } + // Convert remaining parts to CamelCase + for ; index < len(parts); index++ { + if parts[index] != "" { + result += capitalizeAscii(parts[index]) + } + } + return result +} + +func convertStyleToReactStyles(styleMap map[string]string, params map[string]any) map[string]any { + if len(styleMap) == 0 { + return nil + } + rtn := make(map[string]any) + for key, val := range styleMap { + rtn[toReactName(key)] = attrToProp(val, false, params) + } + return rtn +} + +func styleAttrStrToStyleMap(styleText string, params map[string]any) (map[string]any, error) { + parser := cssparser.MakeParser(styleText) + m, err := parser.Parse() + if err != nil { + return nil, err + } + return convertStyleToReactStyles(m, params), nil +} + +func fixStyleAttribute(elem *VDomElem, params map[string]any, elemPath []string) error { + styleText, ok := elem.Props["style"].(string) + if !ok { + return nil + } + styleMap, err := styleAttrStrToStyleMap(styleText, params) + if err != nil { + return fmt.Errorf("%v (at %s)", err, makePathStr(elemPath)) + } + elem.Props["style"] = styleMap + return nil +} + +func fixupStyleAttributes(elem *VDomElem, params map[string]any, elemPath []string) { + if elem == nil { + return + } + // call fixStyleAttribute, and walk children + elemCountMap := make(map[string]int) + if len(elemPath) == 0 { + elemPath = append(elemPath, elem.Tag) + } + fixStyleAttribute(elem, params, elemPath) + for i := range elem.Children { + child := &elem.Children[i] + elemCountMap[child.Tag]++ + subPath := child.Tag + if elemCountMap[child.Tag] > 1 { + subPath = fmt.Sprintf("%s[%d]", child.Tag, elemCountMap[child.Tag]) + } + elemPath = append(elemPath, subPath) + fixupStyleAttributes(&elem.Children[i], params, elemPath) + elemPath = elemPath[:len(elemPath)-1] + } +} + +func Bind(htmlStr string, params map[string]any) *VDomElem { + htmlStr = processWhitespace(htmlStr) + r := strings.NewReader(htmlStr) + iter := htmltoken.NewTokenizer(r) + var elemStack []*VDomElem + elemStack = append(elemStack, &VDomElem{Tag: FragmentTag}) + var tokenErr error +outer: + for { + tokenType := iter.Next() + token := iter.Token() + switch tokenType { + case htmltoken.StartTagToken: + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags must be self closing") + break outer + } + elem := tokenToElem(token, params) + elemStack = pushElemStack(elemStack, elem) + case htmltoken.EndTagToken: + if token.Data == Html_BindTagName || token.Data == Html_BindParamTagName { + tokenErr = errors.New("bind tags 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 == Html_BindParamTagName { + keyAttr := getAttrString(token, "key") + dataVal := params[keyAttr] + elemList := partToElems(dataVal) + for _, elem := range elemList { + appendChildToStack(elemStack, &elem) + } + continue + } + if token.Data == Html_BindTagName { + keyAttr := getAttrString(token, "key") + binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} + appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) + continue + } + elem := tokenToElem(token, params) + 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) + } + rtn := finalizeStack(elemStack) + fixupStyleAttributes(rtn, params, nil) + return rtn +} diff --git a/tsunami/vdom/vdom_root.go b/tsunami/vdom/vdom_root.go new file mode 100644 index 0000000000..e7b5b3b984 --- /dev/null +++ b/tsunami/vdom/vdom_root.go @@ -0,0 +1,594 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "fmt" + "log" + "reflect" + "strconv" + "strings" + + "github.com/google/uuid" + "tsunami/util" +) + +const ( + BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync + BackendUpdate_ChunkSize = 100 // Size for subsequent chunks +) + +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]any), + CompMap: make(map[string]*ComponentImpl), + Atoms: make(map[string]*Atom), + } +} + +func (r *RootElem) GetAtom(name string) *Atom { + atom, ok := r.Atoms[name] + if !ok { + atom = &Atom{UsedBy: make(map[string]bool)} + r.Atoms[name] = atom + } + return atom +} + +func (r *RootElem) GetAtomVal(name string) any { + atom := r.GetAtom(name) + return atom.Val +} + +func (r *RootElem) GetStateSync(full bool) []VDomStateSync { + stateSync := make([]VDomStateSync, 0) + for atomName, atom := range r.Atoms { + if atom.Dirty || full { + stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) + atom.Dirty = false + } + } + return stateSync +} + +func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { + atom := r.GetAtom(name) + if !markDirty { + atom.Val = val + return + } + // try to avoid setting the value and marking as dirty if it's the "same" + if util.JsonValEqual(val, atom.Val) { + return + } + atom.Val = val + atom.Dirty = true +} + +func (r *RootElem) SetOuterCtx(ctx context.Context) { + r.OuterCtx = ctx +} + +func validateCFunc(cfunc any) error { + if cfunc == nil { + return fmt.Errorf("Component function cannot b nil") + } + rval := reflect.ValueOf(cfunc) + if rval.Kind() != reflect.Func { + return fmt.Errorf("Component function must be a function") + } + rtype := rval.Type() + if rtype.NumIn() != 2 { + return fmt.Errorf("Component function must take exactly 2 arguments") + } + if rtype.NumOut() != 1 { + return fmt.Errorf("Component function must return exactly 1 value") + } + // first arg must be context.Context + if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() { + return fmt.Errorf("Component function first argument must be context.Context") + } + // second can a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it) + arg2Type := rtype.In(1) + if arg2Type.Kind() == reflect.Ptr { + arg2Type = arg2Type.Elem() + } + if arg2Type.Kind() == reflect.Map { + if arg2Type.Key().Kind() != reflect.String || + !(arg2Type.Elem().Kind() == reflect.Interface && arg2Type.Elem().NumMethod() == 0) { + return fmt.Errorf("Map argument must be map[string]any") + } + } else if arg2Type.Kind() != reflect.Struct && + !(arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0) { + return fmt.Errorf("Component function second argument must be map[string]any, struct, or any") + } + return nil +} + +func (r *RootElem) RegisterComponent(name string, cfunc any) error { + if err := validateCFunc(cfunc); err != nil { + return err + } + r.CFuncs[name] = cfunc + return nil +} + +func (r *RootElem) Render(elem *VDomElem) { + r.render(elem, &r.Root) +} + +func (vdf *VDomFunc) CallFn(event VDomEvent) { + if vdf.Fn == nil { + return + } + rval := reflect.ValueOf(vdf.Fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + } + if rtype.NumIn() == 1 { + if rtype.In(0) == reflect.TypeOf((*VDomEvent)(nil)).Elem() { + rval.Call([]reflect.Value{reflect.ValueOf(event)}) + } + } +} + +func callVDomFn(fnVal any, data VDomEvent) { + if fnVal == nil { + return + } + fn := fnVal + if vdf, ok := fnVal.(*VDomFunc); ok { + fn = vdf.Fn + } + if fn == nil { + return + } + rval := reflect.ValueOf(fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + return + } + if rtype.NumIn() == 1 { + rval.Call([]reflect.Value{reflect.ValueOf(data)}) + return + } +} + +func (r *RootElem) Event(id string, propName string, event VDomEvent) { + comp := r.CompMap[id] + if comp == nil || comp.Elem == nil { + return + } + fnVal := comp.Elem.Props[propName] + callVDomFn(fnVal, event) +} + +// 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 *VDomElem, comp **ComponentImpl) { + 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 **ComponentImpl) { + 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).WaveId) + *comp = nil +} + +func (r *RootElem) createComp(tag string, key string, comp **ComponentImpl) { + *comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key} + r.CompMap[(*comp).WaveId] = *comp +} + +func (r *RootElem) renderText(text string, comp **ComponentImpl) { + if (*comp).Text != text { + (*comp).Text = text + } +} + +func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { + newChildren := make([]*ComponentImpl, len(elems)) + curCM := make(map[ChildKey]*ComponentImpl) + usedMap := make(map[*ComponentImpl]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 *ComponentImpl + 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 *VDomElem, comp **ComponentImpl) { + if (*comp).Comp != nil { + r.unmount(&(*comp).Comp) + } + (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) +} + +func (r *RootElem) makeRenderContext(comp *ComponentImpl) 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 callCFunc(cfunc any, ctx context.Context, props map[string]any) any { + rval := reflect.ValueOf(cfunc) + arg2Type := rval.Type().In(1) + + var arg2Val reflect.Value + if arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0 { + // For any/interface{}, pass nil properly + arg2Val = reflect.New(arg2Type) + } else { + arg2Val = reflect.New(arg2Type) + // if arg2 is a map, just pass props + if arg2Type.Kind() == reflect.Map { + arg2Val.Elem().Set(reflect.ValueOf(props)) + } else { + err := util.MapToStruct(props, arg2Val.Interface()) + if err != nil { + fmt.Printf("error unmarshalling props: %v\n", err) + } + } + } + rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()}) + if len(rtnVal) == 0 { + return nil + } + return rtnVal[0].Interface() +} + +func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentImpl) { + 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 := callCFunc(cfunc, ctx, props) + rtnElemArr := partToElems(renderedElem) + if len(rtnElemArr) == 0 { + r.unmount(&(*comp).Comp) + return + } + var rtnElem *VDomElem + if len(rtnElemArr) == 1 { + rtnElem = &rtnElemArr[0] + } else { + rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} + } + r.render(rtnElem, &(*comp).Comp) +} + +func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { + refId := updateRef.RefId + split := strings.SplitN(refId, ":", 2) + if len(split) != 2 { + log.Printf("invalid ref id: %s\n", refId) + return + } + waveId := split[0] + hookIdx, err := strconv.Atoi(split[1]) + if err != nil { + log.Printf("invalid ref id (bad hook idx): %s\n", refId) + return + } + comp := r.CompMap[waveId] + if comp == nil { + return + } + if hookIdx < 0 || hookIdx >= len(comp.Hooks) { + return + } + hook := comp.Hooks[hookIdx] + if hook == nil { + return + } + ref, ok := hook.Val.(*VDomRef) + if !ok { + return + } + ref.HasCurrent = updateRef.HasCurrent + ref.Position = updateRef.Position + r.AddRenderWork(waveId) +} + +func (r *RootElem) QueueRefOp(op VDomRefOperation) { + r.RefOperations = append(r.RefOperations, op) +} + +func (r *RootElem) GetRefOperations() []VDomRefOperation { + ops := r.RefOperations + r.RefOperations = nil + return ops +} + +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] = VDomFunc{Type: ObjectType_Func} + continue + } + vdomProps[k] = v + } + return vdomProps +} + +func convertBaseToVDom(c *ComponentImpl) *VDomElem { + elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} + if c.Elem != nil { + elem.Props = convertPropsToVDom(c.Elem.Props) + } + for _, child := range c.Children { + childElem := convertCompToVDom(child) + if childElem != nil { + elem.Children = append(elem.Children, *childElem) + } + } + if c.Tag == TextTag { + elem.Text = c.Text + } + return elem +} + +func convertCompToVDom(c *ComponentImpl) *VDomElem { + if c == nil { + return nil + } + if c.Comp != nil { + return convertCompToVDom(c.Comp) + } + return convertBaseToVDom(c) +} + +func (r *RootElem) MakeVDom() *VDomElem { + if r.Root == nil { + return nil + } + return convertCompToVDom(r.Root) +} + +func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem { + transferElems := make([]VDomTransferElem, 0) + for _, elem := range elems { + transferElem := VDomTransferElem{ + WaveId: elem.WaveId, + Tag: elem.Tag, + Props: elem.Props, + Text: elem.Text, + } + for _, child := range elem.Children { + transferElem.Children = append(transferElem.Children, child.WaveId) + } + transferElems = append(transferElems, transferElem) + childTransferElems := ConvertElemsToTransferElems(elem.Children) + transferElems = append(transferElems, childTransferElems...) + } + return transferElems +} + +func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { + seen := make(map[string]bool) + result := make([]VDomTransferElem, 0) + for _, elem := range elems { + if !seen[elem.WaveId] { + seen[elem.WaveId] = true + result = append(result, elem) + } + } + return result +} + +func (beUpdate *VDomBackendUpdate) CreateTransferElems() { + var allElems []VDomElem + for _, renderUpdate := range beUpdate.RenderUpdates { + if renderUpdate.VDom != nil { + allElems = append(allElems, *renderUpdate.VDom) + } + } + transferElems := ConvertElemsToTransferElems(allElems) + beUpdate.TransferElems = DedupTransferElems(transferElems) + for i := range beUpdate.RenderUpdates { + beUpdate.RenderUpdates[i].VDom = nil + } +} + +func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { + if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { + return []*VDomBackendUpdate{update} + } + + updates := make([]*VDomBackendUpdate, 0) + transferElemChunks := chunkSlice(update.TransferElems, BackendUpdate_ChunkSize) + stateSyncChunks := chunkSlice(update.StateSync, BackendUpdate_ChunkSize) + + maxChunks := len(transferElemChunks) + if len(stateSyncChunks) > maxChunks { + maxChunks = len(stateSyncChunks) + } + + for i := 0; i < maxChunks; i++ { + newUpdate := &VDomBackendUpdate{ + Type: update.Type, + Ts: update.Ts, + BlockId: update.BlockId, + } + + if i == 0 { + newUpdate.Opts = update.Opts + newUpdate.HasWork = update.HasWork + newUpdate.RenderUpdates = update.RenderUpdates + newUpdate.RefOperations = update.RefOperations + newUpdate.Messages = update.Messages + } + + if i < len(transferElemChunks) { + newUpdate.TransferElems = transferElemChunks[i] + } + + if i < len(stateSyncChunks) { + newUpdate.StateSync = stateSyncChunks[i] + } + + updates = append(updates, newUpdate) + } + + return updates +} + +func chunkSlice[T any](slice []T, chunkSize int) [][]T { + if len(slice) == 0 { + return nil + } + chunks := make([][]T, 0) + for i := 0; i < len(slice); i += chunkSize { + end := i + chunkSize + if end > len(slice) { + end = len(slice) + } + chunks = append(chunks, slice[i:end]) + } + return chunks +} \ No newline at end of file diff --git a/tsunami/vdom/vdom_test.go b/tsunami/vdom/vdom_test.go new file mode 100644 index 0000000000..8707f6ca4e --- /dev/null +++ b/tsunami/vdom/vdom_test.go @@ -0,0 +1,185 @@ +package vdom + +import ( + "context" + "encoding/json" + "fmt" + "log" + "reflect" + "testing" + + "tsunami/util" +) + +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 *VDomElem + 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 := UseVDomRef(ctx) + 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", VDomEvent{EventType: "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)) +} + +func TestJsonBind(t *testing.T) { + elem := Bind(`
`, nil) + if elem == nil { + t.Fatalf("elem is nil") + } + if elem.Tag != "div" { + t.Fatalf("elem.Tag: %s (expected 'div')\n", elem.Tag) + } + if elem.Props == nil || len(elem.Props) != 3 { + t.Fatalf("elem.Props: %v\n", elem.Props) + } + data1Val, ok := elem.Props["data1"] + if !ok { + t.Fatalf("data1 not found\n") + } + _, ok = data1Val.(float64) + if !ok { + t.Fatalf("data1: %T\n", data1Val) + } + data1Int, ok := util.ToInt(data1Val) + if !ok || data1Int != 5 { + t.Fatalf("data1: %v\n", data1Val) + } + data2Val, ok := elem.Props["data2"] + if !ok { + t.Fatalf("data2 not found\n") + } + d2type := reflect.TypeOf(data2Val) + if d2type.Kind() != reflect.Slice { + t.Fatalf("data2: %T\n", data2Val) + } + data2Arr := data2Val.([]any) + if len(data2Arr) != 3 { + t.Fatalf("data2: %v\n", data2Val) + } + d2v2, ok := data2Arr[1].(float64) + if !ok || d2v2 != 2 { + t.Fatalf("data2: %v\n", data2Val) + } + data3Val, ok := elem.Props["data3"] + if !ok || data3Val == nil { + t.Fatalf("data3 not found\n") + } + d3type := reflect.TypeOf(data3Val) + if d3type.Kind() != reflect.Map { + t.Fatalf("data3: %T\n", data3Val) + } + data3Map := data3Val.(map[string]any) + if len(data3Map) != 1 { + t.Fatalf("data3: %v\n", data3Val) + } + d3v1, ok := data3Map["a"] + if !ok { + t.Fatalf("data3: %v\n", data3Val) + } + mval, ok := util.ToInt(d3v1) + if !ok || mval != 1 { + t.Fatalf("data3: %v\n", data3Val) + } + log.Printf("elem: %v\n", elem) +} \ No newline at end of file diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go new file mode 100644 index 0000000000..a920185e0f --- /dev/null +++ b/tsunami/vdom/vdom_types.go @@ -0,0 +1,316 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" + "time" +) + +const TextTag = "#text" +const WaveTextTag = "wave:text" +const WaveNullTag = "wave:null" +const FragmentTag = "#fragment" +const BindTag = "#bind" + +const ChildrenPropKey = "children" +const KeyPropKey = "key" + +const ObjectType_Ref = "ref" +const ObjectType_Binding = "binding" +const ObjectType_Func = "func" + +// 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 vdomContextKeyType struct{} + +var vdomContextKey = vdomContextKeyType{} + +type VDomContextVal struct { + Root *RootElem + Comp *ComponentImpl + HookIdx int +} + +type Atom struct { + Val any + Dirty bool + UsedBy map[string]bool // component waveid -> true +} + +type RootElem struct { + OuterCtx context.Context + Root *ComponentImpl + RenderTs int64 + CFuncs map[string]any + CompMap map[string]*ComponentImpl // component waveid -> component + EffectWorkQueue []*EffectWorkElem + NeedsRenderMap map[string]bool + Atoms map[string]*Atom + RefOperations []VDomRefOperation +} + +const ( + WorkType_Render = "render" + WorkType_Effect = "effect" +) + +type EffectWorkElem struct { + Id string + EffectIndex int +} + +// vdom element +type VDomElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []VDomElem `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +// the over the wire format for a vdom element +type VDomTransferElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []string `json:"children,omitempty"` + Text string `json:"text,omitempty"` +} + +//// protocol messages + +type VDomCreateContext struct { + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta map[string]any `json:"meta,omitempty"` + Target *VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +type VDomAsyncInitiationRequest struct { + Type string `json:"type" tstype:"\"asyncinitiationrequest\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid,omitempty"` +} + +func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { + return VDomAsyncInitiationRequest{ + Type: "asyncinitiationrequest", + Ts: time.Now().UnixMilli(), + BlockId: blockId, + } +} + +type VDomFrontendUpdate struct { + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext VDomRenderContext `json:"rendercontext,omitempty"` + Events []VDomEvent `json:"events,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +type VDomBackendUpdate struct { + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *VDomBackendOpts `json:"opts,omitempty"` + HasWork bool `json:"haswork,omitempty"` + RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` + TransferElems []VDomTransferElem `json:"transferelems,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefOperations []VDomRefOperation `json:"refoperations,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +///// prop types + +// used in props +type VDomBinding struct { + Type string `json:"type" tstype:"\"binding\""` + Bind string `json:"bind"` +} + +// used in props +type VDomFunc struct { + Fn any `json:"-"` // server side function (called with reflection) + Type string `json:"type" tstype:"\"func\""` + StopPropagation bool `json:"stoppropagation,omitempty"` + PreventDefault bool `json:"preventdefault,omitempty"` + GlobalEvent string `json:"globalevent,omitempty"` + Keys []string `json:"#keys,omitempty"` // special for keyDown events a list of keys to "capture" +} + +// used in props +type VDomRef struct { + Type string `json:"type" tstype:"\"ref\""` + RefId string `json:"refid"` + TrackPosition bool `json:"trackposition,omitempty"` + Position *VDomRefPosition `json:"position,omitempty"` + HasCurrent bool `json:"hascurrent,omitempty"` +} + +type VDomSimpleRef[T any] struct { + Current T `json:"current"` +} + +type DomRect struct { + Top float64 `json:"top"` + Left float64 `json:"left"` + Right float64 `json:"right"` + Bottom float64 `json:"bottom"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +type VDomRefPosition struct { + OffsetHeight int `json:"offsetheight"` + OffsetWidth int `json:"offsetwidth"` + ScrollHeight int `json:"scrollheight"` + ScrollWidth int `json:"scrollwidth"` + ScrollTop int `json:"scrolltop"` + BoundingClientRect DomRect `json:"boundingclientrect"` +} + +///// subbordinate protocol types + +type VDomEvent struct { + WaveId string `json:"waveid"` + EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) + GlobalEventType string `json:"globaleventtype,omitempty"` + TargetValue string `json:"targetvalue,omitempty"` + TargetChecked bool `json:"targetchecked,omitempty"` + TargetName string `json:"targetname,omitempty"` + TargetId string `json:"targetid,omitempty"` + KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` + MouseData *WavePointerData `json:"mousedata,omitempty"` +} + +type VDomRenderContext struct { + BlockId string `json:"blockid"` + Focused bool `json:"focused"` + Width int `json:"width"` + Height int `json:"height"` + RootRefId string `json:"rootrefid"` + Background bool `json:"background,omitempty"` +} + +type VDomStateSync struct { + Atom string `json:"atom"` + Value any `json:"value"` +} + +type VDomRefUpdate struct { + RefId string `json:"refid"` + HasCurrent bool `json:"hascurrent"` + Position *VDomRefPosition `json:"position,omitempty"` +} + +type VDomBackendOpts struct { + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` + GlobalStyles bool `json:"globalstyles,omitempty"` +} + +type VDomRenderUpdate struct { + UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` + WaveId string `json:"waveid,omitempty"` + VDomWaveId string `json:"vdomwaveid,omitempty"` + VDom *VDomElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems) + Index *int `json:"index,omitempty"` +} + +type VDomRefOperation struct { + RefId string `json:"refid"` + Op string `json:"op"` + Params []any `json:"params,omitempty"` + OutputRef string `json:"outputref,omitempty"` +} + +type VDomMessage struct { + MessageType string `json:"messagetype"` + Message string `json:"message"` + StackTrace string `json:"stacktrace,omitempty"` + Params []any `json:"params,omitempty"` +} + +// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. +// default is vdom context inside of a terminal block +type VDomTarget struct { + NewBlock bool `json:"newblock,omitempty"` + Magnified bool `json:"magnified,omitempty"` + Toolbar *VDomTargetToolbar `json:"toolbar,omitempty"` +} + +type VDomTargetToolbar struct { + Toolbar bool `json:"toolbar"` + Height string `json:"height,omitempty"` +} + +// matches WaveKeyboardEvent +type VDomKeyboardEvent struct { + Type string `json:"type"` + Key string `json:"key"` + Code string `json:"code"` + Shift bool `json:"shift,omitempty"` + Control bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` + Option bool `json:"option,omitempty"` + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` +} + +type WaveKeyboardEvent struct { + Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` + Key string `json:"key"` // KeyboardEvent.key + Code string `json:"code"` // KeyboardEvent.code + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` // KeyboardEvent.location + + // modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} + +type WavePointerData struct { + Button int `json:"button"` + Buttons int `json:"buttons"` + + ClientX int `json:"clientx,omitempty"` + ClientY int `json:"clienty,omitempty"` + PageX int `json:"pagex,omitempty"` + PageY int `json:"pagey,omitempty"` + ScreenX int `json:"screenx,omitempty"` + ScreenY int `json:"screeny,omitempty"` + MovementX int `json:"movementx,omitempty"` + MovementY int `json:"movementy,omitempty"` + + // Modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} \ No newline at end of file From 0bfffccdc7ec5c3d3c4e21ed1eef05e85c1fe796 Mon Sep 17 00:00:00 2001 From: sawka Date: Sat, 30 Aug 2025 21:51:23 -0700 Subject: [PATCH 002/134] fix all errors in tsunami packages --- tsunami/app/streamingresp.go | 129 +++++++++ tsunami/app/waveapp.go | 444 +++++++++++++++++++++++++++++++ tsunami/app/waveappserverimpl.go | 151 +++++++++++ tsunami/go.mod | 3 +- tsunami/go.sum | 2 + tsunami/rpc/rpc.go | 8 + tsunami/rpc/types.go | 51 ++++ tsunami/rpc/wps.go | 31 +++ tsunami/rpcclient/client.go | 43 +++ tsunami/rpcclient/rpcclient.go | 27 ++ tsunami/util/util.go | 52 ++++ tsunami/vdom/vdom.go | 3 +- tsunami/vdom/vdom_html.go | 2 +- tsunami/vdom/vdom_root.go | 4 +- tsunami/vdom/vdom_test.go | 4 +- 15 files changed, 946 insertions(+), 8 deletions(-) create mode 100644 tsunami/app/streamingresp.go create mode 100644 tsunami/app/waveapp.go create mode 100644 tsunami/app/waveappserverimpl.go create mode 100644 tsunami/rpc/rpc.go create mode 100644 tsunami/rpc/types.go create mode 100644 tsunami/rpc/wps.go create mode 100644 tsunami/rpcclient/client.go create mode 100644 tsunami/rpcclient/rpcclient.go create mode 100644 tsunami/util/util.go diff --git a/tsunami/app/streamingresp.go b/tsunami/app/streamingresp.go new file mode 100644 index 0000000000..57188baab4 --- /dev/null +++ b/tsunami/app/streamingresp.go @@ -0,0 +1,129 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveapp + +import ( + "bytes" + "net/http" + + "github.com/wavetermdev/waveterm/tsunami/rpc" +) + +const maxChunkSize = 64 * 1024 // 64KB maximum chunk size + +// StreamingResponseWriter implements http.ResponseWriter interface to stream response +// data through a channel rather than buffering it in memory. This is particularly +// useful for handling large responses like video streams or file downloads. +type StreamingResponseWriter struct { + header http.Header + statusCode int + respChan chan<- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse] + headerSent bool + buffer *bytes.Buffer +} + +func NewStreamingResponseWriter(respChan chan<- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]) *StreamingResponseWriter { + return &StreamingResponseWriter{ + header: make(http.Header), + statusCode: http.StatusOK, + respChan: respChan, + headerSent: false, + buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), + } +} + +func (w *StreamingResponseWriter) Header() http.Header { + return w.header +} + +func (w *StreamingResponseWriter) WriteHeader(statusCode int) { + if w.headerSent { + return + } + + w.statusCode = statusCode + w.headerSent = true + + headers := make(map[string]string) + for key, values := range w.header { + if len(values) > 0 { + headers[key] = values[0] + } + } + + w.respChan <- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]{ + Response: rpc.VDomUrlRequestResponse{ + StatusCode: w.statusCode, + Headers: headers, + }, + } +} + +// sendChunk sends a single chunk of exactly maxChunkSize (or less) +func (w *StreamingResponseWriter) sendChunk(data []byte) { + if len(data) == 0 { + return + } + chunk := make([]byte, len(data)) + copy(chunk, data) + w.respChan <- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]{ + Response: rpc.VDomUrlRequestResponse{ + Body: chunk, + }, + } +} + +func (w *StreamingResponseWriter) Write(data []byte) (int, error) { + if !w.headerSent { + w.WriteHeader(http.StatusOK) + } + + originalLen := len(data) + + // If we already have data in the buffer + if w.buffer.Len() > 0 { + // Fill the buffer up to maxChunkSize + spaceInBuffer := maxChunkSize - w.buffer.Len() + if spaceInBuffer > 0 { + // How much of the new data can fit in the buffer + toBuffer := spaceInBuffer + if toBuffer > len(data) { + toBuffer = len(data) + } + w.buffer.Write(data[:toBuffer]) + data = data[toBuffer:] // Advance data slice + } + + // If buffer is full, send it + if w.buffer.Len() == maxChunkSize { + w.sendChunk(w.buffer.Bytes()) + w.buffer.Reset() + } + } + + // Send any full chunks from data + for len(data) >= maxChunkSize { + w.sendChunk(data[:maxChunkSize]) + data = data[maxChunkSize:] + } + + // Buffer any remaining data + if len(data) > 0 { + w.buffer.Write(data) + } + + return originalLen, nil +} + +func (w *StreamingResponseWriter) Close() error { + if !w.headerSent { + w.WriteHeader(http.StatusOK) + } + + if w.buffer.Len() > 0 { + w.sendChunk(w.buffer.Bytes()) + w.buffer.Reset() + } + return nil +} diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go new file mode 100644 index 0000000000..bd43a2f826 --- /dev/null +++ b/tsunami/app/waveapp.go @@ -0,0 +1,444 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveapp + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + "unicode" + + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/wavetermdev/waveterm/tsunami/rpc" + "github.com/wavetermdev/waveterm/tsunami/rpcclient" + "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +type AppOpts struct { + CloseOnCtrlC bool + GlobalKeyboardEvents bool + GlobalStyles []byte + RootComponentName string // defaults to "App" + NewBlockFlag string // defaults to "n" (set to "-" to disable) + TargetNewBlock bool + TargetToolbar *vdom.VDomTargetToolbar +} + +type Client struct { + Lock *sync.Mutex + AppOpts AppOpts + Root *vdom.RootElem + RootElem *vdom.VDomElem + RpcClient *rpcclient.RpcClient + RpcContext *rpc.RpcContext + ServerImpl *WaveAppServerImpl + IsDone bool + RouteId string + VDomContextBlockId string + DoneReason string + DoneCh chan struct{} + Opts vdom.VDomBackendOpts + GlobalEventHandler func(client *Client, event vdom.VDomEvent) + GlobalStylesOption *FileHandlerOption + UrlHandlerMux *mux.Router + OverrideUrlHandler http.Handler + NewBlockFlag bool + SetupFn func() +} + +func (c *Client) GetIsDone() bool { + c.Lock.Lock() + defer c.Lock.Unlock() + return c.IsDone +} + +func (c *Client) doShutdown(reason string) { + c.Lock.Lock() + defer c.Lock.Unlock() + if c.IsDone { + return + } + c.DoneReason = reason + c.IsDone = true + close(c.DoneCh) +} + +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { + c.GlobalEventHandler = handler +} + +func (c *Client) SetOverrideUrlHandler(handler http.Handler) { + c.OverrideUrlHandler = handler +} + +func MakeClient(appOpts AppOpts) *Client { + if appOpts.RootComponentName == "" { + appOpts.RootComponentName = "App" + } + if appOpts.NewBlockFlag == "" { + appOpts.NewBlockFlag = "n" + } + client := &Client{ + Lock: &sync.Mutex{}, + AppOpts: appOpts, + Root: vdom.MakeRoot(), + RpcClient: rpcclient.MakeRpcClient(), + DoneCh: make(chan struct{}), + UrlHandlerMux: mux.NewRouter(), + Opts: vdom.VDomBackendOpts{ + CloseOnCtrlC: appOpts.CloseOnCtrlC, + GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents, + }, + } + if len(appOpts.GlobalStyles) > 0 { + client.Opts.GlobalStyles = true + client.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"} + } + client.SetRootElem(vdom.E(appOpts.RootComponentName)) + return client +} + +func (client *Client) runMainE() error { + if client.SetupFn != nil { + client.SetupFn() + } + err := client.Connect() + if err != nil { + return err + } + target := &vdom.VDomTarget{} + if client.AppOpts.TargetNewBlock || client.NewBlockFlag { + target.NewBlock = client.NewBlockFlag + } + if client.AppOpts.TargetToolbar != nil { + target.Toolbar = client.AppOpts.TargetToolbar + } + if target.NewBlock && target.Toolbar != nil { + return fmt.Errorf("cannot specify both new block and toolbar target") + } + err = client.CreateVDomContext(target) + if err != nil { + return err + } + <-client.DoneCh + return nil +} + +func (client *Client) AddSetupFn(fn func()) { + client.SetupFn = fn +} + +func (client *Client) RegisterDefaultFlags() { + if client.AppOpts.NewBlockFlag != "-" { + flag.BoolVar(&client.NewBlockFlag, client.AppOpts.NewBlockFlag, false, "new block") + } +} + +func (client *Client) RunMain() { + if !flag.Parsed() { + client.RegisterDefaultFlags() + flag.Parse() + } + err := client.runMainE() + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func (client *Client) Connect() error { + return errors.New("unimplemented") +} + +func (c *Client) SetRootElem(elem *vdom.VDomElem) { + c.RootElem = elem +} + +func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { + blockORef, err := rpcclient.VDomCreateContextCommand( + c.RpcClient, + vdom.VDomCreateContext{Target: target}, + &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.RpcContext.BlockId)}, + ) + if err != nil { + return err + } + c.VDomContextBlockId = blockORef.OID + log.Printf("created vdom context: %v\n", blockORef) + gotRoute, err := rpcclient.WaitForRouteCommand(c.RpcClient, rpc.CommandWaitForRouteData{ + RouteId: rpc.MakeFeBlockRouteId(blockORef.OID), + WaitMs: 4000, + }, &rpc.RpcOpts{Timeout: 5000}) + if err != nil { + return fmt.Errorf("error waiting for vdom context route: %v", err) + } + if !gotRoute { + return fmt.Errorf("vdom context route could not be established") + } + rpcclient.EventSubCommand(c.RpcClient, rpc.SubscriptionRequest{Event: rpc.Event_BlockClose, Scopes: []string{ + blockORef.String(), + }}, nil) + c.RpcClient.EventListener.On("blockclose", func(event *rpc.WaveEvent) { + c.doShutdown("got blockclose event") + }) + return nil +} + +func (c *Client) SendAsyncInitiation() error { + if c.VDomContextBlockId == "" { + return fmt.Errorf("no vdom context block id") + } + if c.GetIsDone() { + return fmt.Errorf("client is done") + } + return rpcclient.VDomAsyncInitiationCommand( + c.RpcClient, + vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), + &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.VDomContextBlockId)}, + ) +} + +func (c *Client) SetAtomVals(m map[string]any) { + for k, v := range m { + c.Root.SetAtomVal(k, v, true) + } +} + +func (c *Client) SetAtomVal(name string, val any) { + c.Root.SetAtomVal(name, val, true) +} + +func (c *Client) GetAtomVal(name string) any { + return c.Root.GetAtomVal(name) +} + +func makeNullVDom() *vdom.VDomElem { + return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} +} + +func DefineComponent[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { + if name == "" { + panic("Component name cannot be empty") + } + if !unicode.IsUpper(rune(name[0])) { + panic("Component name must start with an uppercase letter") + } + err := client.RegisterComponent(name, renderFn) + if err != nil { + panic(err) + } + return func(props P) *vdom.VDomElem { + return vdom.E(name, vdom.Props(props)) + } +} + +func (c *Client) RegisterComponent(name string, cfunc any) error { + return c.Root.RegisterComponent(name, cfunc) +} + +func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + c.Root.Render(c.RootElem) + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + HasWork: len(c.Root.EffectWorkQueue) > 0, + Opts: &c.Opts, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: renderedVDom}, + }, + RefOperations: c.Root.GetRefOperations(), + StateSync: c.Root.GetStateSync(true), + }, nil +} + +func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { + c.Root.RunWork() + renderedVDom := c.Root.MakeVDom() + if renderedVDom == nil { + renderedVDom = makeNullVDom() + } + return &vdom.VDomBackendUpdate{ + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + BlockId: c.RpcContext.BlockId, + RenderUpdates: []vdom.VDomRenderUpdate{ + {UpdateType: "root", VDom: renderedVDom}, + }, + RefOperations: c.Root.GetRefOperations(), + StateSync: c.Root.GetStateSync(false), + }, nil +} + +func (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) { + c.UrlHandlerMux.Handle(path, handler) +} + +type FileHandlerOption struct { + FilePath string // optional file path on disk + Data []byte // optional byte slice content + Reader io.Reader // optional reader for content + File fs.File // optional embedded or opened file + MimeType string // optional mime type + ETag string // optional ETag (if set, resource may be cached) +} + +func determineMimeType(option FileHandlerOption) (string, []byte) { + // If MimeType is set, use it directly + if option.MimeType != "" { + return option.MimeType, nil + } + + // Detect from Data if available, no need to buffer + if option.Data != nil { + return http.DetectContentType(option.Data), nil + } + + // Detect from FilePath, no buffering necessary + if option.FilePath != "" { + filePath := util.ExpandHomeDirSafe(option.FilePath) + file, err := os.Open(filePath) + if err != nil { + return "application/octet-stream", nil // Fallback on error + } + defer file.Close() + + // Read first 512 bytes for MIME detection + buf := make([]byte, 512) + _, err = file.Read(buf) + if err != nil && err != io.EOF { + return "application/octet-stream", nil + } + return http.DetectContentType(buf), nil + } + + // Buffer for File (fs.File), since it lacks Seek + if option.File != nil { + buf := make([]byte, 512) + n, err := option.File.Read(buf) + if err != nil && err != io.EOF { + return "application/octet-stream", nil + } + return http.DetectContentType(buf[:n]), buf[:n] + } + + // Buffer for Reader (io.Reader), same as File + if option.Reader != nil { + buf := make([]byte, 512) + n, err := option.Reader.Read(buf) + if err != nil && err != io.EOF { + return "application/octet-stream", nil + } + return http.DetectContentType(buf[:n]), buf[:n] + } + + // Default MIME type if none specified + return "application/octet-stream", nil +} + +// ServeFileOption handles serving content based on the provided FileHandlerOption +func ServeFileOption(w http.ResponseWriter, r *http.Request, option FileHandlerOption) error { + // Determine MIME type and get buffered data if needed + contentType, bufferedData := determineMimeType(option) + w.Header().Set("Content-Type", contentType) + // Handle ETag + if option.ETag != "" { + w.Header().Set("ETag", option.ETag) + + // Check If-None-Match header + if inm := r.Header.Get("If-None-Match"); inm != "" { + // Strip W/ prefix and quotes if present + inm = strings.Trim(inm, `"`) + inm = strings.TrimPrefix(inm, "W/") + etag := strings.Trim(option.ETag, `"`) + etag = strings.TrimPrefix(etag, "W/") + + if inm == etag { + // Resource not modified + w.WriteHeader(http.StatusNotModified) + return nil + } + } + } + + // Handle the content based on the option type + switch { + case option.FilePath != "": + filePath := util.ExpandHomeDirSafe(option.FilePath) + http.ServeFile(w, r, filePath) + + case option.Data != nil: + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(option.Data))) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(option.Data); err != nil { + return fmt.Errorf("failed to write data: %v", err) + } + + case option.File != nil: + if bufferedData != nil { + if _, err := w.Write(bufferedData); err != nil { + return fmt.Errorf("failed to write buffered data: %v", err) + } + } + if _, err := io.Copy(w, option.File); err != nil { + return fmt.Errorf("failed to copy from file: %v", err) + } + + case option.Reader != nil: + if bufferedData != nil { + if _, err := w.Write(bufferedData); err != nil { + return fmt.Errorf("failed to write buffered data: %v", err) + } + } + if _, err := io.Copy(w, option.Reader); err != nil { + return fmt.Errorf("failed to copy from reader: %v", err) + } + + default: + return fmt.Errorf("no content available") + } + + return nil +} + +func (c *Client) RegisterFilePrefixHandler(prefix string, optionProvider func(path string) (*FileHandlerOption, error)) { + c.UrlHandlerMux.PathPrefix(prefix).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + option, err := optionProvider(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if option == nil { + http.Error(w, "no content available", http.StatusNotFound) + return + } + if err := ServeFileOption(w, r, *option); err != nil { + http.Error(w, fmt.Sprintf("Failed to serve content: %v", err), http.StatusInternalServerError) + } + }) +} + +func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) { + c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + if err := ServeFileOption(w, r, option); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) +} diff --git a/tsunami/app/waveappserverimpl.go b/tsunami/app/waveappserverimpl.go new file mode 100644 index 0000000000..de057d0fba --- /dev/null +++ b/tsunami/app/waveappserverimpl.go @@ -0,0 +1,151 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveapp + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + + "github.com/wavetermdev/waveterm/tsunami/rpc" + "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +type WaveAppServerImpl struct { + Client *Client + BlockId string +} + +func (*WaveAppServerImpl) WshServerImpl() {} + +func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) chan rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] { + respChan := make(chan rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate], 5) + defer func() { + panicErr := util.PanicHandler("VDomRenderCommand", recover()) + if panicErr != nil { + respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + Error: panicErr, + } + close(respChan) + } + }() + + if feUpdate.Dispose { + defer close(respChan) + log.Printf("got dispose from frontend\n") + impl.Client.doShutdown("got dispose from frontend") + return respChan + } + + if impl.Client.GetIsDone() { + close(respChan) + return respChan + } + + impl.Client.Root.RenderTs = feUpdate.Ts + + // set atoms + for _, ss := range feUpdate.StateSync { + impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.GlobalEventType != "" { + if impl.Client.GlobalEventHandler != nil { + impl.Client.GlobalEventHandler(impl.Client, event) + } + } else { + impl.Client.Root.Event(event.WaveId, event.EventType, event) + } + } + // update refs + for _, ref := range feUpdate.RefUpdates { + impl.Client.Root.UpdateRef(ref) + } + + var update *vdom.VDomBackendUpdate + var err error + + if feUpdate.Resync || true { + update, err = impl.Client.fullRender() + } else { + update, err = impl.Client.incrementalRender() + } + update.CreateTransferElems() + + if err != nil { + respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + Error: err, + } + close(respChan) + return respChan + } + + // Split the update into chunks and send them sequentially + updates := vdom.SplitBackendUpdate(update) + go func() { + defer func() { + util.PanicHandler("VDomRenderCommand:splitUpdates", recover()) + }() + defer close(respChan) + for _, splitUpdate := range updates { + respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + Response: splitUpdate, + } + } + }() + + return respChan +} + +func (impl *WaveAppServerImpl) VDomUrlRequestCommand(ctx context.Context, data rpc.VDomUrlRequestData) chan rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse] { + respChan := make(chan rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]) + writer := NewStreamingResponseWriter(respChan) + + go func() { + defer close(respChan) // Declared first, so it executes last + defer writer.Close() // Ensures writer is closed before the channel is closed + + defer func() { + panicErr := util.PanicHandler("VDomUrlRequestCommand", recover()) + if panicErr != nil { + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(fmt.Sprintf("internal server error: %v", panicErr))) + } + }() + + // Create an HTTP request from the RPC request data + var bodyReader *bytes.Reader + if data.Body != nil { + bodyReader = bytes.NewReader(data.Body) + } else { + bodyReader = bytes.NewReader([]byte{}) + } + + httpReq, err := http.NewRequest(data.Method, data.URL, bodyReader) + if err != nil { + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(err.Error())) + return + } + + for key, value := range data.Headers { + httpReq.Header.Set(key, value) + } + if httpReq.URL.Path == "/wave/global.css" && impl.Client.GlobalStylesOption != nil { + ServeFileOption(writer, httpReq, *impl.Client.GlobalStylesOption) + return + } + if impl.Client.OverrideUrlHandler != nil { + impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq) + return + } + impl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq) + }() + + return respChan +} diff --git a/tsunami/go.mod b/tsunami/go.mod index 1a7e3b9b91..9f44097522 100644 --- a/tsunami/go.mod +++ b/tsunami/go.mod @@ -1,4 +1,4 @@ -module tsunami +module github.com/wavetermdev/waveterm/tsunami go 1.22.4 @@ -6,6 +6,7 @@ toolchain go1.24.6 require ( github.com/google/uuid v1.6.0 + github.com/gorilla/mux v1.8.1 github.com/wavetermdev/htmltoken v0.2.0 ) diff --git a/tsunami/go.sum b/tsunami/go.sum index 338224a4d8..87a6d9bc63 100644 --- a/tsunami/go.sum +++ b/tsunami/go.sum @@ -1,5 +1,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= diff --git a/tsunami/rpc/rpc.go b/tsunami/rpc/rpc.go new file mode 100644 index 0000000000..913cd57b4c --- /dev/null +++ b/tsunami/rpc/rpc.go @@ -0,0 +1,8 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpc + +func MakeFeBlockRouteId(blockId string) string { + return "feblock:" + blockId +} \ No newline at end of file diff --git a/tsunami/rpc/types.go b/tsunami/rpc/types.go new file mode 100644 index 0000000000..834f0dbabf --- /dev/null +++ b/tsunami/rpc/types.go @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpc + +type RespOrErrorUnion[T any] struct { + Response T + Error error +} + +type VDomUrlRequestData struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body,omitempty"` +} + +type VDomUrlRequestResponse struct { + StatusCode int `json:"statuscode,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type RpcOpts struct { + Timeout int64 `json:"timeout,omitempty"` + NoResponse bool `json:"noresponse,omitempty"` + Route string `json:"route,omitempty"` + + StreamCancelFn func() `json:"-"` // this is an *output* parameter, set by the handler +} + +type RpcContext struct { + ClientType string `json:"ctype,omitempty"` + BlockId string `json:"blockid,omitempty"` + TabId string `json:"tabid,omitempty"` + Conn string `json:"conn,omitempty"` +} + +type CommandWaitForRouteData struct { + RouteId string `json:"routeid"` + WaitMs int `json:"waitms"` +} + +type ORef struct { + OType string `json:"otype"` + OID string `json:"oid"` +} + +func (oref ORef) String() string { + return oref.OType + ":" + oref.OID +} diff --git a/tsunami/rpc/wps.go b/tsunami/rpc/wps.go new file mode 100644 index 0000000000..4f324cbe08 --- /dev/null +++ b/tsunami/rpc/wps.go @@ -0,0 +1,31 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpc + +const ( + Event_BlockClose = "blockclose" +) + +type WaveEvent struct { + Event string `json:"event"` + Scopes []string `json:"scopes,omitempty"` + Sender string `json:"sender,omitempty"` + Persist int `json:"persist,omitempty"` + Data any `json:"data,omitempty"` +} + +func (e WaveEvent) HasScope(scope string) bool { + for _, s := range e.Scopes { + if s == scope { + return true + } + } + return false +} + +type SubscriptionRequest struct { + Event string `json:"event"` + Scopes []string `json:"scopes,omitempty"` + AllScopes bool `json:"allscopes,omitempty"` +} \ No newline at end of file diff --git a/tsunami/rpcclient/client.go b/tsunami/rpcclient/client.go new file mode 100644 index 0000000000..9e8256c351 --- /dev/null +++ b/tsunami/rpcclient/client.go @@ -0,0 +1,43 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpcclient + +import ( + "github.com/wavetermdev/waveterm/tsunami/rpc" +) + +type EventListener struct { + eventHandlers map[string][]func(event *rpc.WaveEvent) +} + +func MakeEventListener() *EventListener { + return &EventListener{ + eventHandlers: make(map[string][]func(event *rpc.WaveEvent)), + } +} + +func (el *EventListener) On(eventName string, handler func(event *rpc.WaveEvent)) { + if el.eventHandlers == nil { + el.eventHandlers = make(map[string][]func(event *rpc.WaveEvent)) + } + el.eventHandlers[eventName] = append(el.eventHandlers[eventName], handler) +} + +func (el *EventListener) Emit(eventName string, event *rpc.WaveEvent) { + if handlers, exists := el.eventHandlers[eventName]; exists { + for _, handler := range handlers { + handler(event) + } + } +} + +type RpcClient struct { + EventListener *EventListener +} + +func MakeRpcClient() *RpcClient { + return &RpcClient{ + EventListener: MakeEventListener(), + } +} \ No newline at end of file diff --git a/tsunami/rpcclient/rpcclient.go b/tsunami/rpcclient/rpcclient.go new file mode 100644 index 0000000000..28096619de --- /dev/null +++ b/tsunami/rpcclient/rpcclient.go @@ -0,0 +1,27 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpcclient + +import ( + "errors" + + "github.com/wavetermdev/waveterm/tsunami/rpc" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +func VDomCreateContextCommand(rpcClient *RpcClient, data vdom.VDomCreateContext, opts *rpc.RpcOpts) (rpc.ORef, error) { + return rpc.ORef{}, errors.New("VDomCreateContextCommand: unimplemented") +} + +func WaitForRouteCommand(rpcClient *RpcClient, data rpc.CommandWaitForRouteData, opts *rpc.RpcOpts) (bool, error) { + return false, errors.New("WaitForRouteCommand: unimplemented") +} + +func EventSubCommand(rpcClient *RpcClient, data rpc.SubscriptionRequest, opts *rpc.RpcOpts) error { + return errors.New("EventSubCommand: unimplemented") +} + +func VDomAsyncInitiationCommand(rpcClient *RpcClient, data vdom.VDomAsyncInitiationRequest, opts *rpc.RpcOpts) error { + return errors.New("VDomAsyncInitiationCommand: unimplemented") +} \ No newline at end of file diff --git a/tsunami/util/util.go b/tsunami/util/util.go new file mode 100644 index 0000000000..d45e8a3881 --- /dev/null +++ b/tsunami/util/util.go @@ -0,0 +1,52 @@ +package util + +import ( + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" +) + +func PanicHandler(debugStr string, recoverVal any) error { + if recoverVal == nil { + return nil + } + log.Printf("[panic] in %s: %v\n", debugStr, recoverVal) + debug.PrintStack() + if err, ok := recoverVal.(error); ok { + return fmt.Errorf("panic in %s: %w", debugStr, err) + } + return fmt.Errorf("panic in %s: %v", debugStr, recoverVal) +} + +func GetHomeDir() string { + homeVar, err := os.UserHomeDir() + if err != nil { + return "/" + } + return homeVar +} + +func ExpandHomeDir(pathStr string) (string, error) { + if pathStr != "~" && !strings.HasPrefix(pathStr, "~/") && (!strings.HasPrefix(pathStr, `~\`) || runtime.GOOS != "windows") { + return filepath.Clean(pathStr), nil + } + homeDir := GetHomeDir() + if pathStr == "~" { + return homeDir, nil + } + expandedPath := filepath.Clean(filepath.Join(homeDir, pathStr[2:])) + absPath, err := filepath.Abs(filepath.Join(homeDir, expandedPath)) + if err != nil || !strings.HasPrefix(absPath, homeDir) { + return "", fmt.Errorf("potential path traversal detected for path %s", pathStr) + } + return expandedPath, nil +} + +func ExpandHomeDirSafe(pathStr string) string { + path, _ := ExpandHomeDir(pathStr) + return path +} diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index f49743730b..c4f6c037f0 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -12,7 +12,7 @@ import ( "strings" "unicode" - "tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/util" ) // ReactNode types = nil | string | Elem @@ -458,7 +458,6 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) } - func partToElems(part any) []VDomElem { if part == nil { return nil diff --git a/tsunami/vdom/vdom_html.go b/tsunami/vdom/vdom_html.go index fa7ec8a27c..9501ca2c53 100644 --- a/tsunami/vdom/vdom_html.go +++ b/tsunami/vdom/vdom_html.go @@ -10,7 +10,7 @@ import ( "io" "strings" - "tsunami/vdom/cssparser" + "github.com/wavetermdev/waveterm/tsunami/vdom/cssparser" "github.com/wavetermdev/htmltoken" ) diff --git a/tsunami/vdom/vdom_root.go b/tsunami/vdom/vdom_root.go index e7b5b3b984..dd2ca0ae69 100644 --- a/tsunami/vdom/vdom_root.go +++ b/tsunami/vdom/vdom_root.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/google/uuid" - "tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/util" ) const ( @@ -591,4 +591,4 @@ func chunkSlice[T any](slice []T, chunkSize int) [][]T { chunks = append(chunks, slice[i:end]) } return chunks -} \ No newline at end of file +} diff --git a/tsunami/vdom/vdom_test.go b/tsunami/vdom/vdom_test.go index 8707f6ca4e..7ecf35facb 100644 --- a/tsunami/vdom/vdom_test.go +++ b/tsunami/vdom/vdom_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/util" ) type renderContextKeyType struct{} @@ -182,4 +182,4 @@ func TestJsonBind(t *testing.T) { t.Fatalf("data3: %v\n", data3Val) } log.Printf("elem: %v\n", elem) -} \ No newline at end of file +} From 6398a5f698e1dd324ac8f70dc59d7c188c3ccb4f Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 10:06:48 -0700 Subject: [PATCH 003/134] refactor rpctypes (protocol messages) --- tsunami/app/waveapp.go | 51 +++++------ tsunami/app/waveappserverimpl.go | 16 ++-- tsunami/go.mod | 4 +- tsunami/go.sum | 4 +- tsunami/rpc/types.go | 27 ++---- tsunami/rpcclient/rpcclient.go | 8 +- tsunami/rpctypes/types.go | 140 +++++++++++++++++++++++++++++++ tsunami/util/util.go | 15 ++++ tsunami/vdom/vdom_root.go | 75 ----------------- tsunami/vdom/vdom_types.go | 49 ----------- 10 files changed, 206 insertions(+), 183 deletions(-) create mode 100644 tsunami/rpctypes/types.go diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index bd43a2f826..c95d228523 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -22,6 +22,7 @@ import ( "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/tsunami/rpc" "github.com/wavetermdev/waveterm/tsunami/rpcclient" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) @@ -110,55 +111,55 @@ func MakeClient(appOpts AppOpts) *Client { return client } -func (client *Client) runMainE() error { - if client.SetupFn != nil { - client.SetupFn() +func (c *Client) runMainE() error { + if c.SetupFn != nil { + c.SetupFn() } - err := client.Connect() + err := c.Connect() if err != nil { return err } target := &vdom.VDomTarget{} - if client.AppOpts.TargetNewBlock || client.NewBlockFlag { - target.NewBlock = client.NewBlockFlag + if c.AppOpts.TargetNewBlock || c.NewBlockFlag { + target.NewBlock = c.NewBlockFlag } - if client.AppOpts.TargetToolbar != nil { - target.Toolbar = client.AppOpts.TargetToolbar + if c.AppOpts.TargetToolbar != nil { + target.Toolbar = c.AppOpts.TargetToolbar } if target.NewBlock && target.Toolbar != nil { return fmt.Errorf("cannot specify both new block and toolbar target") } - err = client.CreateVDomContext(target) + err = c.CreateVDomContext(target) if err != nil { return err } - <-client.DoneCh + <-c.DoneCh return nil } -func (client *Client) AddSetupFn(fn func()) { - client.SetupFn = fn +func (c *Client) AddSetupFn(fn func()) { + c.SetupFn = fn } -func (client *Client) RegisterDefaultFlags() { - if client.AppOpts.NewBlockFlag != "-" { - flag.BoolVar(&client.NewBlockFlag, client.AppOpts.NewBlockFlag, false, "new block") +func (c *Client) RegisterDefaultFlags() { + if c.AppOpts.NewBlockFlag != "-" { + flag.BoolVar(&c.NewBlockFlag, c.AppOpts.NewBlockFlag, false, "new block") } } -func (client *Client) RunMain() { +func (c *Client) RunMain() { if !flag.Parsed() { - client.RegisterDefaultFlags() + c.RegisterDefaultFlags() flag.Parse() } - err := client.runMainE() + err := c.runMainE() if err != nil { fmt.Println(err) os.Exit(1) } } -func (client *Client) Connect() error { +func (c *Client) Connect() error { return errors.New("unimplemented") } @@ -169,7 +170,7 @@ func (c *Client) SetRootElem(elem *vdom.VDomElem) { func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { blockORef, err := rpcclient.VDomCreateContextCommand( c.RpcClient, - vdom.VDomCreateContext{Target: target}, + rpctypes.VDomCreateContext{Target: target}, &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.RpcContext.BlockId)}, ) if err != nil { @@ -205,7 +206,7 @@ func (c *Client) SendAsyncInitiation() error { } return rpcclient.VDomAsyncInitiationCommand( c.RpcClient, - vdom.MakeAsyncInitiationRequest(c.RpcContext.BlockId), + rpctypes.MakeAsyncInitiationRequest(c.RpcContext.BlockId), &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.VDomContextBlockId)}, ) } @@ -248,14 +249,14 @@ func (c *Client) RegisterComponent(name string, cfunc any) error { return c.Root.RegisterComponent(name, cfunc) } -func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { +func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { c.Root.RunWork() c.Root.Render(c.RootElem) renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() } - return &vdom.VDomBackendUpdate{ + return &rpctypes.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, @@ -269,13 +270,13 @@ func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { }, nil } -func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { +func (c *Client) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { c.Root.RunWork() renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() } - return &vdom.VDomBackendUpdate{ + return &rpctypes.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, diff --git a/tsunami/app/waveappserverimpl.go b/tsunami/app/waveappserverimpl.go index de057d0fba..376f7aae1d 100644 --- a/tsunami/app/waveappserverimpl.go +++ b/tsunami/app/waveappserverimpl.go @@ -11,8 +11,8 @@ import ( "net/http" "github.com/wavetermdev/waveterm/tsunami/rpc" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" - "github.com/wavetermdev/waveterm/tsunami/vdom" ) type WaveAppServerImpl struct { @@ -22,12 +22,12 @@ type WaveAppServerImpl struct { func (*WaveAppServerImpl) WshServerImpl() {} -func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) chan rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate] { - respChan := make(chan rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate], 5) +func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate rpctypes.VDomFrontendUpdate) chan rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate] { + respChan := make(chan rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate], 5) defer func() { panicErr := util.PanicHandler("VDomRenderCommand", recover()) if panicErr != nil { - respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ Error: panicErr, } close(respChan) @@ -67,7 +67,7 @@ func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate v impl.Client.Root.UpdateRef(ref) } - var update *vdom.VDomBackendUpdate + var update *rpctypes.VDomBackendUpdate var err error if feUpdate.Resync || true { @@ -78,7 +78,7 @@ func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate v update.CreateTransferElems() if err != nil { - respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ Error: err, } close(respChan) @@ -86,14 +86,14 @@ func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate v } // Split the update into chunks and send them sequentially - updates := vdom.SplitBackendUpdate(update) + updates := rpctypes.SplitBackendUpdate(update) go func() { defer func() { util.PanicHandler("VDomRenderCommand:splitUpdates", recover()) }() defer close(respChan) for _, splitUpdate := range updates { - respChan <- rpc.RespOrErrorUnion[*vdom.VDomBackendUpdate]{ + respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ Response: splitUpdate, } } diff --git a/tsunami/go.mod b/tsunami/go.mod index 9f44097522..68aaca9c38 100644 --- a/tsunami/go.mod +++ b/tsunami/go.mod @@ -1,6 +1,6 @@ module github.com/wavetermdev/waveterm/tsunami -go 1.22.4 +go 1.23.0 toolchain go1.24.6 @@ -10,4 +10,4 @@ require ( github.com/wavetermdev/htmltoken v0.2.0 ) -require golang.org/x/net v0.27.0 // indirect +require golang.org/x/net v0.43.0 // indirect diff --git a/tsunami/go.sum b/tsunami/go.sum index 87a6d9bc63..4fe0663af0 100644 --- a/tsunami/go.sum +++ b/tsunami/go.sum @@ -4,5 +4,5 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= -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/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= diff --git a/tsunami/rpc/types.go b/tsunami/rpc/types.go index 834f0dbabf..70c46bcefd 100644 --- a/tsunami/rpc/types.go +++ b/tsunami/rpc/types.go @@ -3,24 +3,15 @@ package rpc +import ( + "github.com/wavetermdev/waveterm/tsunami/rpctypes" +) + type RespOrErrorUnion[T any] struct { Response T Error error } -type VDomUrlRequestData struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body,omitempty"` -} - -type VDomUrlRequestResponse struct { - StatusCode int `json:"statuscode,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - Body []byte `json:"body,omitempty"` -} - type RpcOpts struct { Timeout int64 `json:"timeout,omitempty"` NoResponse bool `json:"noresponse,omitempty"` @@ -36,11 +27,6 @@ type RpcContext struct { Conn string `json:"conn,omitempty"` } -type CommandWaitForRouteData struct { - RouteId string `json:"routeid"` - WaitMs int `json:"waitms"` -} - type ORef struct { OType string `json:"otype"` OID string `json:"oid"` @@ -49,3 +35,8 @@ type ORef struct { func (oref ORef) String() string { return oref.OType + ":" + oref.OID } + +// Types moved to rpctypes package +type VDomUrlRequestData = rpctypes.VDomUrlRequestData +type VDomUrlRequestResponse = rpctypes.VDomUrlRequestResponse +type CommandWaitForRouteData = rpctypes.CommandWaitForRouteData diff --git a/tsunami/rpcclient/rpcclient.go b/tsunami/rpcclient/rpcclient.go index 28096619de..da87aea1a6 100644 --- a/tsunami/rpcclient/rpcclient.go +++ b/tsunami/rpcclient/rpcclient.go @@ -7,10 +7,10 @@ import ( "errors" "github.com/wavetermdev/waveterm/tsunami/rpc" - "github.com/wavetermdev/waveterm/tsunami/vdom" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" ) -func VDomCreateContextCommand(rpcClient *RpcClient, data vdom.VDomCreateContext, opts *rpc.RpcOpts) (rpc.ORef, error) { +func VDomCreateContextCommand(rpcClient *RpcClient, data rpctypes.VDomCreateContext, opts *rpc.RpcOpts) (rpc.ORef, error) { return rpc.ORef{}, errors.New("VDomCreateContextCommand: unimplemented") } @@ -22,6 +22,6 @@ func EventSubCommand(rpcClient *RpcClient, data rpc.SubscriptionRequest, opts *r return errors.New("EventSubCommand: unimplemented") } -func VDomAsyncInitiationCommand(rpcClient *RpcClient, data vdom.VDomAsyncInitiationRequest, opts *rpc.RpcOpts) error { +func VDomAsyncInitiationCommand(rpcClient *RpcClient, data rpctypes.VDomAsyncInitiationRequest, opts *rpc.RpcOpts) error { return errors.New("VDomAsyncInitiationCommand: unimplemented") -} \ No newline at end of file +} diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go new file mode 100644 index 0000000000..b76d7e8459 --- /dev/null +++ b/tsunami/rpctypes/types.go @@ -0,0 +1,140 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package rpctypes + +import ( + "time" + + "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +const ( + BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync + BackendUpdate_ChunkSize = 100 // Size for subsequent chunks +) + +type VDomUrlRequestData struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body,omitempty"` +} + +type VDomUrlRequestResponse struct { + StatusCode int `json:"statuscode,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type CommandWaitForRouteData struct { + RouteId string `json:"routeid"` + WaitMs int `json:"waitms"` +} + +type VDomCreateContext struct { + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta map[string]any `json:"meta,omitempty"` + Target *vdom.VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +type VDomAsyncInitiationRequest struct { + Type string `json:"type" tstype:"\"asyncinitiationrequest\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid,omitempty"` +} + +func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { + return VDomAsyncInitiationRequest{ + Type: "asyncinitiationrequest", + Ts: time.Now().UnixMilli(), + BlockId: blockId, + } +} + +type VDomFrontendUpdate struct { + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext vdom.VDomRenderContext `json:"rendercontext,omitempty"` + Events []vdom.VDomEvent `json:"events,omitempty"` + StateSync []vdom.VDomStateSync `json:"statesync,omitempty"` + RefUpdates []vdom.VDomRefUpdate `json:"refupdates,omitempty"` + Messages []vdom.VDomMessage `json:"messages,omitempty"` +} + +type VDomBackendUpdate struct { + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *vdom.VDomBackendOpts `json:"opts,omitempty"` + HasWork bool `json:"haswork,omitempty"` + RenderUpdates []vdom.VDomRenderUpdate `json:"renderupdates,omitempty"` + TransferElems []vdom.VDomTransferElem `json:"transferelems,omitempty"` + StateSync []vdom.VDomStateSync `json:"statesync,omitempty"` + RefOperations []vdom.VDomRefOperation `json:"refoperations,omitempty"` + Messages []vdom.VDomMessage `json:"messages,omitempty"` +} + +func (beUpdate *VDomBackendUpdate) CreateTransferElems() { + var allElems []vdom.VDomElem + for _, renderUpdate := range beUpdate.RenderUpdates { + if renderUpdate.VDom != nil { + allElems = append(allElems, *renderUpdate.VDom) + } + } + transferElems := vdom.ConvertElemsToTransferElems(allElems) + beUpdate.TransferElems = vdom.DedupTransferElems(transferElems) + for i := range beUpdate.RenderUpdates { + beUpdate.RenderUpdates[i].VDom = nil + } +} + +func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { + if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { + return []*VDomBackendUpdate{update} + } + + updates := make([]*VDomBackendUpdate, 0) + transferElemChunks := util.ChunkSlice(update.TransferElems, BackendUpdate_ChunkSize) + stateSyncChunks := util.ChunkSlice(update.StateSync, BackendUpdate_ChunkSize) + + maxChunks := len(transferElemChunks) + if len(stateSyncChunks) > maxChunks { + maxChunks = len(stateSyncChunks) + } + + for i := 0; i < maxChunks; i++ { + newUpdate := &VDomBackendUpdate{ + Type: update.Type, + Ts: update.Ts, + BlockId: update.BlockId, + } + + if i == 0 { + newUpdate.Opts = update.Opts + newUpdate.HasWork = update.HasWork + newUpdate.RenderUpdates = update.RenderUpdates + newUpdate.RefOperations = update.RefOperations + newUpdate.Messages = update.Messages + } + + if i < len(transferElemChunks) { + newUpdate.TransferElems = transferElemChunks[i] + } + + if i < len(stateSyncChunks) { + newUpdate.StateSync = stateSyncChunks[i] + } + + updates = append(updates, newUpdate) + } + + return updates +} diff --git a/tsunami/util/util.go b/tsunami/util/util.go index d45e8a3881..ee2ce5ce89 100644 --- a/tsunami/util/util.go +++ b/tsunami/util/util.go @@ -50,3 +50,18 @@ func ExpandHomeDirSafe(pathStr string) string { path, _ := ExpandHomeDir(pathStr) return path } + +func ChunkSlice[T any](slice []T, chunkSize int) [][]T { + if len(slice) == 0 { + return nil + } + chunks := make([][]T, 0) + for i := 0; i < len(slice); i += chunkSize { + end := i + chunkSize + if end > len(slice) { + end = len(slice) + } + chunks = append(chunks, slice[i:end]) + } + return chunks +} diff --git a/tsunami/vdom/vdom_root.go b/tsunami/vdom/vdom_root.go index dd2ca0ae69..3e4a4ff45a 100644 --- a/tsunami/vdom/vdom_root.go +++ b/tsunami/vdom/vdom_root.go @@ -15,10 +15,6 @@ import ( "github.com/wavetermdev/waveterm/tsunami/util" ) -const ( - BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync - BackendUpdate_ChunkSize = 100 // Size for subsequent chunks -) func (r *RootElem) AddRenderWork(id string) { if r.NeedsRenderMap == nil { @@ -521,74 +517,3 @@ func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { return result } -func (beUpdate *VDomBackendUpdate) CreateTransferElems() { - var allElems []VDomElem - for _, renderUpdate := range beUpdate.RenderUpdates { - if renderUpdate.VDom != nil { - allElems = append(allElems, *renderUpdate.VDom) - } - } - transferElems := ConvertElemsToTransferElems(allElems) - beUpdate.TransferElems = DedupTransferElems(transferElems) - for i := range beUpdate.RenderUpdates { - beUpdate.RenderUpdates[i].VDom = nil - } -} - -func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { - if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { - return []*VDomBackendUpdate{update} - } - - updates := make([]*VDomBackendUpdate, 0) - transferElemChunks := chunkSlice(update.TransferElems, BackendUpdate_ChunkSize) - stateSyncChunks := chunkSlice(update.StateSync, BackendUpdate_ChunkSize) - - maxChunks := len(transferElemChunks) - if len(stateSyncChunks) > maxChunks { - maxChunks = len(stateSyncChunks) - } - - for i := 0; i < maxChunks; i++ { - newUpdate := &VDomBackendUpdate{ - Type: update.Type, - Ts: update.Ts, - BlockId: update.BlockId, - } - - if i == 0 { - newUpdate.Opts = update.Opts - newUpdate.HasWork = update.HasWork - newUpdate.RenderUpdates = update.RenderUpdates - newUpdate.RefOperations = update.RefOperations - newUpdate.Messages = update.Messages - } - - if i < len(transferElemChunks) { - newUpdate.TransferElems = transferElemChunks[i] - } - - if i < len(stateSyncChunks) { - newUpdate.StateSync = stateSyncChunks[i] - } - - updates = append(updates, newUpdate) - } - - return updates -} - -func chunkSlice[T any](slice []T, chunkSize int) [][]T { - if len(slice) == 0 { - return nil - } - chunks := make([][]T, 0) - for i := 0; i < len(slice); i += chunkSize { - end := i + chunkSize - if end > len(slice) { - end = len(slice) - } - chunks = append(chunks, slice[i:end]) - } - return chunks -} diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index a920185e0f..54d849d621 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -5,7 +5,6 @@ package vdom import ( "context" - "time" ) const TextTag = "#text" @@ -89,54 +88,6 @@ type VDomTransferElem struct { //// protocol messages -type VDomCreateContext struct { - Type string `json:"type" tstype:"\"createcontext\""` - Ts int64 `json:"ts"` - Meta map[string]any `json:"meta,omitempty"` - Target *VDomTarget `json:"target,omitempty"` - Persist bool `json:"persist,omitempty"` -} - -type VDomAsyncInitiationRequest struct { - Type string `json:"type" tstype:"\"asyncinitiationrequest\""` - Ts int64 `json:"ts"` - BlockId string `json:"blockid,omitempty"` -} - -func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { - return VDomAsyncInitiationRequest{ - Type: "asyncinitiationrequest", - Ts: time.Now().UnixMilli(), - BlockId: blockId, - } -} - -type VDomFrontendUpdate struct { - Type string `json:"type" tstype:"\"frontendupdate\""` - Ts int64 `json:"ts"` - BlockId string `json:"blockid"` - CorrelationId string `json:"correlationid,omitempty"` - Dispose bool `json:"dispose,omitempty"` // the vdom context was closed - Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads - RenderContext VDomRenderContext `json:"rendercontext,omitempty"` - Events []VDomEvent `json:"events,omitempty"` - StateSync []VDomStateSync `json:"statesync,omitempty"` - RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` - Messages []VDomMessage `json:"messages,omitempty"` -} - -type VDomBackendUpdate struct { - Type string `json:"type" tstype:"\"backendupdate\""` - Ts int64 `json:"ts"` - BlockId string `json:"blockid"` - Opts *VDomBackendOpts `json:"opts,omitempty"` - HasWork bool `json:"haswork,omitempty"` - RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` - TransferElems []VDomTransferElem `json:"transferelems,omitempty"` - StateSync []VDomStateSync `json:"statesync,omitempty"` - RefOperations []VDomRefOperation `json:"refoperations,omitempty"` - Messages []VDomMessage `json:"messages,omitempty"` -} ///// prop types From 819f3ab584d62fe14b1e9ee072748a2eb2a2b802 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 12:03:29 -0700 Subject: [PATCH 004/134] moved componentimpl and rootelem to the comp pkg --- tsunami/app/waveapp.go | 23 +- tsunami/{vdom/vdom_comp.go => comp/comp.go} | 10 +- tsunami/comp/root_test.go | 99 +++++++ .../{vdom/vdom_root.go => comp/rootelem.go} | 161 ++++++----- tsunami/comp/vdomcontext.go | 54 ++++ tsunami/rpctypes/types.go | 261 ++++++++++++++++-- tsunami/vdom/vdom.go | 90 +++--- tsunami/vdom/vdom_context.go | 36 +++ tsunami/vdom/vdom_html.go | 2 +- tsunami/vdom/vdom_test.go | 86 ------ tsunami/vdom/vdom_types.go | 168 ----------- 11 files changed, 553 insertions(+), 437 deletions(-) rename tsunami/{vdom/vdom_comp.go => comp/comp.go} (85%) create mode 100644 tsunami/comp/root_test.go rename tsunami/{vdom/vdom_root.go => comp/rootelem.go} (75%) create mode 100644 tsunami/comp/vdomcontext.go create mode 100644 tsunami/vdom/vdom_context.go diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index c95d228523..fd0d995ca3 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" + "github.com/wavetermdev/waveterm/tsunami/comp" "github.com/wavetermdev/waveterm/tsunami/rpc" "github.com/wavetermdev/waveterm/tsunami/rpcclient" "github.com/wavetermdev/waveterm/tsunami/rpctypes" @@ -34,13 +35,13 @@ type AppOpts struct { RootComponentName string // defaults to "App" NewBlockFlag string // defaults to "n" (set to "-" to disable) TargetNewBlock bool - TargetToolbar *vdom.VDomTargetToolbar + TargetToolbar *rpctypes.VDomTargetToolbar } type Client struct { Lock *sync.Mutex AppOpts AppOpts - Root *vdom.RootElem + Root *comp.RootElem RootElem *vdom.VDomElem RpcClient *rpcclient.RpcClient RpcContext *rpc.RpcContext @@ -50,8 +51,8 @@ type Client struct { VDomContextBlockId string DoneReason string DoneCh chan struct{} - Opts vdom.VDomBackendOpts - GlobalEventHandler func(client *Client, event vdom.VDomEvent) + Opts rpctypes.VDomBackendOpts + GlobalEventHandler func(client *Client, event rpctypes.VDomEvent) GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router OverrideUrlHandler http.Handler @@ -76,7 +77,7 @@ func (c *Client) doShutdown(reason string) { close(c.DoneCh) } -func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event rpctypes.VDomEvent)) { c.GlobalEventHandler = handler } @@ -94,11 +95,11 @@ func MakeClient(appOpts AppOpts) *Client { client := &Client{ Lock: &sync.Mutex{}, AppOpts: appOpts, - Root: vdom.MakeRoot(), + Root: comp.MakeRoot(), RpcClient: rpcclient.MakeRpcClient(), DoneCh: make(chan struct{}), UrlHandlerMux: mux.NewRouter(), - Opts: vdom.VDomBackendOpts{ + Opts: rpctypes.VDomBackendOpts{ CloseOnCtrlC: appOpts.CloseOnCtrlC, GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents, }, @@ -119,7 +120,7 @@ func (c *Client) runMainE() error { if err != nil { return err } - target := &vdom.VDomTarget{} + target := &rpctypes.VDomTarget{} if c.AppOpts.TargetNewBlock || c.NewBlockFlag { target.NewBlock = c.NewBlockFlag } @@ -167,7 +168,7 @@ func (c *Client) SetRootElem(elem *vdom.VDomElem) { c.RootElem = elem } -func (c *Client) CreateVDomContext(target *vdom.VDomTarget) error { +func (c *Client) CreateVDomContext(target *rpctypes.VDomTarget) error { blockORef, err := rpcclient.VDomCreateContextCommand( c.RpcClient, rpctypes.VDomCreateContext{Target: target}, @@ -262,7 +263,7 @@ func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { BlockId: c.RpcContext.BlockId, HasWork: len(c.Root.EffectWorkQueue) > 0, Opts: &c.Opts, - RenderUpdates: []vdom.VDomRenderUpdate{ + RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), @@ -280,7 +281,7 @@ func (c *Client) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { Type: "backendupdate", Ts: time.Now().UnixMilli(), BlockId: c.RpcContext.BlockId, - RenderUpdates: []vdom.VDomRenderUpdate{ + RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, RefOperations: c.Root.GetRefOperations(), diff --git a/tsunami/vdom/vdom_comp.go b/tsunami/comp/comp.go similarity index 85% rename from tsunami/vdom/vdom_comp.go rename to tsunami/comp/comp.go index 37b973951f..07d16c8985 100644 --- a/tsunami/vdom/vdom_comp.go +++ b/tsunami/comp/comp.go @@ -1,7 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -package vdom +package comp + +import "github.com/wavetermdev/waveterm/tsunami/vdom" // so components either render to another component (or fragment) // or to a base element (text or vdom). base elements can then render children @@ -16,11 +18,11 @@ type ComponentImpl struct { WaveId string Tag string Key string - Elem *VDomElem + Elem *vdom.VDomElem Mounted bool // hooks - Hooks []*Hook + Hooks []*vdom.Hook // #text component Text string @@ -37,4 +39,4 @@ func (c *ComponentImpl) compMatch(tag string, key string) bool { return false } return c.Tag == tag && c.Key == key -} \ No newline at end of file +} diff --git a/tsunami/comp/root_test.go b/tsunami/comp/root_test.go new file mode 100644 index 0000000000..30f57ccacf --- /dev/null +++ b/tsunami/comp/root_test.go @@ -0,0 +1,99 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package comp + +import ( + "context" + "encoding/json" + "fmt" + "log" + "testing" + + "github.com/wavetermdev/waveterm/tsunami/rpctypes" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +type renderContextKeyType struct{} + +var renderContextKey = renderContextKeyType{} + +type TestContext struct { + ButtonId string +} + +func Page(ctx context.Context, props map[string]any) any { + clicked, setClicked := vdom.UseState(ctx, false) + var clickedDiv *vdom.VDomElem + if clicked { + clickedDiv = vdom.Bind(`
clicked
`, nil) + } + 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.UseVDomRef(ctx) + clName, setClName := vdom.UseState(ctx, "button") + vdom.UseEffect(ctx, func() func() { + fmt.Printf("Button useEffect\n") + setClName("button mounted") + return nil + }, nil) + compId := vdom.UseId(ctx) + testContext := getTestContext(ctx) + if testContext != nil { + testContext.ButtonId = compId + } + return vdom.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(vdom.E("Page")) + if root.Root == nil { + t.Fatalf("root.Root is nil") + } + printVDom(root) + root.RunWork() + printVDom(root) + root.Event(testContext.ButtonId, "onClick", rpctypes.VDomEvent{EventType: "onClick"}) + root.RunWork() + printVDom(root) +} diff --git a/tsunami/vdom/vdom_root.go b/tsunami/comp/rootelem.go similarity index 75% rename from tsunami/vdom/vdom_root.go rename to tsunami/comp/rootelem.go index 3e4a4ff45a..04b7ab6dcb 100644 --- a/tsunami/vdom/vdom_root.go +++ b/tsunami/comp/rootelem.go @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -package vdom +package comp import ( "context" @@ -12,9 +12,22 @@ import ( "strings" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" + "github.com/wavetermdev/waveterm/tsunami/vdom" ) +type RootElem struct { + OuterCtx context.Context + Root *ComponentImpl + RenderTs int64 + CFuncs map[string]any + CompMap map[string]*ComponentImpl // component waveid -> component + EffectWorkQueue []*vdom.EffectWorkElem + NeedsRenderMap map[string]bool + Atoms map[string]*vdom.Atom + RefOperations []rpctypes.VDomRefOperation +} func (r *RootElem) AddRenderWork(id string) { if r.NeedsRenderMap == nil { @@ -24,7 +37,7 @@ func (r *RootElem) AddRenderWork(id string) { } func (r *RootElem) AddEffectWork(id string, effectIndex int) { - r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{Id: id, EffectIndex: effectIndex}) + r.EffectWorkQueue = append(r.EffectWorkQueue, &vdom.EffectWorkElem{Id: id, EffectIndex: effectIndex}) } func MakeRoot() *RootElem { @@ -32,14 +45,14 @@ func MakeRoot() *RootElem { Root: nil, CFuncs: make(map[string]any), CompMap: make(map[string]*ComponentImpl), - Atoms: make(map[string]*Atom), + Atoms: make(map[string]*vdom.Atom), } } -func (r *RootElem) GetAtom(name string) *Atom { +func (r *RootElem) GetAtom(name string) *vdom.Atom { atom, ok := r.Atoms[name] if !ok { - atom = &Atom{UsedBy: make(map[string]bool)} + atom = &vdom.Atom{UsedBy: make(map[string]bool)} r.Atoms[name] = atom } return atom @@ -50,11 +63,11 @@ func (r *RootElem) GetAtomVal(name string) any { return atom.Val } -func (r *RootElem) GetStateSync(full bool) []VDomStateSync { - stateSync := make([]VDomStateSync, 0) +func (r *RootElem) GetStateSync(full bool) []rpctypes.VDomStateSync { + stateSync := make([]rpctypes.VDomStateSync, 0) for atomName, atom := range r.Atoms { if atom.Dirty || full { - stateSync = append(stateSync, VDomStateSync{Atom: atomName, Value: atom.Val}) + stateSync = append(stateSync, rpctypes.VDomStateSync{Atom: atomName, Value: atom.Val}) atom.Dirty = false } } @@ -123,35 +136,16 @@ func (r *RootElem) RegisterComponent(name string, cfunc any) error { return nil } -func (r *RootElem) Render(elem *VDomElem) { +func (r *RootElem) Render(elem *vdom.VDomElem) { r.render(elem, &r.Root) } -func (vdf *VDomFunc) CallFn(event VDomEvent) { - if vdf.Fn == nil { - return - } - rval := reflect.ValueOf(vdf.Fn) - if rval.Kind() != reflect.Func { - return - } - rtype := rval.Type() - if rtype.NumIn() == 0 { - rval.Call(nil) - } - if rtype.NumIn() == 1 { - if rtype.In(0) == reflect.TypeOf((*VDomEvent)(nil)).Elem() { - rval.Call([]reflect.Value{reflect.ValueOf(event)}) - } - } -} - -func callVDomFn(fnVal any, data VDomEvent) { +func callVDomFn(fnVal any, data rpctypes.VDomEvent) { if fnVal == nil { return } fn := fnVal - if vdf, ok := fnVal.(*VDomFunc); ok { + if vdf, ok := fnVal.(*vdom.VDomFunc); ok { fn = vdf.Fn } if fn == nil { @@ -172,7 +166,7 @@ func callVDomFn(fnVal any, data VDomEvent) { } } -func (r *RootElem) Event(id string, propName string, event VDomEvent) { +func (r *RootElem) Event(id string, propName string, event rpctypes.VDomEvent) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return @@ -215,7 +209,7 @@ func (r *RootElem) RunWork() { } } -func (r *RootElem) render(elem *VDomElem, comp **ComponentImpl) { +func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -226,11 +220,11 @@ func (r *RootElem) render(elem *VDomElem, comp **ComponentImpl) { r.createComp(elem.Tag, elemKey, comp) } (*comp).Elem = elem - if elem.Tag == TextTag { + if elem.Tag == vdom.TextTag { r.renderText(elem.Text, comp) return } - if isBaseTag(elem.Tag) { + if vdom.IsBaseTag(elem.Tag) { // simple vdom, fragment, wave element r.renderSimple(elem, comp) return @@ -278,7 +272,7 @@ func (r *RootElem) renderText(text string, comp **ComponentImpl) { } } -func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { +func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { newChildren := make([]*ComponentImpl, len(elems)) curCM := make(map[ChildKey]*ComponentImpl) usedMap := make(map[*ComponentImpl]bool) @@ -309,32 +303,13 @@ func (r *RootElem) renderChildren(elems []VDomElem, curChildren []*ComponentImpl return newChildren } -func (r *RootElem) renderSimple(elem *VDomElem, comp **ComponentImpl) { +func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) } -func (r *RootElem) makeRenderContext(comp *ComponentImpl) 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 callCFunc(cfunc any, ctx context.Context, props map[string]any) any { rval := reflect.ValueOf(cfunc) arg2Type := rval.Type().In(1) @@ -362,7 +337,7 @@ func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { return rtnVal[0].Interface() } -func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentImpl) { +func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -373,24 +348,25 @@ func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **ComponentIm for k, v := range elem.Props { props[k] = v } - props[ChildrenPropKey] = elem.Children - ctx := r.makeRenderContext(*comp) + props[vdom.ChildrenPropKey] = elem.Children + vc := MakeContextVal(r, *comp) + ctx := vdom.WithRenderContext(r.OuterCtx, vc) renderedElem := callCFunc(cfunc, ctx, props) - rtnElemArr := partToElems(renderedElem) + rtnElemArr := vdom.PartToElems(renderedElem) if len(rtnElemArr) == 0 { r.unmount(&(*comp).Comp) return } - var rtnElem *VDomElem + var rtnElem *vdom.VDomElem if len(rtnElemArr) == 1 { rtnElem = &rtnElemArr[0] } else { - rtnElem = &VDomElem{Tag: FragmentTag, Children: rtnElemArr} + rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr} } r.render(rtnElem, &(*comp).Comp) } -func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { +func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { refId := updateRef.RefId split := strings.SplitN(refId, ":", 2) if len(split) != 2 { @@ -414,7 +390,7 @@ func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { if hook == nil { return } - ref, ok := hook.Val.(*VDomRef) + ref, ok := hook.Val.(*vdom.VDomRef) if !ok { return } @@ -423,11 +399,11 @@ func (r *RootElem) UpdateRef(updateRef VDomRefUpdate) { r.AddRenderWork(waveId) } -func (r *RootElem) QueueRefOp(op VDomRefOperation) { +func (r *RootElem) QueueRefOp(op rpctypes.VDomRefOperation) { r.RefOperations = append(r.RefOperations, op) } -func (r *RootElem) GetRefOperations() []VDomRefOperation { +func (r *RootElem) GetRefOperations() []rpctypes.VDomRefOperation { ops := r.RefOperations r.RefOperations = nil return ops @@ -444,7 +420,7 @@ func convertPropsToVDom(props map[string]any) map[string]any { } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { - vdomProps[k] = VDomFunc{Type: ObjectType_Func} + vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func} continue } vdomProps[k] = v @@ -452,8 +428,8 @@ func convertPropsToVDom(props map[string]any) map[string]any { return vdomProps } -func convertBaseToVDom(c *ComponentImpl) *VDomElem { - elem := &VDomElem{WaveId: c.WaveId, Tag: c.Tag} +func convertBaseToVDom(c *ComponentImpl) *vdom.VDomElem { + elem := &vdom.VDomElem{WaveId: c.WaveId, Tag: c.Tag} if c.Elem != nil { elem.Props = convertPropsToVDom(c.Elem.Props) } @@ -463,13 +439,13 @@ func convertBaseToVDom(c *ComponentImpl) *VDomElem { elem.Children = append(elem.Children, *childElem) } } - if c.Tag == TextTag { + if c.Tag == vdom.TextTag { elem.Text = c.Text } return elem } -func convertCompToVDom(c *ComponentImpl) *VDomElem { +func convertCompToVDom(c *ComponentImpl) *vdom.VDomElem { if c == nil { return nil } @@ -479,17 +455,17 @@ func convertCompToVDom(c *ComponentImpl) *VDomElem { return convertBaseToVDom(c) } -func (r *RootElem) MakeVDom() *VDomElem { +func (r *RootElem) MakeVDom() *vdom.VDomElem { if r.Root == nil { return nil } return convertCompToVDom(r.Root) } -func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem { - transferElems := make([]VDomTransferElem, 0) +func ConvertElemsToTransferElems(elems []vdom.VDomElem) []rpctypes.VDomTransferElem { + transferElems := make([]rpctypes.VDomTransferElem, 0) for _, elem := range elems { - transferElem := VDomTransferElem{ + transferElem := rpctypes.VDomTransferElem{ WaveId: elem.WaveId, Tag: elem.Tag, Props: elem.Props, @@ -505,15 +481,36 @@ func ConvertElemsToTransferElems(elems []VDomElem) []VDomTransferElem { return transferElems } -func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { - seen := make(map[string]bool) - result := make([]VDomTransferElem, 0) - for _, elem := range elems { - if !seen[elem.WaveId] { - seen[elem.WaveId] = true - result = append(result, elem) +func VDomFuncCallFn(vdf *vdom.VDomFunc, event rpctypes.VDomEvent) { + if vdf.Fn == nil { + return + } + rval := reflect.ValueOf(vdf.Fn) + if rval.Kind() != reflect.Func { + return + } + rtype := rval.Type() + if rtype.NumIn() == 0 { + rval.Call(nil) + } + if rtype.NumIn() == 1 { + if rtype.In(0) == reflect.TypeOf((*rpctypes.VDomEvent)(nil)).Elem() { + rval.Call([]reflect.Value{reflect.ValueOf(event)}) } } - return result } +func QueueRefOp(ctx context.Context, ref *vdom.VDomRef, op rpctypes.VDomRefOperation) { + if ref == nil || !ref.HasCurrent { + return + } + vcIf := vdom.GetRenderContext(ctx) + if vcIf == nil { + panic("QueueRefOp must be called within a component (no context)") + } + vc := vcIf.(*VDomContextVal) + if op.RefId == "" { + op.RefId = ref.RefId + } + vc.Root.QueueRefOp(op) +} diff --git a/tsunami/comp/vdomcontext.go b/tsunami/comp/vdomcontext.go new file mode 100644 index 0000000000..63063951db --- /dev/null +++ b/tsunami/comp/vdomcontext.go @@ -0,0 +1,54 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package comp + +import "github.com/wavetermdev/waveterm/tsunami/vdom" + +type VDomContextVal struct { + Root *RootElem + Comp *ComponentImpl + HookIdx int +} + +func MakeContextVal(root *RootElem, comp *ComponentImpl) *VDomContextVal { + return &VDomContextVal{Root: root, Comp: comp, HookIdx: 0} +} + +// Compile-time check to ensure VDomContextVal implements vdom.VDomContext +var _ vdom.VDomContext = (*VDomContextVal)(nil) + +func (vc *VDomContextVal) AddRenderWork(id string) { + vc.Root.AddRenderWork(id) +} + +func (vc *VDomContextVal) AddEffectWork(id string, effectIndex int) { + vc.Root.AddEffectWork(id, effectIndex) +} + +func (vc *VDomContextVal) GetAtom(atomName string) *vdom.Atom { + return vc.Root.GetAtom(atomName) +} + +func (vc *VDomContextVal) GetRenderTs() int64 { + return vc.Root.RenderTs +} + +func (vc *VDomContextVal) GetCompWaveId() string { + if vc.Comp == nil { + return "" + } + return vc.Comp.WaveId +} + +func (vc *VDomContextVal) GetOrderedHook() *vdom.Hook { + if vc.Comp == nil { + panic("tsunami hooks must be called within a component (vc.Comp is nil)") + } + for len(vc.Comp.Hooks) <= vc.HookIdx { + vc.Comp.Hooks = append(vc.Comp.Hooks, &vdom.Hook{Idx: len(vc.Comp.Hooks)}) + } + hookVal := vc.Comp.Hooks[vc.HookIdx] + vc.HookIdx++ + return hookVal +} diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index b76d7e8459..f578cba04e 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -4,6 +4,7 @@ package rpctypes import ( + "fmt" "time" "github.com/wavetermdev/waveterm/tsunami/util" @@ -34,11 +35,11 @@ type CommandWaitForRouteData struct { } type VDomCreateContext struct { - Type string `json:"type" tstype:"\"createcontext\""` - Ts int64 `json:"ts"` - Meta map[string]any `json:"meta,omitempty"` - Target *vdom.VDomTarget `json:"target,omitempty"` - Persist bool `json:"persist,omitempty"` + Type string `json:"type" tstype:"\"createcontext\""` + Ts int64 `json:"ts"` + Meta map[string]any `json:"meta,omitempty"` + Target *VDomTarget `json:"target,omitempty"` + Persist bool `json:"persist,omitempty"` } type VDomAsyncInitiationRequest struct { @@ -56,30 +57,39 @@ func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { } type VDomFrontendUpdate struct { - Type string `json:"type" tstype:"\"frontendupdate\""` - Ts int64 `json:"ts"` - BlockId string `json:"blockid"` - CorrelationId string `json:"correlationid,omitempty"` - Dispose bool `json:"dispose,omitempty"` // the vdom context was closed - Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads - RenderContext vdom.VDomRenderContext `json:"rendercontext,omitempty"` - Events []vdom.VDomEvent `json:"events,omitempty"` - StateSync []vdom.VDomStateSync `json:"statesync,omitempty"` - RefUpdates []vdom.VDomRefUpdate `json:"refupdates,omitempty"` - Messages []vdom.VDomMessage `json:"messages,omitempty"` + Type string `json:"type" tstype:"\"frontendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + CorrelationId string `json:"correlationid,omitempty"` + Dispose bool `json:"dispose,omitempty"` // the vdom context was closed + Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads + RenderContext VDomRenderContext `json:"rendercontext,omitempty"` + Events []VDomEvent `json:"events,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` } type VDomBackendUpdate struct { - Type string `json:"type" tstype:"\"backendupdate\""` - Ts int64 `json:"ts"` - BlockId string `json:"blockid"` - Opts *vdom.VDomBackendOpts `json:"opts,omitempty"` - HasWork bool `json:"haswork,omitempty"` - RenderUpdates []vdom.VDomRenderUpdate `json:"renderupdates,omitempty"` - TransferElems []vdom.VDomTransferElem `json:"transferelems,omitempty"` - StateSync []vdom.VDomStateSync `json:"statesync,omitempty"` - RefOperations []vdom.VDomRefOperation `json:"refoperations,omitempty"` - Messages []vdom.VDomMessage `json:"messages,omitempty"` + Type string `json:"type" tstype:"\"backendupdate\""` + Ts int64 `json:"ts"` + BlockId string `json:"blockid"` + Opts *VDomBackendOpts `json:"opts,omitempty"` + HasWork bool `json:"haswork,omitempty"` + RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` + TransferElems []VDomTransferElem `json:"transferelems,omitempty"` + StateSync []VDomStateSync `json:"statesync,omitempty"` + RefOperations []VDomRefOperation `json:"refoperations,omitempty"` + Messages []VDomMessage `json:"messages,omitempty"` +} + +// the over the wire format for a vdom element +type VDomTransferElem struct { + WaveId string `json:"waveid,omitempty"` // required, except for #text nodes + Tag string `json:"tag"` + Props map[string]any `json:"props,omitempty"` + Children []string `json:"children,omitempty"` + Text string `json:"text,omitempty"` } func (beUpdate *VDomBackendUpdate) CreateTransferElems() { @@ -89,13 +99,82 @@ func (beUpdate *VDomBackendUpdate) CreateTransferElems() { allElems = append(allElems, *renderUpdate.VDom) } } - transferElems := vdom.ConvertElemsToTransferElems(allElems) - beUpdate.TransferElems = vdom.DedupTransferElems(transferElems) + transferElems := ConvertElemsToTransferElems(allElems) + beUpdate.TransferElems = DedupTransferElems(transferElems) for i := range beUpdate.RenderUpdates { beUpdate.RenderUpdates[i].VDom = nil } } +func ConvertElemsToTransferElems(elems []vdom.VDomElem) []VDomTransferElem { + var transferElems []VDomTransferElem + textCounter := 0 // Counter for generating unique IDs for #text nodes + + // Helper function to recursively process each VDomElem in preorder + var processElem func(elem vdom.VDomElem) string + processElem = func(elem vdom.VDomElem) string { + // Handle #text nodes by generating a unique placeholder ID + if elem.Tag == "#text" { + textId := fmt.Sprintf("text-%d", textCounter) + textCounter++ + transferElems = append(transferElems, VDomTransferElem{ + WaveId: textId, + Tag: elem.Tag, + Text: elem.Text, + Props: nil, + Children: nil, + }) + return textId + } + + // Convert children to WaveId references, handling potential #text nodes + childrenIds := make([]string, len(elem.Children)) + for i, child := range elem.Children { + childrenIds[i] = processElem(child) // Children are not roots + } + + // Create the VDomTransferElem for the current element + transferElem := VDomTransferElem{ + WaveId: elem.WaveId, + Tag: elem.Tag, + Props: elem.Props, + Children: childrenIds, + Text: elem.Text, + } + transferElems = append(transferElems, transferElem) + + return elem.WaveId + } + + // Start processing each top-level element, marking them as roots + for _, elem := range elems { + processElem(elem) + } + + return transferElems +} + +func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { + seen := make(map[string]int) // maps WaveId to its index in the result slice + var result []VDomTransferElem + + for _, elem := range elems { + if idx, exists := seen[elem.WaveId]; exists { + // Overwrite the previous element with the latest one + result[idx] = elem + } else { + // Add new element and store its index + seen[elem.WaveId] = len(result) + result = append(result, elem) + } + } + + return result +} + +// SplitBackendUpdate splits a large VDomBackendUpdate into multiple smaller updates +// The first update contains all the core fields, while subsequent updates only contain +// array elements that need to be appended func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { return []*VDomBackendUpdate{update} @@ -138,3 +217,129 @@ func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { return updates } + +type VDomEvent struct { + WaveId string `json:"waveid"` + EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) + GlobalEventType string `json:"globaleventtype,omitempty"` + TargetValue string `json:"targetvalue,omitempty"` + TargetChecked bool `json:"targetchecked,omitempty"` + TargetName string `json:"targetname,omitempty"` + TargetId string `json:"targetid,omitempty"` + KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` + MouseData *WavePointerData `json:"mousedata,omitempty"` +} + +type VDomRenderContext struct { + BlockId string `json:"blockid"` + Focused bool `json:"focused"` + Width int `json:"width"` + Height int `json:"height"` + RootRefId string `json:"rootrefid"` + Background bool `json:"background,omitempty"` +} + +type VDomStateSync struct { + Atom string `json:"atom"` + Value any `json:"value"` +} + +type VDomRefUpdate struct { + RefId string `json:"refid"` + HasCurrent bool `json:"hascurrent"` + Position *vdom.VDomRefPosition `json:"position,omitempty"` +} + +type VDomBackendOpts struct { + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` + GlobalStyles bool `json:"globalstyles,omitempty"` +} + +type VDomRenderUpdate struct { + UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` + WaveId string `json:"waveid,omitempty"` + VDomWaveId string `json:"vdomwaveid,omitempty"` + VDom *vdom.VDomElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems) + Index *int `json:"index,omitempty"` +} + +type VDomRefOperation struct { + RefId string `json:"refid"` + Op string `json:"op"` + Params []any `json:"params,omitempty"` + OutputRef string `json:"outputref,omitempty"` +} + +type VDomMessage struct { + MessageType string `json:"messagetype"` + Message string `json:"message"` + StackTrace string `json:"stacktrace,omitempty"` + Params []any `json:"params,omitempty"` +} + +// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. +// default is vdom context inside of a terminal block +type VDomTarget struct { + NewBlock bool `json:"newblock,omitempty"` + Magnified bool `json:"magnified,omitempty"` + Toolbar *VDomTargetToolbar `json:"toolbar,omitempty"` +} + +type VDomTargetToolbar struct { + Toolbar bool `json:"toolbar"` + Height string `json:"height,omitempty"` +} + +// matches WaveKeyboardEvent +type VDomKeyboardEvent struct { + Type string `json:"type"` + Key string `json:"key"` + Code string `json:"code"` + Shift bool `json:"shift,omitempty"` + Control bool `json:"ctrl,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` + Option bool `json:"option,omitempty"` + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` +} + +type WaveKeyboardEvent struct { + Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` + Key string `json:"key"` // KeyboardEvent.key + Code string `json:"code"` // KeyboardEvent.code + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` // KeyboardEvent.location + + // modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} + +type WavePointerData struct { + Button int `json:"button"` + Buttons int `json:"buttons"` + + ClientX int `json:"clientx,omitempty"` + ClientY int `json:"clienty,omitempty"` + PageX int `json:"pagex,omitempty"` + PageY int `json:"pagey,omitempty"` + ScreenX int `json:"screenx,omitempty"` + ScreenY int `json:"screeny,omitempty"` + MovementX int `json:"movementx,omitempty"` + MovementY int `json:"movementy,omitempty"` + + // Modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index c4f6c037f0..dab9465182 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -148,7 +148,7 @@ func H(tag string, props map[string]any, children ...any) *VDomElem { rtn := &VDomElem{Tag: tag, Props: props} if len(children) > 0 { for _, part := range children { - elems := partToElems(part) + elems := PartToElems(part) rtn.Children = append(rtn.Children, elems...) } } @@ -180,7 +180,7 @@ func E(tag string, parts ...any) *VDomElem { mergeClassAttr(&rtn.Props, classAttr) continue } - elems := partToElems(part) + elems := PartToElems(part) rtn.Children = append(rtn.Children, elems...) } return rtn @@ -287,24 +287,9 @@ func P(propName string, propVal any) 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) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true hookVal.Val = initialVal @@ -316,13 +301,14 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { } setVal := func(newVal T) { hookVal.Val = newVal - vc.Root.AddRenderWork(vc.Comp.WaveId) + vc.AddRenderWork(vc.GetCompWaveId()) } return rtnVal, setVal } func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) { - vc, hookVal := getHookFromCtx(ctx) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true hookVal.Val = initialVal @@ -335,29 +321,30 @@ func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func( setVal := func(newVal T) { hookVal.Val = newVal - vc.Root.AddRenderWork(vc.Comp.WaveId) + vc.AddRenderWork(vc.GetCompWaveId()) } setFuncVal := func(updateFunc func(T) T) { hookVal.Val = updateFunc(hookVal.Val.(T)) - vc.Root.AddRenderWork(vc.Comp.WaveId) + vc.AddRenderWork(vc.GetCompWaveId()) } return rtnVal, setVal, setFuncVal } func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { - vc, hookVal := getHookFromCtx(ctx) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true - closedWaveId := vc.Comp.WaveId + closedWaveId := vc.GetCompWaveId() hookVal.UnmountFn = func() { - atom := vc.Root.GetAtom(atomName) + atom := vc.GetAtom(atomName) delete(atom.UsedBy, closedWaveId) } } - atom := vc.Root.GetAtom(atomName) - atom.UsedBy[vc.Comp.WaveId] = true + atom := vc.GetAtom(atomName) + atom.UsedBy[vc.GetCompWaveId()] = true atomVal, ok := atom.Val.(T) if !ok { panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) @@ -365,17 +352,18 @@ func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { setVal := func(newVal T) { atom.Val = newVal for waveId := range atom.UsedBy { - vc.Root.AddRenderWork(waveId) + vc.AddRenderWork(waveId) } } return atomVal, setVal } func UseVDomRef(ctx context.Context) *VDomRef { - vc, hookVal := getHookFromCtx(ctx) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true - refId := vc.Comp.WaveId + ":" + strconv.Itoa(hookVal.Idx) + refId := vc.GetCompWaveId() + ":" + strconv.Itoa(hookVal.Idx) hookVal.Val = &VDomRef{Type: ObjectType_Ref, RefId: refId} } refVal, ok := hookVal.Val.(*VDomRef) @@ -386,7 +374,8 @@ func UseVDomRef(ctx context.Context) *VDomRef { } func UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] { - _, hookVal := getHookFromCtx(ctx) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true hookVal.Val = &VDomSimpleRef[T]{Current: val} @@ -399,33 +388,19 @@ func UseRef[T any](ctx context.Context, val T) *VDomSimpleRef[T] { } func UseId(ctx context.Context) string { - vc := getRenderContext(ctx) + vc := GetRenderContext(ctx) if vc == nil { panic("UseId must be called within a component (no context)") } - return vc.Comp.WaveId + return vc.GetCompWaveId() } func UseRenderTs(ctx context.Context) int64 { - vc := getRenderContext(ctx) + vc := GetRenderContext(ctx) if vc == nil { panic("UseRenderTs must be called within a component (no context)") } - return vc.Root.RenderTs -} - -func QueueRefOp(ctx context.Context, ref *VDomRef, op VDomRefOperation) { - if ref == nil || !ref.HasCurrent { - return - } - vc := getRenderContext(ctx) - if vc == nil { - panic("QueueRefOp must be called within a component (no context)") - } - if op.RefId == "" { - op.RefId = ref.RefId - } - vc.Root.QueueRefOp(op) + return vc.GetRenderTs() } func depsEqual(deps1 []any, deps2 []any) bool { @@ -442,12 +417,13 @@ func depsEqual(deps1 []any, deps2 []any) bool { 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) + vc := GetRenderContext(ctx) + hookVal := vc.GetOrderedHook() if !hookVal.Init { hookVal.Init = true hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) + vc.AddEffectWork(vc.GetCompWaveId(), hookVal.Idx) return } if depsEqual(hookVal.Deps, deps) { @@ -455,10 +431,10 @@ func UseEffect(ctx context.Context, fn func() func(), deps []any) { } hookVal.Fn = fn hookVal.Deps = deps - vc.Root.AddEffectWork(vc.Comp.WaveId, hookVal.Idx) + vc.AddEffectWork(vc.GetCompWaveId(), hookVal.Idx) } -func partToElems(part any) []VDomElem { +func PartToElems(part any) []VDomElem { if part == nil { return nil } @@ -485,7 +461,7 @@ func partToElems(part any) []VDomElem { case []any: var rtn []VDomElem for _, subPart := range partTyped { - rtn = append(rtn, partToElems(subPart)...) + rtn = append(rtn, PartToElems(subPart)...) } return rtn default: @@ -493,7 +469,7 @@ func partToElems(part any) []VDomElem { if partVal.Kind() == reflect.Slice { var rtn []VDomElem for i := 0; i < partVal.Len(); i++ { - rtn = append(rtn, partToElems(partVal.Index(i).Interface())...) + rtn = append(rtn, PartToElems(partVal.Index(i).Interface())...) } return rtn } @@ -505,7 +481,7 @@ func partToElems(part any) []VDomElem { } } -func isBaseTag(tag string) bool { +func IsBaseTag(tag string) bool { if tag == "" { return false } diff --git a/tsunami/vdom/vdom_context.go b/tsunami/vdom/vdom_context.go new file mode 100644 index 0000000000..b693116ff0 --- /dev/null +++ b/tsunami/vdom/vdom_context.go @@ -0,0 +1,36 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdom + +import ( + "context" +) + +type vdomContextKeyType struct{} + +var vdomContextKey = vdomContextKeyType{} + +type VDomContext interface { + AddRenderWork(id string) + AddEffectWork(id string, effectIndex int) + GetAtom(atomName string) *Atom + GetRenderTs() int64 + GetCompWaveId() string + GetOrderedHook() *Hook +} + +func WithRenderContext(ctx context.Context, vc VDomContext) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, vdomContextKey, vc) +} + +func GetRenderContext(ctx context.Context) VDomContext { + v := ctx.Value(vdomContextKey) + if v == nil { + return nil + } + return v.(VDomContext) +} diff --git a/tsunami/vdom/vdom_html.go b/tsunami/vdom/vdom_html.go index 9501ca2c53..b167d4d0f4 100644 --- a/tsunami/vdom/vdom_html.go +++ b/tsunami/vdom/vdom_html.go @@ -356,7 +356,7 @@ outer: if token.Data == Html_BindParamTagName { keyAttr := getAttrString(token, "key") dataVal := params[keyAttr] - elemList := partToElems(dataVal) + elemList := PartToElems(dataVal) for _, elem := range elemList { appendChildToStack(elemStack, &elem) } diff --git a/tsunami/vdom/vdom_test.go b/tsunami/vdom/vdom_test.go index 7ecf35facb..9c5c2ef79a 100644 --- a/tsunami/vdom/vdom_test.go +++ b/tsunami/vdom/vdom_test.go @@ -1,9 +1,7 @@ package vdom import ( - "context" "encoding/json" - "fmt" "log" "reflect" "testing" @@ -11,90 +9,6 @@ import ( "github.com/wavetermdev/waveterm/tsunami/util" ) -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 *VDomElem - 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 := UseVDomRef(ctx) - 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", VDomEvent{EventType: "onClick"}) - root.RunWork() - printVDom(root) -} - func TestBind(t *testing.T) { elem := Bind(`
clicked
`, nil) jsonBytes, _ := json.MarshalIndent(elem, "", " ") diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index 54d849d621..cfce234243 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -3,10 +3,6 @@ package vdom -import ( - "context" -) - const TextTag = "#text" const WaveTextTag = "wave:text" const WaveNullTag = "wave:null" @@ -30,34 +26,12 @@ type Hook struct { Deps []any } -type vdomContextKeyType struct{} - -var vdomContextKey = vdomContextKeyType{} - -type VDomContextVal struct { - Root *RootElem - Comp *ComponentImpl - HookIdx int -} - type Atom struct { Val any Dirty bool UsedBy map[string]bool // component waveid -> true } -type RootElem struct { - OuterCtx context.Context - Root *ComponentImpl - RenderTs int64 - CFuncs map[string]any - CompMap map[string]*ComponentImpl // component waveid -> component - EffectWorkQueue []*EffectWorkElem - NeedsRenderMap map[string]bool - Atoms map[string]*Atom - RefOperations []VDomRefOperation -} - const ( WorkType_Render = "render" WorkType_Effect = "effect" @@ -77,20 +51,6 @@ type VDomElem struct { Text string `json:"text,omitempty"` } -// the over the wire format for a vdom element -type VDomTransferElem struct { - WaveId string `json:"waveid,omitempty"` // required, except for #text nodes - Tag string `json:"tag"` - Props map[string]any `json:"props,omitempty"` - Children []string `json:"children,omitempty"` - Text string `json:"text,omitempty"` -} - -//// protocol messages - - -///// prop types - // used in props type VDomBinding struct { Type string `json:"type" tstype:"\"binding\""` @@ -137,131 +97,3 @@ type VDomRefPosition struct { ScrollTop int `json:"scrolltop"` BoundingClientRect DomRect `json:"boundingclientrect"` } - -///// subbordinate protocol types - -type VDomEvent struct { - WaveId string `json:"waveid"` - EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) - GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` - TargetChecked bool `json:"targetchecked,omitempty"` - TargetName string `json:"targetname,omitempty"` - TargetId string `json:"targetid,omitempty"` - KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` - MouseData *WavePointerData `json:"mousedata,omitempty"` -} - -type VDomRenderContext struct { - BlockId string `json:"blockid"` - Focused bool `json:"focused"` - Width int `json:"width"` - Height int `json:"height"` - RootRefId string `json:"rootrefid"` - Background bool `json:"background,omitempty"` -} - -type VDomStateSync struct { - Atom string `json:"atom"` - Value any `json:"value"` -} - -type VDomRefUpdate struct { - RefId string `json:"refid"` - HasCurrent bool `json:"hascurrent"` - Position *VDomRefPosition `json:"position,omitempty"` -} - -type VDomBackendOpts struct { - CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` - GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` - GlobalStyles bool `json:"globalstyles,omitempty"` -} - -type VDomRenderUpdate struct { - UpdateType string `json:"updatetype" tstype:"\"root\"|\"append\"|\"replace\"|\"remove\"|\"insert\""` - WaveId string `json:"waveid,omitempty"` - VDomWaveId string `json:"vdomwaveid,omitempty"` - VDom *VDomElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems) - Index *int `json:"index,omitempty"` -} - -type VDomRefOperation struct { - RefId string `json:"refid"` - Op string `json:"op"` - Params []any `json:"params,omitempty"` - OutputRef string `json:"outputref,omitempty"` -} - -type VDomMessage struct { - MessageType string `json:"messagetype"` - Message string `json:"message"` - StackTrace string `json:"stacktrace,omitempty"` - Params []any `json:"params,omitempty"` -} - -// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. -// default is vdom context inside of a terminal block -type VDomTarget struct { - NewBlock bool `json:"newblock,omitempty"` - Magnified bool `json:"magnified,omitempty"` - Toolbar *VDomTargetToolbar `json:"toolbar,omitempty"` -} - -type VDomTargetToolbar struct { - Toolbar bool `json:"toolbar"` - Height string `json:"height,omitempty"` -} - -// matches WaveKeyboardEvent -type VDomKeyboardEvent struct { - Type string `json:"type"` - Key string `json:"key"` - Code string `json:"code"` - Shift bool `json:"shift,omitempty"` - Control bool `json:"ctrl,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` - Option bool `json:"option,omitempty"` - Repeat bool `json:"repeat,omitempty"` - Location int `json:"location,omitempty"` -} - -type WaveKeyboardEvent struct { - Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` - Key string `json:"key"` // KeyboardEvent.key - Code string `json:"code"` // KeyboardEvent.code - Repeat bool `json:"repeat,omitempty"` - Location int `json:"location,omitempty"` // KeyboardEvent.location - - // modifiers - Shift bool `json:"shift,omitempty"` - Control bool `json:"control,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) - Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) -} - -type WavePointerData struct { - Button int `json:"button"` - Buttons int `json:"buttons"` - - ClientX int `json:"clientx,omitempty"` - ClientY int `json:"clienty,omitempty"` - PageX int `json:"pagex,omitempty"` - PageY int `json:"pagey,omitempty"` - ScreenX int `json:"screenx,omitempty"` - ScreenY int `json:"screeny,omitempty"` - MovementX int `json:"movementx,omitempty"` - MovementY int `json:"movementy,omitempty"` - - // Modifiers - Shift bool `json:"shift,omitempty"` - Control bool `json:"control,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) - Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) -} \ No newline at end of file From cd8ae61944b31519b278d2d276a459b1ccdda9d1 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 12:06:55 -0700 Subject: [PATCH 005/134] remove vdom.scss --- frontend/app/view/vdom/vdom.scss | 8 -------- frontend/app/view/vdom/vdom.tsx | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 frontend/app/view/vdom/vdom.scss diff --git a/frontend/app/view/vdom/vdom.scss b/frontend/app/view/vdom/vdom.scss deleted file mode 100644 index b8cd5f3003..0000000000 --- a/frontend/app/view/vdom/vdom.scss +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.view-vdom { - overflow: auto; - width: 100%; - min-height: 100%; -} diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx index 4634defd87..eacdb79424 100644 --- a/frontend/app/view/vdom/vdom.tsx +++ b/frontend/app/view/vdom/vdom.tsx @@ -16,7 +16,6 @@ import { validateAndWrapCss, validateAndWrapReactStyle, } from "@/app/view/vdom/vdom-utils"; -import "./vdom.scss"; const TextTag = "#text"; const FragmentTag = "#fragment"; @@ -506,7 +505,7 @@ function VDomView({ blockId, model }: VDomViewProps) { model.viewRef = viewRef; const vdomClass = "vdom-" + blockId; return ( -
+
{contextActive ? : null}
); From 73b3527e5c8290f952b3e96be576f030c861e4f8 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 13:26:35 -0700 Subject: [PATCH 006/134] server handlers + listenandserve --- tsunami/app/serverhandlers.go | 138 ++++++++++++++++++++++++++++++++++ tsunami/app/waveapp.go | 47 +++++++++++- 2 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tsunami/app/serverhandlers.go diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go new file mode 100644 index 0000000000..b20b9ce13f --- /dev/null +++ b/tsunami/app/serverhandlers.go @@ -0,0 +1,138 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveapp + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "github.com/wavetermdev/waveterm/tsunami/rpctypes" + "github.com/wavetermdev/waveterm/tsunami/util" +) + +type HTTPHandlers struct { + Client *Client + BlockId string +} + +func NewHTTPHandlers(client *Client, blockId string) *HTTPHandlers { + return &HTTPHandlers{ + Client: client, + BlockId: blockId, + } +} + +func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux) { + mux.HandleFunc("/api/render", h.handleRender) + mux.HandleFunc("/vdom/", h.handleVDomUrl) +} + +func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleRender", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var feUpdate rpctypes.VDomFrontendUpdate + if err := json.Unmarshal(body, &feUpdate); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + + if feUpdate.Dispose { + log.Printf("got dispose from frontend\n") + h.Client.doShutdown("got dispose from frontend") + w.WriteHeader(http.StatusOK) + return + } + + if h.Client.GetIsDone() { + w.WriteHeader(http.StatusOK) + return + } + + h.Client.Root.RenderTs = feUpdate.Ts + + // set atoms + for _, ss := range feUpdate.StateSync { + h.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.GlobalEventType != "" { + if h.Client.GlobalEventHandler != nil { + h.Client.GlobalEventHandler(h.Client, event) + } + } else { + h.Client.Root.Event(event.WaveId, event.EventType, event) + } + } + // update refs + for _, ref := range feUpdate.RefUpdates { + h.Client.Root.UpdateRef(ref) + } + + var update *rpctypes.VDomBackendUpdate + var renderErr error + + if feUpdate.Resync || true { + update, renderErr = h.Client.fullRender() + } else { + update, renderErr = h.Client.incrementalRender() + } + + if renderErr != nil { + http.Error(w, fmt.Sprintf("render error: %v", renderErr), http.StatusInternalServerError) + return + } + + update.CreateTransferElems() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(update); err != nil { + log.Printf("failed to encode response: %v", err) + } +} + +func (h *HTTPHandlers) handleVDomUrl(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleVDomUrl", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + // Strip /vdom prefix and update the request URL + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/vdom") + if r.URL.Path == "" { + r.URL.Path = "/" + } + + if r.URL.Path == "/wave/global.css" && h.Client.GlobalStylesOption != nil { + ServeFileOption(w, r, *h.Client.GlobalStylesOption) + return + } + if h.Client.OverrideUrlHandler != nil { + h.Client.OverrideUrlHandler.ServeHTTP(w, r) + return + } + h.Client.UrlHandlerMux.ServeHTTP(w, r) +} diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index fd0d995ca3..14fc0f9612 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -5,12 +5,12 @@ package waveapp import ( "context" - "errors" "flag" "fmt" "io" "io/fs" "log" + "net" "net/http" "os" "strings" @@ -116,7 +116,7 @@ func (c *Client) runMainE() error { if c.SetupFn != nil { c.SetupFn() } - err := c.Connect() + err := c.ListenAndServe(context.Background()) if err != nil { return err } @@ -160,8 +160,47 @@ func (c *Client) RunMain() { } } -func (c *Client) Connect() error { - return errors.New("unimplemented") +func (c *Client) ListenAndServe(ctx context.Context) error { + // Create HTTP handlers + handlers := NewHTTPHandlers(c, c.RpcContext.BlockId) + + // Create a new ServeMux and register handlers + mux := http.NewServeMux() + handlers.RegisterHandlers(mux) + + // Create server and listen on any available port on localhost + server := &http.Server{ + Addr: "localhost:0", + Handler: mux, + } + + // Start listening + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + return fmt.Errorf("failed to listen: %v", err) + } + + // Log the port we're listening on + port := listener.Addr().(*net.TCPAddr).Port + log.Printf("Wave app server listening on port %d", port) + + // Serve in a goroutine so we don't block + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } + }() + + // Wait for context cancellation and shutdown server gracefully + go func() { + <-ctx.Done() + log.Printf("Context canceled, shutting down server...") + if err := server.Shutdown(context.Background()); err != nil { + log.Printf("Server shutdown error: %v", err) + } + }() + + return nil } func (c *Client) SetRootElem(elem *vdom.VDomElem) { From ec54dc96a32c3a98515213d6c7e29b8010034a87 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 13:28:14 -0700 Subject: [PATCH 007/134] use handlers intead of rpc --- tsunami/app/waveapp.go | 15 ++- tsunami/app/waveappserverimpl.go | 151 ------------------------------- 2 files changed, 7 insertions(+), 159 deletions(-) delete mode 100644 tsunami/app/waveappserverimpl.go diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index 14fc0f9612..5f0e660e1a 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -45,7 +45,6 @@ type Client struct { RootElem *vdom.VDomElem RpcClient *rpcclient.RpcClient RpcContext *rpc.RpcContext - ServerImpl *WaveAppServerImpl IsDone bool RouteId string VDomContextBlockId string @@ -163,34 +162,34 @@ func (c *Client) RunMain() { func (c *Client) ListenAndServe(ctx context.Context) error { // Create HTTP handlers handlers := NewHTTPHandlers(c, c.RpcContext.BlockId) - + // Create a new ServeMux and register handlers mux := http.NewServeMux() handlers.RegisterHandlers(mux) - + // Create server and listen on any available port on localhost server := &http.Server{ Addr: "localhost:0", Handler: mux, } - + // Start listening listener, err := net.Listen("tcp", "localhost:0") if err != nil { return fmt.Errorf("failed to listen: %v", err) } - + // Log the port we're listening on port := listener.Addr().(*net.TCPAddr).Port log.Printf("Wave app server listening on port %d", port) - + // Serve in a goroutine so we don't block go func() { if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { log.Printf("HTTP server error: %v", err) } }() - + // Wait for context cancellation and shutdown server gracefully go func() { <-ctx.Done() @@ -199,7 +198,7 @@ func (c *Client) ListenAndServe(ctx context.Context) error { log.Printf("Server shutdown error: %v", err) } }() - + return nil } diff --git a/tsunami/app/waveappserverimpl.go b/tsunami/app/waveappserverimpl.go deleted file mode 100644 index 376f7aae1d..0000000000 --- a/tsunami/app/waveappserverimpl.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveapp - -import ( - "bytes" - "context" - "fmt" - "log" - "net/http" - - "github.com/wavetermdev/waveterm/tsunami/rpc" - "github.com/wavetermdev/waveterm/tsunami/rpctypes" - "github.com/wavetermdev/waveterm/tsunami/util" -) - -type WaveAppServerImpl struct { - Client *Client - BlockId string -} - -func (*WaveAppServerImpl) WshServerImpl() {} - -func (impl *WaveAppServerImpl) VDomRenderCommand(ctx context.Context, feUpdate rpctypes.VDomFrontendUpdate) chan rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate] { - respChan := make(chan rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate], 5) - defer func() { - panicErr := util.PanicHandler("VDomRenderCommand", recover()) - if panicErr != nil { - respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ - Error: panicErr, - } - close(respChan) - } - }() - - if feUpdate.Dispose { - defer close(respChan) - log.Printf("got dispose from frontend\n") - impl.Client.doShutdown("got dispose from frontend") - return respChan - } - - if impl.Client.GetIsDone() { - close(respChan) - return respChan - } - - impl.Client.Root.RenderTs = feUpdate.Ts - - // set atoms - for _, ss := range feUpdate.StateSync { - impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) - } - // run events - for _, event := range feUpdate.Events { - if event.GlobalEventType != "" { - if impl.Client.GlobalEventHandler != nil { - impl.Client.GlobalEventHandler(impl.Client, event) - } - } else { - impl.Client.Root.Event(event.WaveId, event.EventType, event) - } - } - // update refs - for _, ref := range feUpdate.RefUpdates { - impl.Client.Root.UpdateRef(ref) - } - - var update *rpctypes.VDomBackendUpdate - var err error - - if feUpdate.Resync || true { - update, err = impl.Client.fullRender() - } else { - update, err = impl.Client.incrementalRender() - } - update.CreateTransferElems() - - if err != nil { - respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ - Error: err, - } - close(respChan) - return respChan - } - - // Split the update into chunks and send them sequentially - updates := rpctypes.SplitBackendUpdate(update) - go func() { - defer func() { - util.PanicHandler("VDomRenderCommand:splitUpdates", recover()) - }() - defer close(respChan) - for _, splitUpdate := range updates { - respChan <- rpc.RespOrErrorUnion[*rpctypes.VDomBackendUpdate]{ - Response: splitUpdate, - } - } - }() - - return respChan -} - -func (impl *WaveAppServerImpl) VDomUrlRequestCommand(ctx context.Context, data rpc.VDomUrlRequestData) chan rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse] { - respChan := make(chan rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]) - writer := NewStreamingResponseWriter(respChan) - - go func() { - defer close(respChan) // Declared first, so it executes last - defer writer.Close() // Ensures writer is closed before the channel is closed - - defer func() { - panicErr := util.PanicHandler("VDomUrlRequestCommand", recover()) - if panicErr != nil { - writer.WriteHeader(http.StatusInternalServerError) - writer.Write([]byte(fmt.Sprintf("internal server error: %v", panicErr))) - } - }() - - // Create an HTTP request from the RPC request data - var bodyReader *bytes.Reader - if data.Body != nil { - bodyReader = bytes.NewReader(data.Body) - } else { - bodyReader = bytes.NewReader([]byte{}) - } - - httpReq, err := http.NewRequest(data.Method, data.URL, bodyReader) - if err != nil { - writer.WriteHeader(http.StatusInternalServerError) - writer.Write([]byte(err.Error())) - return - } - - for key, value := range data.Headers { - httpReq.Header.Set(key, value) - } - if httpReq.URL.Path == "/wave/global.css" && impl.Client.GlobalStylesOption != nil { - ServeFileOption(writer, httpReq, *impl.Client.GlobalStylesOption) - return - } - if impl.Client.OverrideUrlHandler != nil { - impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq) - return - } - impl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq) - }() - - return respChan -} From e58d7265188de048d7efbca784cdfba7168c1893 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 13:46:13 -0700 Subject: [PATCH 008/134] add an sse handler --- tsunami/app/serverhandlers.go | 66 ++++++++++++++++++++++++++++++++--- tsunami/app/waveapp.go | 9 ++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index b20b9ce13f..904c52c1da 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -10,25 +10,27 @@ import ( "log" "net/http" "strings" + "time" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" ) +const SSEKeepAliveDuration = 5 * time.Second + type HTTPHandlers struct { - Client *Client - BlockId string + Client *Client } -func NewHTTPHandlers(client *Client, blockId string) *HTTPHandlers { +func NewHTTPHandlers(client *Client) *HTTPHandlers { return &HTTPHandlers{ - Client: client, - BlockId: blockId, + Client: client, } } func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/api/render", h.handleRender) + mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/vdom/", h.handleVDomUrl) } @@ -136,3 +138,57 @@ func (h *HTTPHandlers) handleVDomUrl(w http.ResponseWriter, r *http.Request) { } h.Client.UrlHandlerMux.ServeHTTP(w, r) } + +func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleSSE", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Set SSE headers + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("X-Content-Type-Options", "nosniff") + + // Flush headers immediately + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + flusher.Flush() + + // Create a ticker for keepalive packets + keepaliveTicker := time.NewTicker(SSEKeepAliveDuration) + defer keepaliveTicker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-keepaliveTicker.C: + // Send keepalive comment + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + case event := <-h.Client.SSEventCh: + // Send actual event + if event.Event != "" { + fmt.Fprintf(w, "event: %s\n", event.Event) + } + if len(event.Data) > 0 { + fmt.Fprintf(w, "data: %s\n", string(event.Data)) + } + fmt.Fprintf(w, "\n") + flusher.Flush() + } + } +} diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index 5f0e660e1a..bfb8446761 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -28,6 +28,11 @@ import ( "github.com/wavetermdev/waveterm/tsunami/vdom" ) +type SSEvent struct { + Event string + Data []byte +} + type AppOpts struct { CloseOnCtrlC bool GlobalKeyboardEvents bool @@ -50,6 +55,7 @@ type Client struct { VDomContextBlockId string DoneReason string DoneCh chan struct{} + SSEventCh chan SSEvent Opts rpctypes.VDomBackendOpts GlobalEventHandler func(client *Client, event rpctypes.VDomEvent) GlobalStylesOption *FileHandlerOption @@ -97,6 +103,7 @@ func MakeClient(appOpts AppOpts) *Client { Root: comp.MakeRoot(), RpcClient: rpcclient.MakeRpcClient(), DoneCh: make(chan struct{}), + SSEventCh: make(chan SSEvent, 100), UrlHandlerMux: mux.NewRouter(), Opts: rpctypes.VDomBackendOpts{ CloseOnCtrlC: appOpts.CloseOnCtrlC, @@ -161,7 +168,7 @@ func (c *Client) RunMain() { func (c *Client) ListenAndServe(ctx context.Context) error { // Create HTTP handlers - handlers := NewHTTPHandlers(c, c.RpcContext.BlockId) + handlers := NewHTTPHandlers(c) // Create a new ServeMux and register handlers mux := http.NewServeMux() From fc9f1e14f72abb754df9f791cb808e0cd1f06bc9 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 13:51:51 -0700 Subject: [PATCH 009/134] remove client, implement sse for async initiation --- tsunami/app/serverhandlers.go | 6 ++-- tsunami/app/waveapp.go | 59 ++++------------------------------ tsunami/rpcclient/client.go | 43 ------------------------- tsunami/rpcclient/rpcclient.go | 27 ---------------- 4 files changed, 10 insertions(+), 125 deletions(-) delete mode 100644 tsunami/rpcclient/client.go delete mode 100644 tsunami/rpcclient/rpcclient.go diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 904c52c1da..464c530c9e 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -180,10 +180,10 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, ": keepalive\n\n") flusher.Flush() case event := <-h.Client.SSEventCh: - // Send actual event - if event.Event != "" { - fmt.Fprintf(w, "event: %s\n", event.Event) + if event.Event == "" { + break } + fmt.Fprintf(w, "event: %s\n", event.Event) if len(event.Data) > 0 { fmt.Fprintf(w, "data: %s\n", string(event.Data)) } diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index bfb8446761..fddc02d76f 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -22,7 +22,6 @@ import ( "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/tsunami/comp" "github.com/wavetermdev/waveterm/tsunami/rpc" - "github.com/wavetermdev/waveterm/tsunami/rpcclient" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" @@ -48,7 +47,6 @@ type Client struct { AppOpts AppOpts Root *comp.RootElem RootElem *vdom.VDomElem - RpcClient *rpcclient.RpcClient RpcContext *rpc.RpcContext IsDone bool RouteId string @@ -101,7 +99,6 @@ func MakeClient(appOpts AppOpts) *Client { Lock: &sync.Mutex{}, AppOpts: appOpts, Root: comp.MakeRoot(), - RpcClient: rpcclient.MakeRpcClient(), DoneCh: make(chan struct{}), SSEventCh: make(chan SSEvent, 100), UrlHandlerMux: mux.NewRouter(), @@ -126,20 +123,6 @@ func (c *Client) runMainE() error { if err != nil { return err } - target := &rpctypes.VDomTarget{} - if c.AppOpts.TargetNewBlock || c.NewBlockFlag { - target.NewBlock = c.NewBlockFlag - } - if c.AppOpts.TargetToolbar != nil { - target.Toolbar = c.AppOpts.TargetToolbar - } - if target.NewBlock && target.Toolbar != nil { - return fmt.Errorf("cannot specify both new block and toolbar target") - } - err = c.CreateVDomContext(target) - if err != nil { - return err - } <-c.DoneCh return nil } @@ -213,36 +196,6 @@ func (c *Client) SetRootElem(elem *vdom.VDomElem) { c.RootElem = elem } -func (c *Client) CreateVDomContext(target *rpctypes.VDomTarget) error { - blockORef, err := rpcclient.VDomCreateContextCommand( - c.RpcClient, - rpctypes.VDomCreateContext{Target: target}, - &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.RpcContext.BlockId)}, - ) - if err != nil { - return err - } - c.VDomContextBlockId = blockORef.OID - log.Printf("created vdom context: %v\n", blockORef) - gotRoute, err := rpcclient.WaitForRouteCommand(c.RpcClient, rpc.CommandWaitForRouteData{ - RouteId: rpc.MakeFeBlockRouteId(blockORef.OID), - WaitMs: 4000, - }, &rpc.RpcOpts{Timeout: 5000}) - if err != nil { - return fmt.Errorf("error waiting for vdom context route: %v", err) - } - if !gotRoute { - return fmt.Errorf("vdom context route could not be established") - } - rpcclient.EventSubCommand(c.RpcClient, rpc.SubscriptionRequest{Event: rpc.Event_BlockClose, Scopes: []string{ - blockORef.String(), - }}, nil) - c.RpcClient.EventListener.On("blockclose", func(event *rpc.WaveEvent) { - c.doShutdown("got blockclose event") - }) - return nil -} - func (c *Client) SendAsyncInitiation() error { if c.VDomContextBlockId == "" { return fmt.Errorf("no vdom context block id") @@ -250,11 +203,13 @@ func (c *Client) SendAsyncInitiation() error { if c.GetIsDone() { return fmt.Errorf("client is done") } - return rpcclient.VDomAsyncInitiationCommand( - c.RpcClient, - rpctypes.MakeAsyncInitiationRequest(c.RpcContext.BlockId), - &rpc.RpcOpts{Route: rpc.MakeFeBlockRouteId(c.VDomContextBlockId)}, - ) + + select { + case c.SSEventCh <- SSEvent{Event: "asyncinitiation", Data: nil}: + return nil + default: + return fmt.Errorf("SSEvent channel is full") + } } func (c *Client) SetAtomVals(m map[string]any) { diff --git a/tsunami/rpcclient/client.go b/tsunami/rpcclient/client.go deleted file mode 100644 index 9e8256c351..0000000000 --- a/tsunami/rpcclient/client.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package rpcclient - -import ( - "github.com/wavetermdev/waveterm/tsunami/rpc" -) - -type EventListener struct { - eventHandlers map[string][]func(event *rpc.WaveEvent) -} - -func MakeEventListener() *EventListener { - return &EventListener{ - eventHandlers: make(map[string][]func(event *rpc.WaveEvent)), - } -} - -func (el *EventListener) On(eventName string, handler func(event *rpc.WaveEvent)) { - if el.eventHandlers == nil { - el.eventHandlers = make(map[string][]func(event *rpc.WaveEvent)) - } - el.eventHandlers[eventName] = append(el.eventHandlers[eventName], handler) -} - -func (el *EventListener) Emit(eventName string, event *rpc.WaveEvent) { - if handlers, exists := el.eventHandlers[eventName]; exists { - for _, handler := range handlers { - handler(event) - } - } -} - -type RpcClient struct { - EventListener *EventListener -} - -func MakeRpcClient() *RpcClient { - return &RpcClient{ - EventListener: MakeEventListener(), - } -} \ No newline at end of file diff --git a/tsunami/rpcclient/rpcclient.go b/tsunami/rpcclient/rpcclient.go deleted file mode 100644 index da87aea1a6..0000000000 --- a/tsunami/rpcclient/rpcclient.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package rpcclient - -import ( - "errors" - - "github.com/wavetermdev/waveterm/tsunami/rpc" - "github.com/wavetermdev/waveterm/tsunami/rpctypes" -) - -func VDomCreateContextCommand(rpcClient *RpcClient, data rpctypes.VDomCreateContext, opts *rpc.RpcOpts) (rpc.ORef, error) { - return rpc.ORef{}, errors.New("VDomCreateContextCommand: unimplemented") -} - -func WaitForRouteCommand(rpcClient *RpcClient, data rpc.CommandWaitForRouteData, opts *rpc.RpcOpts) (bool, error) { - return false, errors.New("WaitForRouteCommand: unimplemented") -} - -func EventSubCommand(rpcClient *RpcClient, data rpc.SubscriptionRequest, opts *rpc.RpcOpts) error { - return errors.New("EventSubCommand: unimplemented") -} - -func VDomAsyncInitiationCommand(rpcClient *RpcClient, data rpctypes.VDomAsyncInitiationRequest, opts *rpc.RpcOpts) error { - return errors.New("VDomAsyncInitiationCommand: unimplemented") -} From d7934eb39c436c1745df772436677646fbf6f651 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 13:57:29 -0700 Subject: [PATCH 010/134] remove more rpc cruft --- tsunami/app/streamingresp.go | 129 ----------------------------------- tsunami/app/waveapp.go | 7 +- tsunami/rpc/types.go | 9 --- tsunami/rpctypes/types.go | 86 ----------------------- 4 files changed, 2 insertions(+), 229 deletions(-) delete mode 100644 tsunami/app/streamingresp.go diff --git a/tsunami/app/streamingresp.go b/tsunami/app/streamingresp.go deleted file mode 100644 index 57188baab4..0000000000 --- a/tsunami/app/streamingresp.go +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -package waveapp - -import ( - "bytes" - "net/http" - - "github.com/wavetermdev/waveterm/tsunami/rpc" -) - -const maxChunkSize = 64 * 1024 // 64KB maximum chunk size - -// StreamingResponseWriter implements http.ResponseWriter interface to stream response -// data through a channel rather than buffering it in memory. This is particularly -// useful for handling large responses like video streams or file downloads. -type StreamingResponseWriter struct { - header http.Header - statusCode int - respChan chan<- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse] - headerSent bool - buffer *bytes.Buffer -} - -func NewStreamingResponseWriter(respChan chan<- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]) *StreamingResponseWriter { - return &StreamingResponseWriter{ - header: make(http.Header), - statusCode: http.StatusOK, - respChan: respChan, - headerSent: false, - buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), - } -} - -func (w *StreamingResponseWriter) Header() http.Header { - return w.header -} - -func (w *StreamingResponseWriter) WriteHeader(statusCode int) { - if w.headerSent { - return - } - - w.statusCode = statusCode - w.headerSent = true - - headers := make(map[string]string) - for key, values := range w.header { - if len(values) > 0 { - headers[key] = values[0] - } - } - - w.respChan <- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]{ - Response: rpc.VDomUrlRequestResponse{ - StatusCode: w.statusCode, - Headers: headers, - }, - } -} - -// sendChunk sends a single chunk of exactly maxChunkSize (or less) -func (w *StreamingResponseWriter) sendChunk(data []byte) { - if len(data) == 0 { - return - } - chunk := make([]byte, len(data)) - copy(chunk, data) - w.respChan <- rpc.RespOrErrorUnion[rpc.VDomUrlRequestResponse]{ - Response: rpc.VDomUrlRequestResponse{ - Body: chunk, - }, - } -} - -func (w *StreamingResponseWriter) Write(data []byte) (int, error) { - if !w.headerSent { - w.WriteHeader(http.StatusOK) - } - - originalLen := len(data) - - // If we already have data in the buffer - if w.buffer.Len() > 0 { - // Fill the buffer up to maxChunkSize - spaceInBuffer := maxChunkSize - w.buffer.Len() - if spaceInBuffer > 0 { - // How much of the new data can fit in the buffer - toBuffer := spaceInBuffer - if toBuffer > len(data) { - toBuffer = len(data) - } - w.buffer.Write(data[:toBuffer]) - data = data[toBuffer:] // Advance data slice - } - - // If buffer is full, send it - if w.buffer.Len() == maxChunkSize { - w.sendChunk(w.buffer.Bytes()) - w.buffer.Reset() - } - } - - // Send any full chunks from data - for len(data) >= maxChunkSize { - w.sendChunk(data[:maxChunkSize]) - data = data[maxChunkSize:] - } - - // Buffer any remaining data - if len(data) > 0 { - w.buffer.Write(data) - } - - return originalLen, nil -} - -func (w *StreamingResponseWriter) Close() error { - if !w.headerSent { - w.WriteHeader(http.StatusOK) - } - - if w.buffer.Len() > 0 { - w.sendChunk(w.buffer.Bytes()) - w.buffer.Reset() - } - return nil -} diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index fddc02d76f..ae406e0e71 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -39,7 +39,6 @@ type AppOpts struct { RootComponentName string // defaults to "App" NewBlockFlag string // defaults to "n" (set to "-" to disable) TargetNewBlock bool - TargetToolbar *rpctypes.VDomTargetToolbar } type Client struct { @@ -260,7 +259,6 @@ func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { return &rpctypes.VDomBackendUpdate{ Type: "backendupdate", Ts: time.Now().UnixMilli(), - BlockId: c.RpcContext.BlockId, HasWork: len(c.Root.EffectWorkQueue) > 0, Opts: &c.Opts, RenderUpdates: []rpctypes.VDomRenderUpdate{ @@ -278,9 +276,8 @@ func (c *Client) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { renderedVDom = makeNullVDom() } return &rpctypes.VDomBackendUpdate{ - Type: "backendupdate", - Ts: time.Now().UnixMilli(), - BlockId: c.RpcContext.BlockId, + Type: "backendupdate", + Ts: time.Now().UnixMilli(), RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, diff --git a/tsunami/rpc/types.go b/tsunami/rpc/types.go index 70c46bcefd..f4d8f7fb00 100644 --- a/tsunami/rpc/types.go +++ b/tsunami/rpc/types.go @@ -3,10 +3,6 @@ package rpc -import ( - "github.com/wavetermdev/waveterm/tsunami/rpctypes" -) - type RespOrErrorUnion[T any] struct { Response T Error error @@ -35,8 +31,3 @@ type ORef struct { func (oref ORef) String() string { return oref.OType + ":" + oref.OID } - -// Types moved to rpctypes package -type VDomUrlRequestData = rpctypes.VDomUrlRequestData -type VDomUrlRequestResponse = rpctypes.VDomUrlRequestResponse -type CommandWaitForRouteData = rpctypes.CommandWaitForRouteData diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index f578cba04e..1d54841813 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -7,41 +7,15 @@ import ( "fmt" "time" - "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" ) -const ( - BackendUpdate_InitialChunkSize = 50 // Size for initial chunks that contain both TransferElems and StateSync - BackendUpdate_ChunkSize = 100 // Size for subsequent chunks -) - -type VDomUrlRequestData struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body,omitempty"` -} - type VDomUrlRequestResponse struct { StatusCode int `json:"statuscode,omitempty"` Headers map[string]string `json:"headers,omitempty"` Body []byte `json:"body,omitempty"` } -type CommandWaitForRouteData struct { - RouteId string `json:"routeid"` - WaitMs int `json:"waitms"` -} - -type VDomCreateContext struct { - Type string `json:"type" tstype:"\"createcontext\""` - Ts int64 `json:"ts"` - Meta map[string]any `json:"meta,omitempty"` - Target *VDomTarget `json:"target,omitempty"` - Persist bool `json:"persist,omitempty"` -} - type VDomAsyncInitiationRequest struct { Type string `json:"type" tstype:"\"asyncinitiationrequest\""` Ts int64 `json:"ts"` @@ -73,7 +47,6 @@ type VDomFrontendUpdate struct { type VDomBackendUpdate struct { Type string `json:"type" tstype:"\"backendupdate\""` Ts int64 `json:"ts"` - BlockId string `json:"blockid"` Opts *VDomBackendOpts `json:"opts,omitempty"` HasWork bool `json:"haswork,omitempty"` RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` @@ -172,52 +145,6 @@ func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { return result } -// SplitBackendUpdate splits a large VDomBackendUpdate into multiple smaller updates -// The first update contains all the core fields, while subsequent updates only contain -// array elements that need to be appended -func SplitBackendUpdate(update *VDomBackendUpdate) []*VDomBackendUpdate { - if len(update.TransferElems) <= BackendUpdate_InitialChunkSize && len(update.StateSync) <= BackendUpdate_InitialChunkSize { - return []*VDomBackendUpdate{update} - } - - updates := make([]*VDomBackendUpdate, 0) - transferElemChunks := util.ChunkSlice(update.TransferElems, BackendUpdate_ChunkSize) - stateSyncChunks := util.ChunkSlice(update.StateSync, BackendUpdate_ChunkSize) - - maxChunks := len(transferElemChunks) - if len(stateSyncChunks) > maxChunks { - maxChunks = len(stateSyncChunks) - } - - for i := 0; i < maxChunks; i++ { - newUpdate := &VDomBackendUpdate{ - Type: update.Type, - Ts: update.Ts, - BlockId: update.BlockId, - } - - if i == 0 { - newUpdate.Opts = update.Opts - newUpdate.HasWork = update.HasWork - newUpdate.RenderUpdates = update.RenderUpdates - newUpdate.RefOperations = update.RefOperations - newUpdate.Messages = update.Messages - } - - if i < len(transferElemChunks) { - newUpdate.TransferElems = transferElemChunks[i] - } - - if i < len(stateSyncChunks) { - newUpdate.StateSync = stateSyncChunks[i] - } - - updates = append(updates, newUpdate) - } - - return updates -} - type VDomEvent struct { WaveId string `json:"waveid"` EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) @@ -278,19 +205,6 @@ type VDomMessage struct { Params []any `json:"params,omitempty"` } -// target -- to support new targets in the future, like toolbars, partial blocks, splits, etc. -// default is vdom context inside of a terminal block -type VDomTarget struct { - NewBlock bool `json:"newblock,omitempty"` - Magnified bool `json:"magnified,omitempty"` - Toolbar *VDomTargetToolbar `json:"toolbar,omitempty"` -} - -type VDomTargetToolbar struct { - Toolbar bool `json:"toolbar"` - Height string `json:"height,omitempty"` -} - // matches WaveKeyboardEvent type VDomKeyboardEvent struct { Type string `json:"type"` From 73badade47791edd07f9a8faa55b66e77a8a7e55 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 14:02:41 -0700 Subject: [PATCH 011/134] more cleanup --- tsunami/app/waveapp.go | 25 +------------------------ tsunami/rpctypes/types.go | 2 +- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index ae406e0e71..395e2f55b1 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -5,7 +5,6 @@ package waveapp import ( "context" - "flag" "fmt" "io" "io/fs" @@ -21,7 +20,6 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/tsunami/comp" - "github.com/wavetermdev/waveterm/tsunami/rpc" "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/util" "github.com/wavetermdev/waveterm/tsunami/vdom" @@ -37,8 +35,6 @@ type AppOpts struct { GlobalKeyboardEvents bool GlobalStyles []byte RootComponentName string // defaults to "App" - NewBlockFlag string // defaults to "n" (set to "-" to disable) - TargetNewBlock bool } type Client struct { @@ -46,10 +42,8 @@ type Client struct { AppOpts AppOpts Root *comp.RootElem RootElem *vdom.VDomElem - RpcContext *rpc.RpcContext + CurrentClientId string IsDone bool - RouteId string - VDomContextBlockId string DoneReason string DoneCh chan struct{} SSEventCh chan SSEvent @@ -58,7 +52,6 @@ type Client struct { GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router OverrideUrlHandler http.Handler - NewBlockFlag bool SetupFn func() } @@ -91,9 +84,6 @@ func MakeClient(appOpts AppOpts) *Client { if appOpts.RootComponentName == "" { appOpts.RootComponentName = "App" } - if appOpts.NewBlockFlag == "" { - appOpts.NewBlockFlag = "n" - } client := &Client{ Lock: &sync.Mutex{}, AppOpts: appOpts, @@ -130,17 +120,7 @@ func (c *Client) AddSetupFn(fn func()) { c.SetupFn = fn } -func (c *Client) RegisterDefaultFlags() { - if c.AppOpts.NewBlockFlag != "-" { - flag.BoolVar(&c.NewBlockFlag, c.AppOpts.NewBlockFlag, false, "new block") - } -} - func (c *Client) RunMain() { - if !flag.Parsed() { - c.RegisterDefaultFlags() - flag.Parse() - } err := c.runMainE() if err != nil { fmt.Println(err) @@ -196,9 +176,6 @@ func (c *Client) SetRootElem(elem *vdom.VDomElem) { } func (c *Client) SendAsyncInitiation() error { - if c.VDomContextBlockId == "" { - return fmt.Errorf("no vdom context block id") - } if c.GetIsDone() { return fmt.Errorf("client is done") } diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index 1d54841813..86a0fe36c0 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -33,7 +33,7 @@ func MakeAsyncInitiationRequest(blockId string) VDomAsyncInitiationRequest { type VDomFrontendUpdate struct { Type string `json:"type" tstype:"\"frontendupdate\""` Ts int64 `json:"ts"` - BlockId string `json:"blockid"` + ClientId string `json:"clientid"` CorrelationId string `json:"correlationid,omitempty"` Dispose bool `json:"dispose,omitempty"` // the vdom context was closed Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads From 1379d9a68ac8ef3064529f30f255787fe21d60c6 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 14:11:54 -0700 Subject: [PATCH 012/134] add a clientid --- tsunami/app/serverhandlers.go | 15 +++++++++++++++ tsunami/app/waveapp.go | 19 +++++++++++++++++++ tsunami/rpctypes/types.go | 1 + 3 files changed, 35 insertions(+) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 464c530c9e..49ec0f0263 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -59,6 +59,15 @@ func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { return } + if feUpdate.ForceTakeover { + h.Client.ClientTakeover(feUpdate.ClientId) + } + + if err := h.Client.CheckClientId(feUpdate.ClientId); err != nil { + http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) + return + } + if feUpdate.Dispose { log.Printf("got dispose from frontend\n") h.Client.doShutdown("got dispose from frontend") @@ -152,6 +161,12 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { return } + clientId := r.URL.Query().Get("clientId") + if err := h.Client.CheckClientId(clientId); err != nil { + http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) + return + } + // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/tsunami/app/waveapp.go b/tsunami/app/waveapp.go index 395e2f55b1..1c80893a92 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/waveapp.go @@ -61,6 +61,25 @@ func (c *Client) GetIsDone() bool { return c.IsDone } +func (c *Client) CheckClientId(clientId string) error { + if clientId == "" { + return fmt.Errorf("client id cannot be empty") + } + c.Lock.Lock() + defer c.Lock.Unlock() + if c.CurrentClientId == "" || c.CurrentClientId == clientId { + c.CurrentClientId = clientId + return nil + } + return fmt.Errorf("client id mismatch: expected %s, got %s", c.CurrentClientId, clientId) +} + +func (c *Client) ClientTakeover(clientId string) { + c.Lock.Lock() + defer c.Lock.Unlock() + c.CurrentClientId = clientId +} + func (c *Client) doShutdown(reason string) { c.Lock.Lock() defer c.Lock.Unlock() diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index 86a0fe36c0..b33b1de0d7 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -34,6 +34,7 @@ type VDomFrontendUpdate struct { Type string `json:"type" tstype:"\"frontendupdate\""` Ts int64 `json:"ts"` ClientId string `json:"clientid"` + ForceTakeover bool `json:"forcetakeover,omitempty"` CorrelationId string `json:"correlationid,omitempty"` Dispose bool `json:"dispose,omitempty"` // the vdom context was closed Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads From 432d333aca4744113c0c740b569d5abdbbc43564 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 31 Aug 2025 14:18:59 -0700 Subject: [PATCH 013/134] handle fe update should be in a critical section --- tsunami/app/serverhandlers.go | 39 ++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 49ec0f0263..0fbed3862a 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "strings" + "sync" "time" "github.com/wavetermdev/waveterm/tsunami/rpctypes" @@ -19,7 +20,8 @@ import ( const SSEKeepAliveDuration = 5 * time.Second type HTTPHandlers struct { - Client *Client + Client *Client + renderLock sync.Mutex } func NewHTTPHandlers(client *Client) *HTTPHandlers { @@ -68,16 +70,34 @@ func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { return } + update, err := h.processFrontendUpdate(&feUpdate) + if err != nil { + http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError) + return + } + if update == nil { + w.WriteHeader(http.StatusOK) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(update); err != nil { + log.Printf("failed to encode response: %v", err) + } +} + +func (h *HTTPHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) { + h.renderLock.Lock() + defer h.renderLock.Unlock() + if feUpdate.Dispose { log.Printf("got dispose from frontend\n") h.Client.doShutdown("got dispose from frontend") - w.WriteHeader(http.StatusOK) - return + return nil, nil } if h.Client.GetIsDone() { - w.WriteHeader(http.StatusOK) - return + return nil, nil } h.Client.Root.RenderTs = feUpdate.Ts @@ -111,16 +131,11 @@ func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { } if renderErr != nil { - http.Error(w, fmt.Sprintf("render error: %v", renderErr), http.StatusInternalServerError) - return + return nil, renderErr } update.CreateTransferElems() - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(update); err != nil { - log.Printf("failed to encode response: %v", err) - } + return update, nil } func (h *HTTPHandlers) handleVDomUrl(w http.ResponseWriter, r *http.Request) { From 41d04b746de36b7fa36097733240895314ba466d Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 10:08:20 -0700 Subject: [PATCH 014/134] set go 1.24.6 --- go.mod | 2 +- tsunami/go.mod | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 673758bf09..8d9e28f361 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/wavetermdev/waveterm -go 1.24.2 +go 1.24.6 require ( github.com/alexflint/go-filemutex v1.3.0 diff --git a/tsunami/go.mod b/tsunami/go.mod index 68aaca9c38..eb3016560f 100644 --- a/tsunami/go.mod +++ b/tsunami/go.mod @@ -1,8 +1,6 @@ module github.com/wavetermdev/waveterm/tsunami -go 1.23.0 - -toolchain go1.24.6 +go 1.24.6 require ( github.com/google/uuid v1.6.0 From e256dfdfd532e38da5dd305b748d21bcc966355d Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 12:04:35 -0700 Subject: [PATCH 015/134] working on getting frontend setup (checkpoint) --- package.json | 3 +- tsunami/frontend/index.html | 13 + tsunami/frontend/package.json | 36 + tsunami/frontend/src/app.tsx | 17 + tsunami/frontend/src/element/markdown.tsx | 87 +++ tsunami/frontend/src/main.tsx | 13 + tsunami/frontend/src/model/model-utils.ts | 244 +++++++ tsunami/frontend/src/model/tsunami-model.tsx | 690 +++++++++++++++++++ tsunami/frontend/src/tailwind.css | 62 ++ tsunami/frontend/src/types/custom.d.ts | 30 + tsunami/frontend/src/types/vdom.d.ts | 243 +++++++ tsunami/frontend/src/util/keyutil.ts | 343 +++++++++ tsunami/frontend/src/util/platformutil.ts | 30 + tsunami/frontend/src/vdom.tsx | 509 ++++++++++++++ tsunami/frontend/tsconfig.json | 26 + tsunami/frontend/vite.config.ts | 22 + yarn.lock | 88 ++- 17 files changed, 2452 insertions(+), 4 deletions(-) create mode 100644 tsunami/frontend/index.html create mode 100644 tsunami/frontend/package.json create mode 100644 tsunami/frontend/src/app.tsx create mode 100644 tsunami/frontend/src/element/markdown.tsx create mode 100644 tsunami/frontend/src/main.tsx create mode 100644 tsunami/frontend/src/model/model-utils.ts create mode 100644 tsunami/frontend/src/model/tsunami-model.tsx create mode 100644 tsunami/frontend/src/tailwind.css create mode 100644 tsunami/frontend/src/types/custom.d.ts create mode 100644 tsunami/frontend/src/types/vdom.d.ts create mode 100644 tsunami/frontend/src/util/keyutil.ts create mode 100644 tsunami/frontend/src/util/platformutil.ts create mode 100644 tsunami/frontend/src/vdom.tsx create mode 100644 tsunami/frontend/tsconfig.json create mode 100644 tsunami/frontend/vite.config.ts diff --git a/package.json b/package.json index 915c737405..a1b2f62779 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ }, "packageManager": "yarn@4.6.0", "workspaces": [ - "docs" + "docs", + "tsunami/frontend" ] } diff --git a/tsunami/frontend/index.html b/tsunami/frontend/index.html new file mode 100644 index 0000000000..cd14fa1d91 --- /dev/null +++ b/tsunami/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Tsunami Frontend + + +
+ + + diff --git a/tsunami/frontend/package.json b/tsunami/frontend/package.json new file mode 100644 index 0000000000..7ac38ddd69 --- /dev/null +++ b/tsunami/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "tsunami-frontend", + "author": { + "name": "Command Line Inc", + "email": "info@commandline.dev" + }, + "description": "Tsunami Frontend - React application", + "license": "Apache-2.0", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "clsx": "^2.1.1", + "debug": "^4.4.1", + "jotai": "^2.13.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-markdown": "^10.1.0", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.17", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react-swc": "^4.0.1", + "tailwindcss": "^4.1.12", + "typescript": "^5.9.2", + "vite": "^6.0.0" + } +} diff --git a/tsunami/frontend/src/app.tsx b/tsunami/frontend/src/app.tsx new file mode 100644 index 0000000000..823cf54d13 --- /dev/null +++ b/tsunami/frontend/src/app.tsx @@ -0,0 +1,17 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +function App() { + return ( +
+
+

Tsunami Frontend

+

+ Welcome to the Tsunami Frontend application built with React 19, TypeScript, and Tailwind v4. +

+
+
+ ); +} + +export default App; diff --git a/tsunami/frontend/src/element/markdown.tsx b/tsunami/frontend/src/element/markdown.tsx new file mode 100644 index 0000000000..632964f86f --- /dev/null +++ b/tsunami/frontend/src/element/markdown.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import ReactMarkdown, { Components } from 'react-markdown'; +import { twMerge } from 'tailwind-merge'; + +interface MarkdownProps { + text?: string; + style?: React.CSSProperties; + className?: string; + scrollable?: boolean; +} + +const markdownComponents: Partial = { + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + h5: ({ children }) =>
{children}
, + h6: ({ children }) =>
{children}
, + p: ({ children }) =>

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + code: ({ className, children }) => { + const isInline = !className; + if (isInline) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +            {children}
    +        
    + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), +}; + +export function Markdown({ text, style, className, scrollable = true }: MarkdownProps) { + const scrollClasses = scrollable ? "overflow-auto" : ""; + const baseClasses = "prose prose-sm max-w-none"; + + return ( +
    + + {text || ''} + +
    + ); +} \ No newline at end of file diff --git a/tsunami/frontend/src/main.tsx b/tsunami/frontend/src/main.tsx new file mode 100644 index 0000000000..1764ca476e --- /dev/null +++ b/tsunami/frontend/src/main.tsx @@ -0,0 +1,13 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./app"; +import "./tailwind.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts new file mode 100644 index 0000000000..c521ebb816 --- /dev/null +++ b/tsunami/frontend/src/model/model-utils.ts @@ -0,0 +1,244 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { CssNode, List, ListItem } from "css-tree"; +import * as csstree from "css-tree"; +import type { TsunamiModel } from "./tsunami-model"; + +const TextTag = "#text"; + +// TODO support binding +export function getTextChildren(elem: VDomElem): string { + if (elem.tag == TextTag) { + return elem.text; + } + if (!elem.children) { + return null; + } + const textArr = elem.children.map((child) => { + return getTextChildren(child); + }); + return textArr.join(""); +} + +export function convertVDomId(model: TsunamiModel, id: string): string { + return model.blockId + "::" + id; +} + +export function validateAndWrapCss(model: TsunamiModel, cssText: string, wrapperClassName: string) { + try { + const ast = csstree.parse(cssText); + csstree.walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + // Remove disallowed @rules + const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"]; + if (node.type === "Atrule" && blockedRules.includes(node.name)) { + list.remove(item); + } + // Remove :root selectors + if ( + node.type === "Selector" && + node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root") + ) { + list.remove(item); + } + + if (node.type === "IdSelector") { + node.name = convertVDomId(model, node.name); + } + + // Transform url(#id) references in filter and mask properties (svg) + if (node.type === "Declaration" && ["filter", "mask"].includes(node.property)) { + if (node.value && node.value.type === "Value" && "children" in node.value) { + const urlNode = node.value.children + .toArray() + .find( + (child: CssNode): child is CssNode & { value: string } => + child && child.type === "Url" && typeof (child as any).value === "string" + ); + if (urlNode && urlNode.value && urlNode.value.startsWith("#")) { + urlNode.value = "#" + convertVDomId(model, urlNode.value.substring(1)); + } + } + } + // transform url(vdom:///foo.jpg) + if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { + const newUrl = model.transformVDomUrl(node.value); + if (newUrl == null) { + list.remove(item); + } else { + node.value = newUrl; + } + } + }, + }); + const sanitizedCss = csstree.generate(ast); + return `.${wrapperClassName} { ${sanitizedCss} }`; + } catch (error) { + // TODO better error handling + console.error("CSS processing error:", error); + return null; + } +} + +function cssTransformStyleValue(model: TsunamiModel, property: string, value: string): string { + try { + const ast = csstree.parse(value, { context: "value" }); + csstree.walk(ast, { + enter(node: CssNode, item: ListItem, list: List) { + // Transform url(#id) in filter/mask properties + if (node.type === "Url" && (property === "filter" || property === "mask")) { + if (node.value.startsWith("#")) { + node.value = `#${convertVDomId(model, node.value.substring(1))}`; + } + } + // transform vdom:// urls + if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { + const newUrl = model.transformVDomUrl(node.value); + if (newUrl == null) { + list.remove(item); + } else { + node.value = newUrl; + } + } + }, + }); + + return csstree.generate(ast); + } catch (error) { + console.error("Error processing style value:", error); + return value; + } +} + +export function validateAndWrapReactStyle(model: TsunamiModel, style: Record): Record { + const sanitizedStyle: Record = {}; + let updated = false; + for (const [property, value] of Object.entries(style)) { + if (value == null || value === "") { + continue; + } + if (typeof value !== "string") { + sanitizedStyle[property] = value; // For non-string values, just copy as-is + continue; + } + if (value.includes("vdom://") || value.includes("url(#")) { + updated = true; + sanitizedStyle[property] = cssTransformStyleValue(model, property, value); + } else { + sanitizedStyle[property] = value; + } + } + if (!updated) { + return style; + } + return sanitizedStyle; +} + +export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { + if (!backendUpdate.transferelems || !backendUpdate.renderupdates) { + return; + } + + // Step 1: Map of waveid to VDomElem, skipping any without a waveid + const elemMap = new Map(); + backendUpdate.transferelems.forEach((transferElem) => { + if (!transferElem.waveid) { + return; + } + elemMap.set(transferElem.waveid, { + waveid: transferElem.waveid, + tag: transferElem.tag, + props: transferElem.props, + children: [], // Will populate children later + text: transferElem.text, + }); + }); + + // Step 2: Build VDomElem trees by linking children + backendUpdate.transferelems.forEach((transferElem) => { + const parent = elemMap.get(transferElem.waveid); + if (!parent || !transferElem.children || transferElem.children.length === 0) { + return; + } + parent.children = transferElem.children.map((childId) => elemMap.get(childId)).filter((child) => child != null); // Explicit null check + }); + + // Step 3: Update renderupdates with rebuilt VDomElem trees + backendUpdate.renderupdates.forEach((update) => { + if (update.vdomwaveid) { + update.vdom = elemMap.get(update.vdomwaveid); + } + }); +} + +export function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: VDomBackendUpdate) { + // Verify the updates are from the same block/sequence + if (baseUpdate.blockid !== nextUpdate.blockid || baseUpdate.ts !== nextUpdate.ts) { + console.error("Attempted to merge updates from different blocks or timestamps"); + return; + } + + // Merge TransferElems + if (nextUpdate.transferelems?.length > 0) { + if (!baseUpdate.transferelems) { + baseUpdate.transferelems = []; + } + baseUpdate.transferelems.push(...nextUpdate.transferelems); + } + + // Merge StateSync + if (nextUpdate.statesync?.length > 0) { + if (!baseUpdate.statesync) { + baseUpdate.statesync = []; + } + baseUpdate.statesync.push(...nextUpdate.statesync); + } +} + +export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + console.error("Canvas 2D context not available."); + return; + } + + let { op, params, outputref } = canvasOp; + if (params == null) { + params = []; + } + if (op == null || op == "") { + return; + } + // Resolve any reference parameters in params + const resolvedParams: any[] = []; + params.forEach((param) => { + if (typeof param === "string" && param.startsWith("#ref:")) { + const refId = param.slice(5); // Remove "#ref:" prefix + resolvedParams.push(refStore.get(refId)); + } else if (typeof param === "string" && param.startsWith("#spreadRef:")) { + const refId = param.slice(11); // Remove "#spreadRef:" prefix + const arrayRef = refStore.get(refId); + if (Array.isArray(arrayRef)) { + resolvedParams.push(...arrayRef); // Spread array elements + } else { + console.error(`Reference ${refId} is not an array and cannot be spread.`); + } + } else { + resolvedParams.push(param); + } + }); + + // Apply the operation on the canvas context + if (op === "dropRef" && params.length > 0 && typeof params[0] === "string") { + refStore.delete(params[0]); + } else if (op === "addRef" && outputref) { + refStore.set(outputref, resolvedParams[0]); + } else if (typeof ctx[op as keyof CanvasRenderingContext2D] === "function") { + (ctx[op as keyof CanvasRenderingContext2D] as Function).apply(ctx, resolvedParams); + } else if (op in ctx) { + (ctx as any)[op] = resolvedParams[0]; + } else { + console.error(`Unsupported canvas operation: ${op}`); + } +} diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx new file mode 100644 index 0000000000..7d719cfc59 --- /dev/null +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -0,0 +1,690 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import debug from "debug"; +import * as jotai from "jotai"; + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; +import { makeORef } from "@/app/store/wos"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { makeFeBlockRouteId } from "@/app/store/wshrouter"; +import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; +import { VDomView } from "@/app/view/vdom/vdom"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; +import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "./model-utils"; + +const dlog = debug("wave:vdom"); + +type AtomContainer = { + val: any; + beVal: any; + usedBy: Set; +}; + +type RefContainer = { + refFn: (elem: HTMLElement) => void; + vdomRef: VDomRef; + elem: HTMLElement; + updated: boolean; +}; + +function makeVDomIdMap(vdom: VDomElem, idMap: Map) { + if (vdom == null) { + return; + } + if (vdom.waveid != null) { + idMap.set(vdom.waveid, vdom); + } + if (vdom.children == null) { + return; + } + for (let child of vdom.children) { + makeVDomIdMap(child, idMap); + } +} + +function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.SyntheticEvent) { + if (reactEvent == null) { + return; + } + if (propName == "onChange") { + const changeEvent = reactEvent as React.ChangeEvent; + event.targetvalue = changeEvent.target?.value; + event.targetchecked = changeEvent.target?.checked; + } + if (propName == "onClick" || propName == "onMouseDown") { + const mouseEvent = reactEvent as React.MouseEvent; + event.mousedata = { + button: mouseEvent.button, + buttons: mouseEvent.buttons, + alt: mouseEvent.altKey, + control: mouseEvent.ctrlKey, + shift: mouseEvent.shiftKey, + meta: mouseEvent.metaKey, + clientx: mouseEvent.clientX, + clienty: mouseEvent.clientY, + pagex: mouseEvent.pageX, + pagey: mouseEvent.pageY, + screenx: mouseEvent.screenX, + screeny: mouseEvent.screenY, + movementx: mouseEvent.movementX, + movementy: mouseEvent.movementY, + }; + if (PLATFORM == PlatformMacOS) { + event.mousedata.cmd = event.mousedata.meta; + event.mousedata.option = event.mousedata.alt; + } else { + event.mousedata.cmd = event.mousedata.alt; + event.mousedata.option = event.mousedata.meta; + } + } + if (propName == "onKeyDown") { + const waveKeyEvent = adaptFromReactOrNativeKeyEvent(reactEvent as React.KeyboardEvent); + event.keydata = waveKeyEvent; + } +} + +class VDomWshClient extends WshClient { + model: TsunamiModel; + + constructor(model: TsunamiModel) { + super(makeFeBlockRouteId(model.blockId)); + this.model = model; + } + + handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { + dlog("async-initiation", rh.getSource(), data); + this.model.queueUpdate(true); + } +} + +export class TsunamiModel { + blockId: string; + nodeModel: BlockNodeModel; + viewType: string; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + viewRef: React.RefObject = { current: null }; + vdomRoot: jotai.PrimitiveAtom = jotai.atom(); + atoms: Map = new Map(); // key is atomname + refs: Map = new Map(); // key is refid + batchedEvents: VDomEvent[] = []; + messages: VDomMessage[] = []; + needsResync: boolean = true; + vdomNodeVersion: WeakMap> = new WeakMap(); + compoundAtoms: Map> = new Map(); + rootRefId: string = crypto.randomUUID(); + backendRoute: jotai.Atom; + backendOpts: VDomBackendOpts; + shouldDispose: boolean; + disposed: boolean; + hasPendingRequest: boolean; + needsUpdate: boolean; + maxNormalUpdateIntervalMs: number = 100; + needsImmediateUpdate: boolean; + lastUpdateTs: number = 0; + queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; + contextActive: jotai.PrimitiveAtom; + wshClient: VDomWshClient; + persist: jotai.Atom; + routeGoneUnsub: () => void; + routeConfirmed: boolean = false; + refOutputStore: Map = new Map(); + globalVersion: jotai.PrimitiveAtom = jotai.atom(0); + hasBackendWork: boolean = false; + noPadding: jotai.PrimitiveAtom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "vdom"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.contextActive = jotai.atom(false); + this.reset(); + this.viewIcon = jotai.atom("bolt"); + this.viewName = jotai.atom("Wave App"); + this.backendRoute = jotai.atom((get) => { + const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + }); + this.noPadding = jotai.atom(true); + this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); + this.wshClient = new VDomWshClient(this); + DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); + const curBackendRoute = globalStore.get(this.backendRoute); + if (curBackendRoute) { + this.queueUpdate(true); + } + this.routeGoneUnsub = waveEventSubscribe({ + eventType: "route:gone", + scope: curBackendRoute, + handler: (event: WaveEvent) => { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + }, + }); + RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( + (routeOk: boolean) => { + if (routeOk) { + this.routeConfirmed = true; + this.queueUpdate(true); + } else { + this.disposed = true; + const shouldPersist = globalStore.get(this.persist); + if (!shouldPersist) { + this.nodeModel?.onClose?.(); + } + } + } + ); + } + + get viewComponent(): ViewComponent { + return VDomView; + } + + dispose() { + DefaultRouter.unregisterRoute(this.wshClient.routeId); + this.routeGoneUnsub?.(); + } + + reset() { + globalStore.set(this.vdomRoot, null); + this.atoms.clear(); + this.refs.clear(); + this.batchedEvents = []; + this.messages = []; + this.needsResync = true; + this.vdomNodeVersion = new WeakMap(); + this.compoundAtoms.clear(); + this.rootRefId = crypto.randomUUID(); + this.backendOpts = {}; + this.shouldDispose = false; + this.disposed = false; + this.hasPendingRequest = false; + this.needsUpdate = false; + this.maxNormalUpdateIntervalMs = 100; + this.needsImmediateUpdate = false; + this.lastUpdateTs = 0; + this.queuedUpdate = null; + this.refOutputStore.clear(); + this.globalVersion = jotai.atom(0); + this.hasBackendWork = false; + globalStore.set(this.contextActive, false); + } + + getBackendRoute(): string { + const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); + return blockData?.meta?.["vdom:route"]; + } + + transformVDomUrl(url: string): string { + if (url == null || url == "") { + return null; + } + if (!url.startsWith("vdom://")) { + return url; + } + const absUrl = url.substring(7); + return this.makeVDomUrl(absUrl); + } + + makeVDomUrl(path: string): string { + if (path == null || path == "") { + return null; + } + if (!path.startsWith("/")) { + return null; + } + const backendRouteId = this.getBackendRouteId(); + if (backendRouteId == null) { + return null; + } + const fullUrl = "/vdom/" + backendRouteId + path; + return fullUrl; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { + if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { + this.shouldDispose = true; + this.queueUpdate(true); + return true; + } + if (this.backendOpts?.globalkeyboardevents) { + if (e.cmd || e.meta) { + return false; + } + this.batchedEvents.push({ + globaleventtype: "onKeyDown", + waveid: null, + eventtype: "onKeyDown", + keydata: e, + }); + this.queueUpdate(); + return true; + } + return false; + } + + hasRefUpdates() { + for (let ref of this.refs.values()) { + if (ref.updated) { + return true; + } + } + return false; + } + + getRefUpdates(): VDomRefUpdate[] { + let updates: VDomRefUpdate[] = []; + for (let ref of this.refs.values()) { + if (ref.updated || (ref.vdomRef.trackposition && ref.elem != null)) { + const ru: VDomRefUpdate = { + refid: ref.vdomRef.refid, + hascurrent: ref.vdomRef.hascurrent, + }; + if (ref.vdomRef.trackposition && ref.elem != null) { + ru.position = { + offsetheight: ref.elem.offsetHeight, + offsetwidth: ref.elem.offsetWidth, + scrollheight: ref.elem.scrollHeight, + scrollwidth: ref.elem.scrollWidth, + scrolltop: ref.elem.scrollTop, + boundingclientrect: ref.elem.getBoundingClientRect(), + }; + } + updates.push(ru); + ref.updated = false; + } + } + return updates; + } + + queueUpdate(quick: boolean = false, delay: number = 10) { + if (this.disposed) { + return; + } + this.needsUpdate = true; + let nowTs = Date.now(); + if (delay > this.maxNormalUpdateIntervalMs) { + delay = this.maxNormalUpdateIntervalMs; + } + if (quick) { + if (this.queuedUpdate) { + if (this.queuedUpdate.quick || this.queuedUpdate.ts <= nowTs) { + return; + } + clearTimeout(this.queuedUpdate.timeoutId); + this.queuedUpdate = null; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(true); + }, 0); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs, quick: true }; + return; + } + if (this.queuedUpdate) { + return; + } + let lastUpdateDiff = nowTs - this.lastUpdateTs; + let timeoutMs: number = null; + if (lastUpdateDiff >= this.maxNormalUpdateIntervalMs) { + // it has been a while since the last update, so use delay + timeoutMs = delay; + } else { + timeoutMs = this.maxNormalUpdateIntervalMs - lastUpdateDiff; + } + if (timeoutMs < delay) { + timeoutMs = delay; + } + let timeoutId = setTimeout(() => { + this._sendRenderRequest(false); + }, timeoutMs); + this.queuedUpdate = { timeoutId: timeoutId, ts: nowTs + timeoutMs, quick: false }; + } + + async _sendRenderRequest(force: boolean) { + this.queuedUpdate = null; + if (this.disposed || !this.routeConfirmed) { + return; + } + if (this.hasPendingRequest) { + if (force) { + this.needsImmediateUpdate = true; + } + return; + } + if (!force && !this.needsUpdate) { + return; + } + const backendRoute = globalStore.get(this.backendRoute); + if (backendRoute == null) { + console.log("vdom-model", "no backend route"); + return; + } + this.hasPendingRequest = true; + this.needsImmediateUpdate = false; + try { + const feUpdate = this.createFeUpdate(); + dlog("fe-update", feUpdate); + const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); + let baseUpdate: VDomBackendUpdate = null; + for await (const beUpdate of beUpdateGen) { + if (baseUpdate === null) { + baseUpdate = beUpdate; + } else { + mergeBackendUpdates(baseUpdate, beUpdate); + } + } + if (baseUpdate !== null) { + restoreVDomElems(baseUpdate); + dlog("be-update", baseUpdate); + this.handleBackendUpdate(baseUpdate); + } + dlog("update cycle done"); + } finally { + this.lastUpdateTs = Date.now(); + this.hasPendingRequest = false; + } + if (this.needsImmediateUpdate) { + this.queueUpdate(true); + } + } + + getAtomContainer(atomName: string): AtomContainer { + let container = this.atoms.get(atomName); + if (container == null) { + container = { + val: null, + beVal: null, + usedBy: new Set(), + }; + this.atoms.set(atomName, container); + } + return container; + } + + getOrCreateRefContainer(vdomRef: VDomRef): RefContainer { + let container = this.refs.get(vdomRef.refid); + if (container == null) { + container = { + refFn: (elem: HTMLElement) => { + container.elem = elem; + const hasElem = elem != null; + if (vdomRef.hascurrent != hasElem) { + container.updated = true; + vdomRef.hascurrent = hasElem; + } + }, + vdomRef: vdomRef, + elem: null, + updated: false, + }; + this.refs.set(vdomRef.refid, container); + } + return container; + } + + tagUseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.add(waveId); + } + } + + tagUnuseAtoms(waveId: string, atomNames: Set) { + for (let atomName of atomNames) { + let container = this.getAtomContainer(atomName); + container.usedBy.delete(waveId); + } + } + + getVDomNodeVersionAtom(vdom: VDomElem) { + let atom = this.vdomNodeVersion.get(vdom); + if (atom == null) { + atom = jotai.atom(0); + this.vdomNodeVersion.set(vdom, atom); + } + return atom; + } + + incVDomNodeVersion(vdom: VDomElem) { + if (vdom == null) { + return; + } + const atom = this.getVDomNodeVersionAtom(vdom); + globalStore.set(atom, globalStore.get(atom) + 1); + } + + addErrorMessage(message: string) { + this.messages.push({ + messagetype: "error", + message: message, + }); + } + + handleRenderUpdates(update: VDomBackendUpdate, idMap: Map) { + if (!update.renderupdates) { + return; + } + for (let renderUpdate of update.renderupdates) { + if (renderUpdate.updatetype == "root") { + globalStore.set(this.vdomRoot, renderUpdate.vdom); + continue; + } + if (renderUpdate.updatetype == "append") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + parent.children.push(renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "replace") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children[renderUpdate.index] = renderUpdate.vdom; + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "remove") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (renderUpdate.index < 0 || parent.children == null || parent.children.length <= renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 1); + this.incVDomNodeVersion(parent); + continue; + } + if (renderUpdate.updatetype == "insert") { + let parent = idMap.get(renderUpdate.waveid); + if (parent == null) { + this.addErrorMessage(`Could not find vdom with id ${renderUpdate.waveid} (for renderupdates)`); + continue; + } + if (parent.children == null) { + parent.children = []; + } + if (renderUpdate.index < 0 || parent.children.length < renderUpdate.index) { + this.addErrorMessage(`Could not find child at index ${renderUpdate.index} (for renderupdates)`); + continue; + } + parent.children.splice(renderUpdate.index, 0, renderUpdate.vdom); + this.incVDomNodeVersion(parent); + continue; + } + this.addErrorMessage(`Unknown updatetype ${renderUpdate.updatetype}`); + } + } + + setAtomValue(atomName: string, value: any, fromBe: boolean, idMap: Map) { + dlog("setAtomValue", atomName, value, fromBe); + let container = this.getAtomContainer(atomName); + container.val = value; + if (fromBe) { + container.beVal = value; + } + for (let id of container.usedBy) { + this.incVDomNodeVersion(idMap.get(id)); + } + } + + handleStateSync(update: VDomBackendUpdate, idMap: Map) { + if (update.statesync == null) { + return; + } + for (let sync of update.statesync) { + this.setAtomValue(sync.atom, sync.value, true, idMap); + } + } + + getRefElem(refId: string): HTMLElement { + if (refId == this.rootRefId) { + return this.viewRef.current; + } + const ref = this.refs.get(refId); + return ref?.elem; + } + + handleRefOperations(update: VDomBackendUpdate, idMap: Map) { + if (update.refoperations == null) { + return; + } + for (let refOp of update.refoperations) { + const elem = this.getRefElem(refOp.refid); + if (elem == null) { + this.addErrorMessage(`Could not find ref with id ${refOp.refid}`); + continue; + } + if (elem instanceof HTMLCanvasElement) { + applyCanvasOp(elem, refOp, this.refOutputStore); + continue; + } + if (refOp.op == "focus") { + if (elem == null) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: elem is null`); + continue; + } + try { + elem.focus(); + } catch (e) { + this.addErrorMessage(`Could not focus ref with id ${refOp.refid}: ${e.message}`); + } + } else { + this.addErrorMessage(`Unknown ref operation ${refOp.refid} ${refOp.op}`); + } + } + } + + handleBackendUpdate(update: VDomBackendUpdate) { + if (update == null) { + return; + } + globalStore.set(this.contextActive, true); + const idMap = new Map(); + const vdomRoot = globalStore.get(this.vdomRoot); + if (update.opts != null) { + this.backendOpts = update.opts; + } + makeVDomIdMap(vdomRoot, idMap); + this.handleRenderUpdates(update, idMap); + this.handleStateSync(update, idMap); + this.handleRefOperations(update, idMap); + if (update.messages) { + for (let message of update.messages) { + console.log("vdom-message", this.blockId, message.messagetype, message.message); + if (message.stacktrace) { + console.log("vdom-message-stacktrace", message.stacktrace); + } + } + } + globalStore.set(this.globalVersion, globalStore.get(this.globalVersion) + 1); + if (update.haswork) { + this.hasBackendWork = true; + } + } + + renderDone(version: number) { + // called when the render is done + dlog("renderDone", version); + if (this.hasRefUpdates() || this.hasBackendWork) { + this.hasBackendWork = false; + this.queueUpdate(true); + } + } + + callVDomFunc(fnDecl: VDomFunc, e: React.SyntheticEvent, compId: string, propName: string) { + const vdomEvent: VDomEvent = { + waveid: compId, + eventtype: propName, + }; + if (fnDecl.globalevent) { + vdomEvent.globaleventtype = fnDecl.globalevent; + } + annotateEvent(vdomEvent, propName, e); + this.batchedEvents.push(vdomEvent); + this.queueUpdate(true); + } + + createFeUpdate(): VDomFrontendUpdate { + const blockORef = makeORef("block", this.blockId); + const blockAtom = WOS.getWaveObjectAtom(blockORef); + const blockData = globalStore.get(blockAtom); + const isBlockFocused = globalStore.get(this.nodeModel.isFocused); + const renderContext: VDomRenderContext = { + blockid: this.blockId, + focused: isBlockFocused, + width: this.viewRef?.current?.offsetWidth ?? 0, + height: this.viewRef?.current?.offsetHeight ?? 0, + rootrefid: this.rootRefId, + background: false, + }; + const feUpdate: VDomFrontendUpdate = { + type: "frontendupdate", + ts: Date.now(), + blockid: this.blockId, + rendercontext: renderContext, + dispose: this.shouldDispose, + resync: this.needsResync, + events: this.batchedEvents, + refupdates: this.getRefUpdates(), + }; + this.needsResync = false; + this.batchedEvents = []; + if (this.shouldDispose) { + this.disposed = true; + } + return feUpdate; + } + + getBackendRouteId(): string { + const fullRoute = globalStore.get(this.backendRoute); + if (fullRoute == null || !fullRoute.startsWith("proc:")) { + return null; + } + return fullRoute?.split(":")[1]; + } +} diff --git a/tsunami/frontend/src/tailwind.css b/tsunami/frontend/src/tailwind.css new file mode 100644 index 0000000000..224623f0bd --- /dev/null +++ b/tsunami/frontend/src/tailwind.css @@ -0,0 +1,62 @@ +/* Copyright 2025, Command Line Inc. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +@import "tailwindcss"; + +@theme { + --color-background: rgb(34, 34, 34); + --color-foreground: #f7f7f7; + --color-white: #f7f7f7; + --color-secondary: rgba(215, 218, 224, 0.7); + --color-muted: rgba(215, 218, 224, 0.5); + --color-accent-50: rgb(236, 253, 232); + --color-accent-100: rgb(209, 250, 202); + --color-accent-200: rgb(167, 243, 168); + --color-accent-300: rgb(110, 231, 133); + --color-accent-400: rgb(88, 193, 66); /* main accent color */ + --color-accent-500: rgb(63, 162, 51); + --color-accent-600: rgb(47, 133, 47); + --color-accent-700: rgb(34, 104, 43); + --color-accent-800: rgb(22, 81, 35); + --color-accent-900: rgb(15, 61, 29); + --color-error: rgb(229, 77, 46); + --color-warning: rgb(224, 185, 86); + --color-success: rgb(78, 154, 6); + --color-panel: rgba(31, 33, 31, 0.5); + --color-hover: rgba(255, 255, 255, 0.1); + --color-border: rgba(255, 255, 255, 0.16); + --color-modalbg: #232323; + --color-accentbg: rgba(88, 193, 66, 0.5); + --color-hoverbg: rgba(255, 255, 255, 0.2); + --color-accent: rgb(88, 193, 66); + --color-accenthover: rgb(118, 223, 96); + + --font-sans: "Inter", sans-serif; + --font-mono: "Hack", monospace; + --font-markdown: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + + --text-xxs: 10px; + --text-title: 18px; + --text-default: 14px; + + --radius: 8px; + + /* ANSI Colors (Default Dark Palette) */ + --ansi-black: #757575; + --ansi-red: #cc685c; + --ansi-green: #76c266; + --ansi-yellow: #cbca9b; + --ansi-blue: #85aacb; + --ansi-magenta: #cc72ca; + --ansi-cyan: #74a7cb; + --ansi-white: #c1c1c1; + --ansi-brightblack: #727272; + --ansi-brightred: #cc9d97; + --ansi-brightgreen: #a3dd97; + --ansi-brightyellow: #cbcaaa; + --ansi-brightblue: #9ab6cb; + --ansi-brightmagenta: #cc8ecb; + --ansi-brightcyan: #b7b8cb; + --ansi-brightwhite: #f0f0f0; +} diff --git a/tsunami/frontend/src/types/custom.d.ts b/tsunami/frontend/src/types/custom.d.ts new file mode 100644 index 0000000000..1c5b788ade --- /dev/null +++ b/tsunami/frontend/src/types/custom.d.ts @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// vdom.WaveKeyboardEvent +type WaveKeyboardEvent = { + type: "keydown" | "keyup" | "keypress" | "unknown"; + key: string; + code: string; + repeat?: boolean; + location?: number; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; +}; + +type KeyPressDecl = { + mods: { + Cmd?: boolean; + Option?: boolean; + Shift?: boolean; + Ctrl?: boolean; + Alt?: boolean; + Meta?: boolean; + }; + key: string; + keyType: string; +}; diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts new file mode 100644 index 0000000000..9ec52fcc01 --- /dev/null +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -0,0 +1,243 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// vdom.VDomAsyncInitiationRequest +type VDomAsyncInitiationRequest = { + type: "asyncinitiationrequest"; + ts: number; + blockid?: string; +}; + +// vdom.VDomBackendOpts +type VDomBackendOpts = { + closeonctrlc?: boolean; + globalkeyboardevents?: boolean; + globalstyles?: boolean; +}; + +// vdom.VDomBackendUpdate +type VDomBackendUpdate = { + type: "backendupdate"; + ts: number; + opts?: VDomBackendOpts; + haswork?: boolean; + renderupdates?: VDomRenderUpdate[]; + transferelems?: VDomTransferElem[]; + statesync?: VDomStateSync[]; + refoperations?: VDomRefOperation[]; + messages?: VDomMessage[]; +}; + +// vdom.VDomBinding +type VDomBinding = { + type: "binding"; + bind: string; +}; + +// vdom.VDomCreateContext +type VDomCreateContext = { + type: "createcontext"; + ts: number; + meta?: MetaType; + target?: VDomTarget; + persist?: boolean; +}; + +// vdom.VDomElem +type VDomElem = { + waveid?: string; + tag: string; + props?: { [key: string]: any }; + children?: VDomElem[]; + text?: string; +}; + +// vdom.VDomEvent +type VDomEvent = { + waveid: string; + eventtype: string; + globaleventtype?: string; + targetvalue?: string; + targetchecked?: boolean; + targetname?: string; + targetid?: string; + keydata?: WaveKeyboardEvent; + mousedata?: WavePointerData; +}; + +// vdom.VDomFrontendUpdate +type VDomFrontendUpdate = { + type: "frontendupdate"; + ts: number; + clientid: string; + forcetakeover?: boolean; + correlationid?: string; + dispose?: boolean; + resync?: boolean; + rendercontext: VDomRenderContext; + events?: VDomEvent[]; + statesync?: VDomStateSync[]; + refupdates?: VDomRefUpdate[]; + messages?: VDomMessage[]; +}; + +// vdom.VDomFunc +type VDomFunc = { + type: "func"; + stoppropagation?: boolean; + preventdefault?: boolean; + globalevent?: string; + "#keys"?: string[]; +}; + +// vdom.VDomMessage +type VDomMessage = { + messagetype: string; + message: string; + stacktrace?: string; + params?: any[]; +}; + +// vdom.VDomRef +type VDomRef = { + type: "ref"; + refid: string; + trackposition?: boolean; + position?: VDomRefPosition; + hascurrent?: boolean; +}; + +// vdom.VDomRefOperation +type VDomRefOperation = { + refid: string; + op: string; + params?: any[]; + outputref?: string; +}; + +// vdom.VDomRefPosition +type VDomRefPosition = { + offsetheight: number; + offsetwidth: number; + scrollheight: number; + scrollwidth: number; + scrolltop: number; + boundingclientrect: DomRect; +}; + +// vdom.VDomRefUpdate +type VDomRefUpdate = { + refid: string; + hascurrent: boolean; + position?: VDomRefPosition; +}; + +// vdom.VDomRenderContext +type VDomRenderContext = { + blockid: string; + focused: boolean; + width: number; + height: number; + rootrefid: string; + background?: boolean; +}; + +// vdom.VDomRenderUpdate +type VDomRenderUpdate = { + updatetype: "root" | "append" | "replace" | "remove" | "insert"; + waveid?: string; + vdomwaveid?: string; + vdom?: VDomElem; + index?: number; +}; + +// vdom.VDomStateSync +type VDomStateSync = { + atom: string; + value: any; +}; + +// vdom.VDomTarget +type VDomTarget = { + newblock?: boolean; + magnified?: boolean; + toolbar?: VDomTargetToolbar; +}; + +// vdom.VDomTargetToolbar +type VDomTargetToolbar = { + toolbar: boolean; + height?: string; +}; + +// vdom.VDomTransferElem +type VDomTransferElem = { + waveid?: string; + tag: string; + props?: { [key: string]: any }; + children?: string[]; + text?: string; +}; + +// wshrpc.VDomUrlRequestData +type VDomUrlRequestData = { + method: string; + url: string; + headers: { [key: string]: string }; + body?: string; +}; + +// wshrpc.VDomUrlRequestResponse +type VDomUrlRequestResponse = { + statuscode?: number; + headers?: { [key: string]: string }; + body?: Uint8Array; +}; + +// Additional types from rpctypes that were missing +type VDomKeyboardEvent = { + type: string; + key: string; + code: string; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; + repeat?: boolean; + location?: number; +}; + +type WaveKeyboardEvent = { + type: "keydown" | "keyup" | "keypress" | "unknown"; + key: string; + code: string; + repeat?: boolean; + location?: number; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; +}; + +type WavePointerData = { + button: number; + buttons: number; + clientx?: number; + clienty?: number; + pagex?: number; + pagey?: number; + screenx?: number; + screeny?: number; + movementx?: number; + movementy?: number; + shift?: boolean; + control?: boolean; + alt?: boolean; + meta?: boolean; + cmd?: boolean; + option?: boolean; +}; diff --git a/tsunami/frontend/src/util/keyutil.ts b/tsunami/frontend/src/util/keyutil.ts new file mode 100644 index 0000000000..25b6c8dbf1 --- /dev/null +++ b/tsunami/frontend/src/util/keyutil.ts @@ -0,0 +1,343 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const KeyTypeCodeRegex = /c{(.*)}/; +const KeyTypeKey = "key"; +const KeyTypeCode = "code"; + +let PLATFORM: NodeJS.Platform = "darwin"; +const PlatformMacOS = "darwin"; + +function setKeyUtilPlatform(platform: NodeJS.Platform) { + PLATFORM = platform; +} + +function getKeyUtilPlatform(): NodeJS.Platform { + return PLATFORM; +} + +function keydownWrapper( + fn: (waveEvent: WaveKeyboardEvent) => boolean +): (event: KeyboardEvent | React.KeyboardEvent) => void { + return (event: KeyboardEvent | React.KeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(event); + const rtnVal = fn(waveEvent); + if (rtnVal) { + event.preventDefault(); + event.stopPropagation(); + } + }; +} + +function waveEventToKeyDesc(waveEvent: WaveKeyboardEvent): string { + let keyDesc: string[] = []; + if (waveEvent.cmd) { + keyDesc.push("Cmd"); + } + if (waveEvent.option) { + keyDesc.push("Option"); + } + if (waveEvent.meta) { + keyDesc.push("Meta"); + } + if (waveEvent.control) { + keyDesc.push("Ctrl"); + } + if (waveEvent.shift) { + keyDesc.push("Shift"); + } + if (waveEvent.key != null && waveEvent.key != "") { + if (waveEvent.key == " ") { + keyDesc.push("Space"); + } else { + keyDesc.push(waveEvent.key); + } + } else { + keyDesc.push("c{" + waveEvent.code + "}"); + } + return keyDesc.join(":"); +} + +function parseKey(key: string): { key: string; type: string } { + let regexMatch = key.match(KeyTypeCodeRegex); + if (regexMatch != null && regexMatch.length > 1) { + let code = regexMatch[1]; + return { key: code, type: KeyTypeCode }; + } else if (regexMatch != null) { + console.log("error: regexMatch is not null yet there is no captured group: ", regexMatch, key); + } + return { key: key, type: KeyTypeKey }; +} + +function parseKeyDescription(keyDescription: string): KeyPressDecl { + let rtn = { key: "", mods: {} } as KeyPressDecl; + let keys = keyDescription.replace(/[()]/g, "").split(":"); + for (let key of keys) { + if (key == "Cmd") { + if (PLATFORM == PlatformMacOS) { + rtn.mods.Meta = true; + } else { + rtn.mods.Alt = true; + } + rtn.mods.Cmd = true; + } else if (key == "Shift") { + rtn.mods.Shift = true; + } else if (key == "Ctrl") { + rtn.mods.Ctrl = true; + } else if (key == "Option") { + if (PLATFORM == PlatformMacOS) { + rtn.mods.Alt = true; + } else { + rtn.mods.Meta = true; + } + rtn.mods.Option = true; + } else if (key == "Alt") { + if (PLATFORM == PlatformMacOS) { + rtn.mods.Option = true; + } else { + rtn.mods.Cmd = true; + } + rtn.mods.Alt = true; + } else if (key == "Meta") { + if (PLATFORM == PlatformMacOS) { + rtn.mods.Cmd = true; + } else { + rtn.mods.Option = true; + } + rtn.mods.Meta = true; + } else { + let { key: parsedKey, type: keyType } = parseKey(key); + rtn.key = parsedKey; + rtn.keyType = keyType; + if (rtn.keyType == KeyTypeKey && key.length == 1) { + // check for if key is upper case + // TODO what about unicode upper case? + if (/[A-Z]/.test(key.charAt(0))) { + // this key is an upper case A - Z - we should apply the shift key, even if it wasn't specified + rtn.mods.Shift = true; + } else if (key == " ") { + rtn.key = "Space"; + // we allow " " and "Space" to be mapped to Space key + } + } + } + } + return rtn; +} + +function notMod(keyPressMod: boolean, eventMod: boolean) { + return (keyPressMod && !eventMod) || (eventMod && !keyPressMod); +} + +function countGraphemes(str: string): number { + if (str == null) { + return 0; + } + // this exists (need to hack TS to get it to not show an error) + const seg = new (Intl as any).Segmenter(undefined, { granularity: "grapheme" }); + return Array.from(seg.segment(str)).length; +} + +function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean { + if (event.alt || event.meta || event.control) { + return false; + } + return countGraphemes(event.key) == 1; +} + +const inputKeyMap = new Map([ + ["Backspace", true], + ["Delete", true], + ["Enter", true], + ["Space", true], + ["Tab", true], + ["ArrowLeft", true], + ["ArrowRight", true], + ["ArrowUp", true], + ["ArrowDown", true], + ["Home", true], + ["End", true], + ["PageUp", true], + ["PageDown", true], + ["Cmd:a", true], + ["Cmd:c", true], + ["Cmd:v", true], + ["Cmd:x", true], + ["Cmd:z", true], + ["Cmd:Shift:z", true], + ["Cmd:ArrowLeft", true], + ["Cmd:ArrowRight", true], + ["Cmd:Backspace", true], + ["Cmd:Delete", true], + ["Shift:ArrowLeft", true], + ["Shift:ArrowRight", true], + ["Shift:ArrowUp", true], + ["Shift:ArrowDown", true], + ["Shift:Home", true], + ["Shift:End", true], + ["Cmd:Shift:ArrowLeft", true], + ["Cmd:Shift:ArrowRight", true], + ["Cmd:Shift:ArrowUp", true], + ["Cmd:Shift:ArrowDown", true], +]); + +function isInputEvent(event: WaveKeyboardEvent): boolean { + if (isCharacterKeyEvent(event)) { + return true; + } + for (let key of inputKeyMap.keys()) { + if (checkKeyPressed(event, key)) { + return true; + } + } +} + +function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean { + let keyPress = parseKeyDescription(keyDescription); + if (notMod(keyPress.mods.Option, event.option)) { + return false; + } + if (notMod(keyPress.mods.Cmd, event.cmd)) { + return false; + } + if (notMod(keyPress.mods.Shift, event.shift)) { + return false; + } + if (notMod(keyPress.mods.Ctrl, event.control)) { + return false; + } + if (notMod(keyPress.mods.Alt, event.alt)) { + return false; + } + if (notMod(keyPress.mods.Meta, event.meta)) { + return false; + } + let eventKey = ""; + let descKey = keyPress.key; + if (keyPress.keyType == KeyTypeCode) { + eventKey = event.code; + } + if (keyPress.keyType == KeyTypeKey) { + eventKey = event.key; + if (eventKey != null && eventKey.length == 1 && /[A-Z]/.test(eventKey.charAt(0))) { + // key is upper case A-Z, this means shift is applied, we want to allow + // "Shift:e" as well as "Shift:E" or "E" + eventKey = eventKey.toLocaleLowerCase(); + descKey = descKey.toLocaleLowerCase(); + } else if (eventKey == " ") { + eventKey = "Space"; + // a space key is shown as " ", we want users to be able to set space key as "Space" or " ", whichever they prefer + } + } + if (descKey != eventKey) { + return false; + } + return true; +} + +function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent { + let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; + rtn.control = event.ctrlKey; + rtn.shift = event.shiftKey; + rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey; + rtn.option = PLATFORM == PlatformMacOS ? event.altKey : event.metaKey; + rtn.meta = event.metaKey; + rtn.alt = event.altKey; + rtn.code = event.code; + rtn.key = event.key; + rtn.location = event.location; + (rtn as any).nativeEvent = event; + if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { + rtn.type = event.type; + } else { + rtn.type = "unknown"; + } + rtn.repeat = event.repeat; + return rtn; +} + +function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { + let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; + if (event.type == "keyUp") { + rtn.type = "keyup"; + } else if (event.type == "keyDown") { + rtn.type = "keydown"; + } else { + rtn.type = "unknown"; + } + rtn.control = event.control; + rtn.cmd = PLATFORM == PlatformMacOS ? event.meta : event.alt; + rtn.option = PLATFORM == PlatformMacOS ? event.alt : event.meta; + rtn.meta = event.meta; + rtn.alt = event.alt; + rtn.shift = event.shift; + rtn.repeat = event.isAutoRepeat; + rtn.location = event.location; + rtn.code = event.code; + rtn.key = event.key; + return rtn; +} + +const keyMap = { + Enter: "\r", + Backspace: "\x7f", + Tab: "\t", + Escape: "\x1b", + ArrowUp: "\x1b[A", + ArrowDown: "\x1b[B", + ArrowRight: "\x1b[C", + ArrowLeft: "\x1b[D", + Insert: "\x1b[2~", + Delete: "\x1b[3~", + Home: "\x1b[1~", + End: "\x1b[4~", + PageUp: "\x1b[5~", + PageDown: "\x1b[6~", +}; + +function keyboardEventToASCII(event: WaveKeyboardEvent): string { + // check modifiers + // if no modifiers are set, just send the key + if (!event.alt && !event.control && !event.meta) { + if (event.key == null || event.key == "") { + return ""; + } + if (keyMap[event.key] != null) { + return keyMap[event.key]; + } + if (event.key.length == 1) { + return event.key; + } else { + console.log("not sending keyboard event", event.key, event); + } + } + // if meta or alt is set, there is no ASCII representation + if (event.meta || event.alt) { + return ""; + } + // if ctrl is set, if it is a letter, subtract 64 from the uppercase value to get the ASCII value + if (event.control) { + if ( + (event.key.length === 1 && event.key >= "A" && event.key <= "Z") || + (event.key >= "a" && event.key <= "z") + ) { + const key = event.key.toUpperCase(); + return String.fromCharCode(key.charCodeAt(0) - 64); + } + } + return ""; +} + +export { + adaptFromElectronKeyEvent, + adaptFromReactOrNativeKeyEvent, + checkKeyPressed, + getKeyUtilPlatform, + isCharacterKeyEvent, + isInputEvent, + keyboardEventToASCII, + keydownWrapper, + parseKeyDescription, + setKeyUtilPlatform, + waveEventToKeyDesc, +}; diff --git a/tsunami/frontend/src/util/platformutil.ts b/tsunami/frontend/src/util/platformutil.ts new file mode 100644 index 0000000000..410f248fb3 --- /dev/null +++ b/tsunami/frontend/src/util/platformutil.ts @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export const PlatformMacOS = "darwin"; +export let PLATFORM: NodeJS.Platform = PlatformMacOS; + +export function setPlatform(platform: NodeJS.Platform) { + PLATFORM = platform; +} + +export function makeNativeLabel(isDirectory: boolean) { + let managerName: string; + if (!isDirectory) { + managerName = "Default Application"; + } else if (PLATFORM === PlatformMacOS) { + managerName = "Finder"; + } else if (PLATFORM == "win32") { + managerName = "Explorer"; + } else { + managerName = "File Manager"; + } + + let fileAction: string; + if (isDirectory) { + fileAction = "Reveal"; + } else { + fileAction = "Open File"; + } + return `${fileAction} in ${managerName}`; +} diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx new file mode 100644 index 0000000000..7e67c54ffb --- /dev/null +++ b/tsunami/frontend/src/vdom.tsx @@ -0,0 +1,509 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; +import debug from "debug"; +import * as jotai from "jotai"; +import * as React from "react"; + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { Markdown } from "@/element/markdown"; +import { convertVDomId, getTextChildren, validateAndWrapCss, validateAndWrapReactStyle } from "@/model/model-utils"; +import { TsunamiModel } from "@/model/tsunami-model"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; + +const TextTag = "#text"; +const FragmentTag = "#fragment"; +const WaveTextTag = "wave:text"; +const WaveNullTag = "wave:null"; +const StyleTagName = "style"; +const WaveStyleTagName = "wave:style"; + +const VDomObjType_Ref = "ref"; +const VDomObjType_Binding = "binding"; +const VDomObjType_Func = "func"; + +const dlog = debug("wave:vdom"); + +type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => React.ReactElement; + +const WaveTagMap: Record = { + "wave:markdown": WaveMarkdown, +}; + +const AllowedSimpleTags: { [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, + label: true, + table: true, + thead: true, + tbody: true, + tr: true, + th: true, + td: true, + hr: true, + br: true, + pre: true, + code: true, + canvas: true, +}; + +const AllowedSvgTags = { + // SVG tags + svg: true, + circle: true, + ellipse: true, + line: true, + path: true, + polygon: true, + polyline: true, + rect: true, + g: true, + text: true, + tspan: true, + textPath: true, + use: true, + defs: true, + linearGradient: true, + radialGradient: true, + stop: true, + clipPath: true, + mask: true, + pattern: true, + image: true, + marker: true, + symbol: true, + filter: true, + feBlend: true, + feColorMatrix: true, + feComponentTransfer: true, + feComposite: true, + feConvolveMatrix: true, + feDiffuseLighting: true, + feDisplacementMap: true, + feFlood: true, + feGaussianBlur: true, + feImage: true, + feMerge: true, + feMorphology: true, + feOffset: true, + feSpecularLighting: true, + feTile: true, + feTurbulence: true, +}; + +const IdAttributes = { + id: true, + for: true, + "aria-labelledby": true, + "aria-describedby": true, + "aria-controls": true, + "aria-owns": true, + form: true, + headers: true, + usemap: true, + list: true, +}; + +const SvgUrlIdAttributes = { + "clip-path": true, + mask: true, + filter: true, + fill: true, + stroke: true, + "marker-start": true, + "marker-mid": true, + "marker-end": true, + "text-decoration": true, +}; + +function convertVDomFunc(model: TsunamiModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { + return (e: any) => { + if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { + dlog("key event", fnDecl, e); + let waveEvent = adaptFromReactOrNativeKeyEvent(e); + for (let keyDesc of fnDecl["#keys"] || []) { + if (checkKeyPressed(waveEvent, keyDesc)) { + e.preventDefault(); + e.stopPropagation(); + model.callVDomFunc(fnDecl, e, compId, propName); + return; + } + } + return; + } + if (fnDecl.preventdefault) { + e.preventDefault(); + } + if (fnDecl.stoppropagation) { + e.stopPropagation(); + } + model.callVDomFunc(fnDecl, e, compId, propName); + }; +} + +function convertElemToTag(elem: VDomElem, model: TsunamiModel): React.ReactNode { + if (elem == null) { + return null; + } + if (elem.tag == TextTag) { + return elem.text; + } + return React.createElement(VDomTag, { key: elem.waveid, elem, model }); +} + +function isObject(v: any): boolean { + return v != null && !Array.isArray(v) && typeof v === "object"; +} + +function isArray(v: any): boolean { + return Array.isArray(v); +} + +function resolveBinding(binding: VDomBinding, model: TsunamiModel): [any, string[]] { + const bindName = binding.bind; + if (bindName == null || bindName == "") { + return [null, []]; + } + // for now we only recognize $.[atomname] bindings + if (!bindName.startsWith("$.")) { + return [null, []]; + } + const atomName = bindName.substring(2); + if (atomName == "") { + return [null, []]; + } + const atom = model.getAtomContainer(atomName); + if (atom == null) { + return [null, []]; + } + return [atom.val, [atomName]]; +} + +type GenericPropsType = { [key: string]: any }; + +// returns props, and a set of atom keys used in the props +function convertProps(elem: VDomElem, model: TsunamiModel): [GenericPropsType, Set] { + let props: GenericPropsType = {}; + let atomKeys = new Set(); + if (elem.props == null) { + return [props, atomKeys]; + } + 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) && val.type == VDomObjType_Ref) { + const valRef = val as VDomRef; + const refContainer = model.getOrCreateRefContainer(valRef); + props[key] = refContainer.refFn; + } + continue; + } + if (isObject(val) && val.type == VDomObjType_Func) { + const valFunc = val as VDomFunc; + props[key] = convertVDomFunc(model, valFunc, elem.waveid, key); + continue; + } + if (isObject(val) && val.type == VDomObjType_Binding) { + const [propVal, atomDeps] = resolveBinding(val as VDomBinding, model); + props[key] = propVal; + for (let atomDep of atomDeps) { + atomKeys.add(atomDep); + } + continue; + } + if (key == "style" && isObject(val)) { + // assuming the entire style prop wasn't bound, look through the individual keys and bind them + for (let styleKey in val) { + let styleVal = val[styleKey]; + if (isObject(styleVal) && styleVal.type == VDomObjType_Binding) { + const [stylePropVal, styleAtomDeps] = resolveBinding(styleVal as VDomBinding, model); + val[styleKey] = stylePropVal; + for (let styleAtomDep of styleAtomDeps) { + atomKeys.add(styleAtomDep); + } + } + } + val = validateAndWrapReactStyle(model, val); + props[key] = val; + continue; + } + if (IdAttributes[key]) { + props[key] = convertVDomId(model, val); + continue; + } + if (AllowedSvgTags[elem.tag]) { + if ((elem.tag == "use" && key == "href") || (elem.tag == "textPath" && key == "href")) { + if (val == null || !val.startsWith("#")) { + continue; + } + props[key] = convertVDomId(model, "#" + val.substring(1)); + continue; + } + if (SvgUrlIdAttributes[key]) { + if (val == null || !val.startsWith("url(#") || !val.endsWith(")")) { + continue; + } + props[key] = "url(#" + convertVDomId(model, val.substring(4, val.length - 1)) + ")"; + continue; + } + } + if (key == "src" && val != null && val.startsWith("vdom://")) { + // transform vdom:// urls + const newUrl = model.transformVDomUrl(val); + if (newUrl == null) { + continue; + } + props[key] = newUrl; + continue; + } + props[key] = val; + } + return [props, atomKeys]; +} + +function convertChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[] { + if (elem.children == null || elem.children.length == 0) { + return null; + } + let childrenComps: React.ReactNode[] = []; + for (let child of elem.children) { + if (child == null) { + continue; + } + childrenComps.push(convertElemToTag(child, model)); + } + if (childrenComps.length == 0) { + return null; + } + return childrenComps; +} + +function stringSetsEqual(set1: Set, set2: Set): boolean { + if (set1.size != set2.size) { + return false; + } + for (let elem of set1) { + if (!set2.has(elem)) { + return false; + } + } + return true; +} + +function useVDom(model: TsunamiModel, elem: VDomElem): GenericPropsType { + const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); + const [oldAtomKeys, setOldAtomKeys] = React.useState>(new Set()); + let [props, atomKeys] = convertProps(elem, model); + React.useEffect(() => { + if (stringSetsEqual(atomKeys, oldAtomKeys)) { + return; + } + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + model.tagUseAtoms(elem.waveid, atomKeys); + setOldAtomKeys(atomKeys); + }, [atomKeys]); + React.useEffect(() => { + return () => { + model.tagUnuseAtoms(elem.waveid, oldAtomKeys); + }; + }, []); + return props; +} + +function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + return ( + + ); +} + +function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const styleText = getTextChildren(elem); + if (styleText == null) { + return null; + } + const wrapperClassName = "vdom-" + model.blockId; + // TODO handle errors + const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName); + if (sanitizedCss == null) { + return null; + } + return ; +} + +function WaveStyle({ src, model, onMount }: { src: string; model: TsunamiModel; onMount?: () => void }) { + const [styleContent, setStyleContent] = React.useState(null); + React.useEffect(() => { + async function fetchAndSanitizeCss() { + try { + const response = await fetch(src); + if (!response.ok) { + console.error(`Failed to load CSS from ${src}`); + return; + } + const cssText = await response.text(); + const wrapperClassName = "vdom-" + model.blockId; + const sanitizedCss = validateAndWrapCss(model, cssText, wrapperClassName); + if (sanitizedCss) { + setStyleContent(sanitizedCss); + } else { + onMount?.(); + console.error("Failed to sanitize CSS"); + } + } catch (error) { + console.error("Error fetching CSS:", error); + onMount?.(); + } + } + fetchAndSanitizeCss(); + }, [src, model]); + // Trigger onMount after styleContent has been set and mounted + React.useEffect(() => { + if (styleContent) { + onMount?.(); + } + }, [styleContent, onMount]); + if (!styleContent) { + return null; + } + return ; +} + +function VDomTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { + const props = useVDom(model, elem); + if (elem.tag == WaveNullTag) { + return null; + } + if (elem.tag == WaveTextTag) { + return props.text; + } + const waveTag = WaveTagMap[elem.tag]; + if (waveTag) { + return waveTag({ elem, model }); + } + if (elem.tag == StyleTagName) { + return ; + } + if (elem.tag == WaveStyleTagName) { + return ; + } + if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) { + return
    {"Invalid Tag <" + elem.tag + ">"}
    ; + } + let childrenComps = convertChildren(elem, model); + if (elem.tag == FragmentTag) { + return childrenComps; + } + props.key = "e-" + elem.waveid; + return React.createElement(elem.tag, props, childrenComps); +} + +function vdomText(text: string): VDomElem { + return { + tag: "#text", + text: text, + }; +} + +const testVDom: VDomElem = { + waveid: "testid1", + tag: "div", + children: [ + { + waveid: "testh1", + tag: "h1", + children: [vdomText("Hello World")], + }, + { + waveid: "testp", + tag: "p", + children: [vdomText("This is a paragraph (from VDOM)")], + }, + ], +}; + +function VDomRoot({ model }: { model: TsunamiModel }) { + let version = jotai.useAtomValue(model.globalVersion); + let rootNode = jotai.useAtomValue(model.vdomRoot); + React.useEffect(() => { + model.renderDone(version); + }, [version]); + if (model.viewRef.current == null || rootNode == null) { + return null; + } + dlog("render", version, rootNode); + let rtn = convertElemToTag(rootNode, model); + return
    {rtn}
    ; +} + +function makeVDomModel(blockId: string, nodeModel: BlockNodeModel): TsunamiModel { + return new TsunamiModel(blockId, nodeModel); +} + +type VDomViewProps = { + model: TsunamiModel; + blockId: string; +}; + +function VDomInnerView({ blockId, model }: VDomViewProps) { + let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles); + const handleStylesMounted = () => { + setStyleMounted(true); + }; + return ( + <> + {model.backendOpts?.globalstyles ? ( + + ) : null} + {styleMounted ? : null} + + ); +} + +function VDomView({ blockId, model }: VDomViewProps) { + let viewRef = React.useRef(null); + let contextActive = jotai.useAtomValue(model.contextActive); + model.viewRef = viewRef; + const vdomClass = "vdom-" + blockId; + return ( +
    + {contextActive ? : null} +
    + ); +} + +export { makeVDomModel, VDomView }; diff --git a/tsunami/frontend/tsconfig.json b/tsunami/frontend/tsconfig.json new file mode 100644 index 0000000000..27d4e97b90 --- /dev/null +++ b/tsunami/frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": false, + "strictNullChecks": false, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsunami/frontend/vite.config.ts b/tsunami/frontend/vite.config.ts new file mode 100644 index 0000000000..d2f1d946f2 --- /dev/null +++ b/tsunami/frontend/vite.config.ts @@ -0,0 +1,22 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": "/src", + }, + }, + server: { + port: 12025, + open: true, + }, + build: { + outDir: "dist", + }, +}); diff --git a/yarn.lock b/yarn.lock index c30975291f..af94e825f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6475,6 +6475,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^19": + version: 19.1.9 + resolution: "@types/react-dom@npm:19.1.9" + peerDependencies: + "@types/react": ^19.0.0 + checksum: 10c0/34c8dda86c1590b3ef0e7ecd38f9663a66ba2dd69113ba74fb0adc36b83bbfb8c94c1487a2505282a5f7e5e000d2ebf36f4c0fd41b3b672f5178fd1d4f1f8f58 + languageName: node + linkType: hard + "@types/react-router-config@npm:*, @types/react-router-config@npm:^5.0.7": version: 5.0.11 resolution: "@types/react-router-config@npm:5.0.11" @@ -6536,6 +6545,15 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^19": + version: 19.1.12 + resolution: "@types/react@npm:19.1.12" + dependencies: + csstype: "npm:^3.0.2" + checksum: 10c0/e35912b43da0caaab5252444bab87a31ca22950cde2822b3b3dc32e39c2d42dad1a4cf7b5dde9783aa2d007f0b2cba6ab9563fc6d2dbcaaa833b35178118767c + languageName: node + linkType: hard + "@types/resolve@npm:1.20.2": version: 1.20.2 resolution: "@types/resolve@npm:1.20.2" @@ -6890,7 +6908,7 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react-swc@npm:4.0.1": +"@vitejs/plugin-react-swc@npm:4.0.1, @vitejs/plugin-react-swc@npm:^4.0.1": version: 4.0.1 resolution: "@vitejs/plugin-react-swc@npm:4.0.1" dependencies: @@ -13825,6 +13843,27 @@ __metadata: languageName: node linkType: hard +"jotai@npm:^2.13.1": + version: 2.13.1 + resolution: "jotai@npm:2.13.1" + peerDependencies: + "@babel/core": ">=7.0.0" + "@babel/template": ">=7.0.0" + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@babel/core": + optional: true + "@babel/template": + optional: true + "@types/react": + optional: true + react: + optional: true + checksum: 10c0/777915c4f83c372bac066ce3acb037c8c5c01e2789b8b435bf3f302ef32a5564d471217c89b9cdee219d735d445b166bf3ff15a9f43f4cb92a8a9115c72446ad + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -18077,7 +18116,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:19.1.1": +"react-dom@npm:19.1.1, react-dom@npm:^19.1.1": version: 19.1.1 resolution: "react-dom@npm:19.1.1" dependencies: @@ -18226,6 +18265,28 @@ __metadata: languageName: node linkType: hard +"react-markdown@npm:^10.1.0": + version: 10.1.0 + resolution: "react-markdown@npm:10.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + hast-util-to-jsx-runtime: "npm:^2.0.0" + html-url-attributes: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + remark-parse: "npm:^11.0.0" + remark-rehype: "npm:^11.0.0" + unified: "npm:^11.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + peerDependencies: + "@types/react": ">=18" + react: ">=18" + checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf + languageName: node + linkType: hard + "react-markdown@npm:^9.0.3": version: 9.0.3 resolution: "react-markdown@npm:9.0.3" @@ -18348,7 +18409,7 @@ __metadata: languageName: node linkType: hard -"react@npm:19.1.1": +"react@npm:19.1.1, react@npm:^19.1.1": version: 19.1.1 resolution: "react@npm:19.1.1" checksum: 10c0/8c9769a2dfd02e603af6445058325e6c8a24b47b185d0e461f66a6454765ddcaecb3f0a90184836c68bb509f3c38248359edbc42f0d07c23eb500a5c30c87b4e @@ -21235,6 +21296,27 @@ __metadata: languageName: node linkType: hard +"tsunami-frontend@workspace:tsunami/frontend": + version: 0.0.0-use.local + resolution: "tsunami-frontend@workspace:tsunami/frontend" + dependencies: + "@tailwindcss/vite": "npm:^4.0.17" + "@types/react": "npm:^19" + "@types/react-dom": "npm:^19" + "@vitejs/plugin-react-swc": "npm:^4.0.1" + clsx: "npm:^2.1.1" + debug: "npm:^4.4.1" + jotai: "npm:^2.13.1" + react: "npm:^19.1.1" + react-dom: "npm:^19.1.1" + react-markdown: "npm:^10.1.0" + tailwind-merge: "npm:^3.3.1" + tailwindcss: "npm:^4.1.12" + typescript: "npm:^5.9.2" + vite: "npm:^6.0.0" + languageName: unknown + linkType: soft + "tsx@npm:^4.20.4": version: 4.20.4 resolution: "tsx@npm:4.20.4" From 336dc293a4e8b5a3d5012cf8f7f962e370b4a342 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 12:31:52 -0700 Subject: [PATCH 016/134] checkpoint --- tsunami/frontend/src/model/model-utils.ts | 142 --------------- tsunami/frontend/src/model/tsunami-model.tsx | 176 ++++--------------- tsunami/frontend/src/types/vdom.d.ts | 1 - tsunami/frontend/src/util/clientid.ts | 26 +++ tsunami/frontend/src/vdom.tsx | 75 ++------ tsunami/rpctypes/types.go | 1 - 6 files changed, 73 insertions(+), 348 deletions(-) create mode 100644 tsunami/frontend/src/util/clientid.ts diff --git a/tsunami/frontend/src/model/model-utils.ts b/tsunami/frontend/src/model/model-utils.ts index c521ebb816..a4f05f9947 100644 --- a/tsunami/frontend/src/model/model-utils.ts +++ b/tsunami/frontend/src/model/model-utils.ts @@ -1,10 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import type { CssNode, List, ListItem } from "css-tree"; -import * as csstree from "css-tree"; -import type { TsunamiModel } from "./tsunami-model"; - const TextTag = "#text"; // TODO support binding @@ -21,120 +17,6 @@ export function getTextChildren(elem: VDomElem): string { return textArr.join(""); } -export function convertVDomId(model: TsunamiModel, id: string): string { - return model.blockId + "::" + id; -} - -export function validateAndWrapCss(model: TsunamiModel, cssText: string, wrapperClassName: string) { - try { - const ast = csstree.parse(cssText); - csstree.walk(ast, { - enter(node: CssNode, item: ListItem, list: List) { - // Remove disallowed @rules - const blockedRules = ["import", "font-face", "keyframes", "namespace", "supports"]; - if (node.type === "Atrule" && blockedRules.includes(node.name)) { - list.remove(item); - } - // Remove :root selectors - if ( - node.type === "Selector" && - node.children.some((child) => child.type === "PseudoClassSelector" && child.name === "root") - ) { - list.remove(item); - } - - if (node.type === "IdSelector") { - node.name = convertVDomId(model, node.name); - } - - // Transform url(#id) references in filter and mask properties (svg) - if (node.type === "Declaration" && ["filter", "mask"].includes(node.property)) { - if (node.value && node.value.type === "Value" && "children" in node.value) { - const urlNode = node.value.children - .toArray() - .find( - (child: CssNode): child is CssNode & { value: string } => - child && child.type === "Url" && typeof (child as any).value === "string" - ); - if (urlNode && urlNode.value && urlNode.value.startsWith("#")) { - urlNode.value = "#" + convertVDomId(model, urlNode.value.substring(1)); - } - } - } - // transform url(vdom:///foo.jpg) - if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { - const newUrl = model.transformVDomUrl(node.value); - if (newUrl == null) { - list.remove(item); - } else { - node.value = newUrl; - } - } - }, - }); - const sanitizedCss = csstree.generate(ast); - return `.${wrapperClassName} { ${sanitizedCss} }`; - } catch (error) { - // TODO better error handling - console.error("CSS processing error:", error); - return null; - } -} - -function cssTransformStyleValue(model: TsunamiModel, property: string, value: string): string { - try { - const ast = csstree.parse(value, { context: "value" }); - csstree.walk(ast, { - enter(node: CssNode, item: ListItem, list: List) { - // Transform url(#id) in filter/mask properties - if (node.type === "Url" && (property === "filter" || property === "mask")) { - if (node.value.startsWith("#")) { - node.value = `#${convertVDomId(model, node.value.substring(1))}`; - } - } - // transform vdom:// urls - if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) { - const newUrl = model.transformVDomUrl(node.value); - if (newUrl == null) { - list.remove(item); - } else { - node.value = newUrl; - } - } - }, - }); - - return csstree.generate(ast); - } catch (error) { - console.error("Error processing style value:", error); - return value; - } -} - -export function validateAndWrapReactStyle(model: TsunamiModel, style: Record): Record { - const sanitizedStyle: Record = {}; - let updated = false; - for (const [property, value] of Object.entries(style)) { - if (value == null || value === "") { - continue; - } - if (typeof value !== "string") { - sanitizedStyle[property] = value; // For non-string values, just copy as-is - continue; - } - if (value.includes("vdom://") || value.includes("url(#")) { - updated = true; - sanitizedStyle[property] = cssTransformStyleValue(model, property, value); - } else { - sanitizedStyle[property] = value; - } - } - if (!updated) { - return style; - } - return sanitizedStyle; -} - export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { if (!backendUpdate.transferelems || !backendUpdate.renderupdates) { return; @@ -172,30 +54,6 @@ export function restoreVDomElems(backendUpdate: VDomBackendUpdate) { }); } -export function mergeBackendUpdates(baseUpdate: VDomBackendUpdate, nextUpdate: VDomBackendUpdate) { - // Verify the updates are from the same block/sequence - if (baseUpdate.blockid !== nextUpdate.blockid || baseUpdate.ts !== nextUpdate.ts) { - console.error("Attempted to merge updates from different blocks or timestamps"); - return; - } - - // Merge TransferElems - if (nextUpdate.transferelems?.length > 0) { - if (!baseUpdate.transferelems) { - baseUpdate.transferelems = []; - } - baseUpdate.transferelems.push(...nextUpdate.transferelems); - } - - // Merge StateSync - if (nextUpdate.statesync?.length > 0) { - if (!baseUpdate.statesync) { - baseUpdate.statesync = []; - } - baseUpdate.statesync.push(...nextUpdate.statesync); - } -} - export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map) { const ctx = canvas.getContext("2d"); if (!ctx) { diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 7d719cfc59..3adde5b54e 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -4,18 +4,13 @@ import debug from "debug"; import * as jotai from "jotai"; -import { BlockNodeModel } from "@/app/block/blocktypes"; -import { getBlockMetaKeyAtom, globalStore, WOS } from "@/app/store/global"; -import { makeORef } from "@/app/store/wos"; -import { waveEventSubscribe } from "@/app/store/wps"; import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { RpcApi } from "@/app/store/wshclientapi"; import { makeFeBlockRouteId } from "@/app/store/wshrouter"; -import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil"; -import { VDomView } from "@/app/view/vdom/vdom"; +import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "./model-utils"; +import { getDefaultStore } from "jotai"; +import { applyCanvasOp, restoreVDomElems } from "./model-utils"; const dlog = debug("wave:vdom"); @@ -103,11 +98,7 @@ class VDomWshClient extends WshClient { } export class TsunamiModel { - blockId: string; - nodeModel: BlockNodeModel; - viewType: string; - viewIcon: jotai.Atom; - viewName: jotai.Atom; + clientId: string; viewRef: React.RefObject = { current: null }; vdomRoot: jotai.PrimitiveAtom = jotai.atom(); atoms: Map = new Map(); // key is atomname @@ -118,7 +109,6 @@ export class TsunamiModel { vdomNodeVersion: WeakMap> = new WeakMap(); compoundAtoms: Map> = new Map(); rootRefId: string = crypto.randomUUID(); - backendRoute: jotai.Atom; backendOpts: VDomBackendOpts; shouldDispose: boolean; disposed: boolean; @@ -130,72 +120,23 @@ export class TsunamiModel { queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; contextActive: jotai.PrimitiveAtom; wshClient: VDomWshClient; - persist: jotai.Atom; - routeGoneUnsub: () => void; - routeConfirmed: boolean = false; refOutputStore: Map = new Map(); globalVersion: jotai.PrimitiveAtom = jotai.atom(0); hasBackendWork: boolean = false; noPadding: jotai.PrimitiveAtom; - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewType = "vdom"; - this.blockId = blockId; - this.nodeModel = nodeModel; + constructor() { + this.clientId = getOrCreateClientId(); this.contextActive = jotai.atom(false); this.reset(); - this.viewIcon = jotai.atom("bolt"); - this.viewName = jotai.atom("Wave App"); - this.backendRoute = jotai.atom((get) => { - const blockData = get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); - return blockData?.meta?.["vdom:route"]; - }); this.noPadding = jotai.atom(true); - this.persist = getBlockMetaKeyAtom(this.blockId, "vdom:persist"); - this.wshClient = new VDomWshClient(this); - DefaultRouter.registerRoute(this.wshClient.routeId, this.wshClient); - const curBackendRoute = globalStore.get(this.backendRoute); - if (curBackendRoute) { - this.queueUpdate(true); - } - this.routeGoneUnsub = waveEventSubscribe({ - eventType: "route:gone", - scope: curBackendRoute, - handler: (event: WaveEvent) => { - this.disposed = true; - const shouldPersist = globalStore.get(this.persist); - if (!shouldPersist) { - this.nodeModel?.onClose?.(); - } - }, - }); - RpcApi.WaitForRouteCommand(TabRpcClient, { routeid: curBackendRoute, waitms: 4000 }, { timeout: 5000 }).then( - (routeOk: boolean) => { - if (routeOk) { - this.routeConfirmed = true; - this.queueUpdate(true); - } else { - this.disposed = true; - const shouldPersist = globalStore.get(this.persist); - if (!shouldPersist) { - this.nodeModel?.onClose?.(); - } - } - } - ); - } - - get viewComponent(): ViewComponent { - return VDomView; + this.queueUpdate(true); } - dispose() { - DefaultRouter.unregisterRoute(this.wshClient.routeId); - this.routeGoneUnsub?.(); - } + dispose() {} reset() { - globalStore.set(this.vdomRoot, null); + getDefaultStore().set(this.vdomRoot, null); this.atoms.clear(); this.refs.clear(); this.batchedEvents = []; @@ -216,38 +157,7 @@ export class TsunamiModel { this.refOutputStore.clear(); this.globalVersion = jotai.atom(0); this.hasBackendWork = false; - globalStore.set(this.contextActive, false); - } - - getBackendRoute(): string { - const blockData = globalStore.get(WOS.getWaveObjectAtom(makeORef("block", this.blockId))); - return blockData?.meta?.["vdom:route"]; - } - - transformVDomUrl(url: string): string { - if (url == null || url == "") { - return null; - } - if (!url.startsWith("vdom://")) { - return url; - } - const absUrl = url.substring(7); - return this.makeVDomUrl(absUrl); - } - - makeVDomUrl(path: string): string { - if (path == null || path == "") { - return null; - } - if (!path.startsWith("/")) { - return null; - } - const backendRouteId = this.getBackendRouteId(); - if (backendRouteId == null) { - return null; - } - const fullUrl = "/vdom/" + backendRouteId + path; - return fullUrl; + getDefaultStore().set(this.contextActive, false); } keyDownHandler(e: WaveKeyboardEvent): boolean { @@ -351,7 +261,7 @@ export class TsunamiModel { async _sendRenderRequest(force: boolean) { this.queuedUpdate = null; - if (this.disposed || !this.routeConfirmed) { + if (this.disposed) { return; } if (this.hasPendingRequest) { @@ -363,29 +273,29 @@ export class TsunamiModel { if (!force && !this.needsUpdate) { return; } - const backendRoute = globalStore.get(this.backendRoute); - if (backendRoute == null) { - console.log("vdom-model", "no backend route"); - return; - } this.hasPendingRequest = true; this.needsImmediateUpdate = false; try { const feUpdate = this.createFeUpdate(); dlog("fe-update", feUpdate); - const beUpdateGen = await RpcApi.VDomRenderCommand(TabRpcClient, feUpdate, { route: backendRoute }); - let baseUpdate: VDomBackendUpdate = null; - for await (const beUpdate of beUpdateGen) { - if (baseUpdate === null) { - baseUpdate = beUpdate; - } else { - mergeBackendUpdates(baseUpdate, beUpdate); - } + + const response = await fetch("/api/render", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(feUpdate), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - if (baseUpdate !== null) { - restoreVDomElems(baseUpdate); - dlog("be-update", baseUpdate); - this.handleBackendUpdate(baseUpdate); + + const backendUpdate: VDomBackendUpdate = await response.json(); + if (backendUpdate !== null) { + restoreVDomElems(backendUpdate); + dlog("be-update", backendUpdate); + this.handleBackendUpdate(backendUpdate); } dlog("update cycle done"); } finally { @@ -459,7 +369,7 @@ export class TsunamiModel { return; } const atom = this.getVDomNodeVersionAtom(vdom); - globalStore.set(atom, globalStore.get(atom) + 1); + getDefaultStore().set(atom, getDefaultStore().get(atom) + 1); } addErrorMessage(message: string) { @@ -475,7 +385,7 @@ export class TsunamiModel { } for (let renderUpdate of update.renderupdates) { if (renderUpdate.updatetype == "root") { - globalStore.set(this.vdomRoot, renderUpdate.vdom); + getDefaultStore().set(this.vdomRoot, renderUpdate.vdom); continue; } if (renderUpdate.updatetype == "append") { @@ -603,9 +513,9 @@ export class TsunamiModel { if (update == null) { return; } - globalStore.set(this.contextActive, true); + getDefaultStore().set(this.contextActive, true); const idMap = new Map(); - const vdomRoot = globalStore.get(this.vdomRoot); + const vdomRoot = getDefaultStore().get(this.vdomRoot); if (update.opts != null) { this.backendOpts = update.opts; } @@ -615,13 +525,13 @@ export class TsunamiModel { this.handleRefOperations(update, idMap); if (update.messages) { for (let message of update.messages) { - console.log("vdom-message", this.blockId, message.messagetype, message.message); + console.log("vdom-message", message.messagetype, message.message); if (message.stacktrace) { console.log("vdom-message-stacktrace", message.stacktrace); } } } - globalStore.set(this.globalVersion, globalStore.get(this.globalVersion) + 1); + getDefaultStore().set(this.globalVersion, getDefaultStore().get(this.globalVersion) + 1); if (update.haswork) { this.hasBackendWork = true; } @@ -650,13 +560,9 @@ export class TsunamiModel { } createFeUpdate(): VDomFrontendUpdate { - const blockORef = makeORef("block", this.blockId); - const blockAtom = WOS.getWaveObjectAtom(blockORef); - const blockData = globalStore.get(blockAtom); - const isBlockFocused = globalStore.get(this.nodeModel.isFocused); + const isFocused = document.hasFocus(); const renderContext: VDomRenderContext = { - blockid: this.blockId, - focused: isBlockFocused, + focused: isFocused, width: this.viewRef?.current?.offsetWidth ?? 0, height: this.viewRef?.current?.offsetHeight ?? 0, rootrefid: this.rootRefId, @@ -665,7 +571,7 @@ export class TsunamiModel { const feUpdate: VDomFrontendUpdate = { type: "frontendupdate", ts: Date.now(), - blockid: this.blockId, + clientid: this.clientId, rendercontext: renderContext, dispose: this.shouldDispose, resync: this.needsResync, @@ -679,12 +585,4 @@ export class TsunamiModel { } return feUpdate; } - - getBackendRouteId(): string { - const fullRoute = globalStore.get(this.backendRoute); - if (fullRoute == null || !fullRoute.startsWith("proc:")) { - return null; - } - return fullRoute?.split(":")[1]; - } } diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index 9ec52fcc01..f55b93b341 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -134,7 +134,6 @@ type VDomRefUpdate = { // vdom.VDomRenderContext type VDomRenderContext = { - blockid: string; focused: boolean; width: number; height: number; diff --git a/tsunami/frontend/src/util/clientid.ts b/tsunami/frontend/src/util/clientid.ts new file mode 100644 index 0000000000..4217e291ae --- /dev/null +++ b/tsunami/frontend/src/util/clientid.ts @@ -0,0 +1,26 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +const CLIENT_ID_KEY = "tsunami:clientid"; + +/** + * Gets or creates a unique client ID for this browser tab/window. + * The client ID is stored in sessionStorage and persists for the lifetime of the tab. + * If no client ID exists, a new UUID is generated and stored. + */ +export function getOrCreateClientId(): string { + let clientId = sessionStorage.getItem(CLIENT_ID_KEY); + if (!clientId) { + clientId = crypto.randomUUID(); + sessionStorage.setItem(CLIENT_ID_KEY, clientId); + } + return clientId; +} + +/** + * Clears the stored client ID from sessionStorage. + * A new client ID will be generated on the next call to getOrCreateClientId(). + */ +export function clearClientId(): void { + sessionStorage.removeItem(CLIENT_ID_KEY); +} \ No newline at end of file diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 7e67c54ffb..278bb4a1ab 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -6,10 +6,9 @@ import debug from "debug"; import * as jotai from "jotai"; import * as React from "react"; -import { BlockNodeModel } from "@/app/block/blocktypes"; import { Markdown } from "@/element/markdown"; -import { convertVDomId, getTextChildren, validateAndWrapCss, validateAndWrapReactStyle } from "@/model/model-utils"; -import { TsunamiModel } from "@/model/tsunami-model"; +import { getTextChildren } from "@/model/model-utils"; +import type { TsunamiModel } from "@/model/tsunami-model"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; const TextTag = "#text"; @@ -251,39 +250,9 @@ function convertProps(elem: VDomElem, model: TsunamiModel): [GenericPropsType, S } } } - val = validateAndWrapReactStyle(model, val); props[key] = val; continue; } - if (IdAttributes[key]) { - props[key] = convertVDomId(model, val); - continue; - } - if (AllowedSvgTags[elem.tag]) { - if ((elem.tag == "use" && key == "href") || (elem.tag == "textPath" && key == "href")) { - if (val == null || !val.startsWith("#")) { - continue; - } - props[key] = convertVDomId(model, "#" + val.substring(1)); - continue; - } - if (SvgUrlIdAttributes[key]) { - if (val == null || !val.startsWith("url(#") || !val.endsWith(")")) { - continue; - } - props[key] = "url(#" + convertVDomId(model, val.substring(4, val.length - 1)) + ")"; - continue; - } - } - if (key == "src" && val != null && val.startsWith("vdom://")) { - // transform vdom:// urls - const newUrl = model.transformVDomUrl(val); - if (newUrl == null) { - continue; - } - props[key] = newUrl; - continue; - } props[key] = val; } return [props, atomKeys]; @@ -341,13 +310,7 @@ function useVDom(model: TsunamiModel, elem: VDomElem): GenericPropsType { function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const props = useVDom(model, elem); return ( - + ); } @@ -356,19 +319,13 @@ function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { if (styleText == null) { return null; } - const wrapperClassName = "vdom-" + model.blockId; - // TODO handle errors - const sanitizedCss = validateAndWrapCss(model, styleText, wrapperClassName); - if (sanitizedCss == null) { - return null; - } - return ; + return ; } function WaveStyle({ src, model, onMount }: { src: string; model: TsunamiModel; onMount?: () => void }) { const [styleContent, setStyleContent] = React.useState(null); React.useEffect(() => { - async function fetchAndSanitizeCss() { + async function fetchCss() { try { const response = await fetch(src); if (!response.ok) { @@ -376,20 +333,13 @@ function WaveStyle({ src, model, onMount }: { src: string; model: TsunamiModel; return; } const cssText = await response.text(); - const wrapperClassName = "vdom-" + model.blockId; - const sanitizedCss = validateAndWrapCss(model, cssText, wrapperClassName); - if (sanitizedCss) { - setStyleContent(sanitizedCss); - } else { - onMount?.(); - console.error("Failed to sanitize CSS"); - } + setStyleContent(cssText); } catch (error) { console.error("Error fetching CSS:", error); onMount?.(); } } - fetchAndSanitizeCss(); + fetchCss(); }, [src, model]); // Trigger onMount after styleContent has been set and mounted React.useEffect(() => { @@ -470,10 +420,6 @@ function VDomRoot({ model }: { model: TsunamiModel }) { return
    {rtn}
    ; } -function makeVDomModel(blockId: string, nodeModel: BlockNodeModel): TsunamiModel { - return new TsunamiModel(blockId, nodeModel); -} - type VDomViewProps = { model: TsunamiModel; blockId: string; @@ -487,7 +433,7 @@ function VDomInnerView({ blockId, model }: VDomViewProps) { return ( <> {model.backendOpts?.globalstyles ? ( - + ) : null} {styleMounted ? : null} @@ -498,12 +444,11 @@ function VDomView({ blockId, model }: VDomViewProps) { let viewRef = React.useRef(null); let contextActive = jotai.useAtomValue(model.contextActive); model.viewRef = viewRef; - const vdomClass = "vdom-" + blockId; return ( -
    +
    {contextActive ? : null}
    ); } -export { makeVDomModel, VDomView }; +export { VDomView }; diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index b33b1de0d7..459e6fbb1b 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -159,7 +159,6 @@ type VDomEvent struct { } type VDomRenderContext struct { - BlockId string `json:"blockid"` Focused bool `json:"focused"` Width int `json:"width"` Height int `json:"height"` From 44f943cbddf24090259fb9eedbc1781c93f53594 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 12:53:14 -0700 Subject: [PATCH 017/134] checkpoint, no errors, try to load the view --- tsunami/frontend/src/app.tsx | 12 ++-- tsunami/frontend/src/model/tsunami-model.tsx | 58 ++++++++++++++------ tsunami/frontend/src/vdom.tsx | 7 +-- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/tsunami/frontend/src/app.tsx b/tsunami/frontend/src/app.tsx index 823cf54d13..d6ffd04b2e 100644 --- a/tsunami/frontend/src/app.tsx +++ b/tsunami/frontend/src/app.tsx @@ -1,15 +1,15 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { TsunamiModel } from "@/model/tsunami-model"; +import { VDomView } from "./vdom"; + +const globalModel = new TsunamiModel(); + function App() { return (
    -
    -

    Tsunami Frontend

    -

    - Welcome to the Tsunami Frontend application built with React 19, TypeScript, and Tailwind v4. -

    -
    +
    ); } diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 3adde5b54e..4cb03e49e8 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -4,8 +4,6 @@ import debug from "debug"; import * as jotai from "jotai"; -import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; -import { makeFeBlockRouteId } from "@/app/store/wshrouter"; import { getOrCreateClientId } from "@/util/clientid"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; @@ -83,20 +81,6 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn } } -class VDomWshClient extends WshClient { - model: TsunamiModel; - - constructor(model: TsunamiModel) { - super(makeFeBlockRouteId(model.blockId)); - this.model = model; - } - - handle_vdomasyncinitiation(rh: RpcResponseHelper, data: VDomAsyncInitiationRequest) { - dlog("async-initiation", rh.getSource(), data); - this.model.queueUpdate(true); - } -} - export class TsunamiModel { clientId: string; viewRef: React.RefObject = { current: null }; @@ -119,7 +103,7 @@ export class TsunamiModel { lastUpdateTs: number = 0; queuedUpdate: { timeoutId: any; ts: number; quick: boolean }; contextActive: jotai.PrimitiveAtom; - wshClient: VDomWshClient; + serverEventSource: EventSource; refOutputStore: Map = new Map(); globalVersion: jotai.PrimitiveAtom = jotai.atom(0); hasBackendWork: boolean = false; @@ -130,12 +114,44 @@ export class TsunamiModel { this.contextActive = jotai.atom(false); this.reset(); this.noPadding = jotai.atom(true); + this.setupServerEventSource(); this.queueUpdate(true); } - dispose() {} + dispose() { + if (this.serverEventSource) { + this.serverEventSource.close(); + this.serverEventSource = null; + } + } + + setupServerEventSource() { + if (this.serverEventSource) { + this.serverEventSource.close(); + } + + const url = `/api/updates?clientId=${encodeURIComponent(this.clientId)}`; + this.serverEventSource = new EventSource(url); + + this.serverEventSource.addEventListener("asyncinitiation", (event) => { + dlog("async-initiation SSE event received", event); + this.queueUpdate(true); + }); + + this.serverEventSource.addEventListener("error", (event) => { + console.error("SSE connection error:", event); + }); + + this.serverEventSource.addEventListener("open", (event) => { + dlog("SSE connection opened", event); + }); + } reset() { + if (this.serverEventSource) { + this.serverEventSource.close(); + this.serverEventSource = null; + } getDefaultStore().set(this.vdomRoot, null); this.atoms.clear(); this.refs.clear(); @@ -291,6 +307,12 @@ export class TsunamiModel { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } + // Check if EventSource connection is closed and reconnect if needed + if (this.serverEventSource && this.serverEventSource.readyState === EventSource.CLOSED) { + dlog("EventSource connection closed, reconnecting"); + this.setupServerEventSource(); + } + const backendUpdate: VDomBackendUpdate = await response.json(); if (backendUpdate !== null) { restoreVDomElems(backendUpdate); diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 278bb4a1ab..486a12064b 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -422,10 +422,9 @@ function VDomRoot({ model }: { model: TsunamiModel }) { type VDomViewProps = { model: TsunamiModel; - blockId: string; }; -function VDomInnerView({ blockId, model }: VDomViewProps) { +function VDomInnerView({ model }: VDomViewProps) { let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles); const handleStylesMounted = () => { setStyleMounted(true); @@ -440,13 +439,13 @@ function VDomInnerView({ blockId, model }: VDomViewProps) { ); } -function VDomView({ blockId, model }: VDomViewProps) { +function VDomView({ model }: VDomViewProps) { let viewRef = React.useRef(null); let contextActive = jotai.useAtomValue(model.contextActive); model.viewRef = viewRef; return (
    - {contextActive ? : null} + {contextActive ? : null}
    ); } From 53ff0c3d631319246d1b28de7a6ed48bd6fe2f93 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 12:57:22 -0700 Subject: [PATCH 018/134] tsunami_listenaddr --- tsunami/app/{waveapp.go => tsunamiapp.go} | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) rename tsunami/app/{waveapp.go => tsunamiapp.go} (96%) diff --git a/tsunami/app/waveapp.go b/tsunami/app/tsunamiapp.go similarity index 96% rename from tsunami/app/waveapp.go rename to tsunami/app/tsunamiapp.go index 1c80893a92..d3c64becc0 100644 --- a/tsunami/app/waveapp.go +++ b/tsunami/app/tsunamiapp.go @@ -25,6 +25,9 @@ import ( "github.com/wavetermdev/waveterm/tsunami/vdom" ) +const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" +const DefaultListenAddr = "localhost:0" + type SSEvent struct { Event string Data []byte @@ -155,14 +158,20 @@ func (c *Client) ListenAndServe(ctx context.Context) error { mux := http.NewServeMux() handlers.RegisterHandlers(mux) - // Create server and listen on any available port on localhost + // Determine listen address from environment variable or use default + listenAddr := os.Getenv(TsunamiListenAddrEnvVar) + if listenAddr == "" { + listenAddr = DefaultListenAddr + } + + // Create server and listen on specified address server := &http.Server{ - Addr: "localhost:0", + Addr: listenAddr, Handler: mux, } // Start listening - listener, err := net.Listen("tcp", "localhost:0") + listener, err := net.Listen("tcp", listenAddr) if err != nil { return fmt.Errorf("failed to listen: %v", err) } From 58eaf16c43116c2142d81810826254bef2d3e638 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 13:59:46 -0700 Subject: [PATCH 019/134] checkpoint -- fixing types, names, packages --- tsunami/app/serverhandlers.go | 2 +- tsunami/app/tsunamiapp.go | 6 +- tsunami/comp/root_test.go | 3 +- tsunami/comp/rootelem.go | 8 +-- tsunami/frontend/src/model/tsunami-model.tsx | 2 +- tsunami/frontend/src/types/custom.d.ts | 15 ----- tsunami/frontend/src/types/vdom.d.ts | 20 +----- tsunami/frontend/src/util/keyutil.ts | 20 +++--- tsunami/rpctypes/types.go | 67 +------------------- tsunami/vdom/vdom_types.go | 50 +++++++++++++++ 10 files changed, 74 insertions(+), 119 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 0fbed3862a..bef047adcf 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -package waveapp +package app import ( "encoding/json" diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index d3c64becc0..5ced66abde 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -package waveapp +package app import ( "context" @@ -51,7 +51,7 @@ type Client struct { DoneCh chan struct{} SSEventCh chan SSEvent Opts rpctypes.VDomBackendOpts - GlobalEventHandler func(client *Client, event rpctypes.VDomEvent) + GlobalEventHandler func(client *Client, event vdom.VDomEvent) GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router OverrideUrlHandler http.Handler @@ -94,7 +94,7 @@ func (c *Client) doShutdown(reason string) { close(c.DoneCh) } -func (c *Client) SetGlobalEventHandler(handler func(client *Client, event rpctypes.VDomEvent)) { +func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { c.GlobalEventHandler = handler } diff --git a/tsunami/comp/root_test.go b/tsunami/comp/root_test.go index 30f57ccacf..e7880829c3 100644 --- a/tsunami/comp/root_test.go +++ b/tsunami/comp/root_test.go @@ -10,7 +10,6 @@ import ( "log" "testing" - "github.com/wavetermdev/waveterm/tsunami/rpctypes" "github.com/wavetermdev/waveterm/tsunami/vdom" ) @@ -93,7 +92,7 @@ func Test1(t *testing.T) { printVDom(root) root.RunWork() printVDom(root) - root.Event(testContext.ButtonId, "onClick", rpctypes.VDomEvent{EventType: "onClick"}) + root.Event(testContext.ButtonId, "onClick", vdom.VDomEvent{EventType: "onClick"}) root.RunWork() printVDom(root) } diff --git a/tsunami/comp/rootelem.go b/tsunami/comp/rootelem.go index 04b7ab6dcb..26559ba387 100644 --- a/tsunami/comp/rootelem.go +++ b/tsunami/comp/rootelem.go @@ -140,7 +140,7 @@ func (r *RootElem) Render(elem *vdom.VDomElem) { r.render(elem, &r.Root) } -func callVDomFn(fnVal any, data rpctypes.VDomEvent) { +func callVDomFn(fnVal any, data vdom.VDomEvent) { if fnVal == nil { return } @@ -166,7 +166,7 @@ func callVDomFn(fnVal any, data rpctypes.VDomEvent) { } } -func (r *RootElem) Event(id string, propName string, event rpctypes.VDomEvent) { +func (r *RootElem) Event(id string, propName string, event vdom.VDomEvent) { comp := r.CompMap[id] if comp == nil || comp.Elem == nil { return @@ -481,7 +481,7 @@ func ConvertElemsToTransferElems(elems []vdom.VDomElem) []rpctypes.VDomTransferE return transferElems } -func VDomFuncCallFn(vdf *vdom.VDomFunc, event rpctypes.VDomEvent) { +func VDomFuncCallFn(vdf *vdom.VDomFunc, event vdom.VDomEvent) { if vdf.Fn == nil { return } @@ -494,7 +494,7 @@ func VDomFuncCallFn(vdf *vdom.VDomFunc, event rpctypes.VDomEvent) { rval.Call(nil) } if rtype.NumIn() == 1 { - if rtype.In(0) == reflect.TypeOf((*rpctypes.VDomEvent)(nil)).Elem() { + if rtype.In(0) == reflect.TypeOf((*vdom.VDomEvent)(nil)).Elem() { rval.Call([]reflect.Value{reflect.ValueOf(event)}) } } diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 4cb03e49e8..76cd5cb95c 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -176,7 +176,7 @@ export class TsunamiModel { getDefaultStore().set(this.contextActive, false); } - keyDownHandler(e: WaveKeyboardEvent): boolean { + keyDownHandler(e: VDomKeyboardEvent): boolean { if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) { this.shouldDispose = true; this.queueUpdate(true); diff --git a/tsunami/frontend/src/types/custom.d.ts b/tsunami/frontend/src/types/custom.d.ts index 1c5b788ade..b7c843aeb2 100644 --- a/tsunami/frontend/src/types/custom.d.ts +++ b/tsunami/frontend/src/types/custom.d.ts @@ -1,21 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -// vdom.WaveKeyboardEvent -type WaveKeyboardEvent = { - type: "keydown" | "keyup" | "keypress" | "unknown"; - key: string; - code: string; - repeat?: boolean; - location?: number; - shift?: boolean; - control?: boolean; - alt?: boolean; - meta?: boolean; - cmd?: boolean; - option?: boolean; -}; - type KeyPressDecl = { mods: { Cmd?: boolean; diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index f55b93b341..bfd45a3da5 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -61,8 +61,8 @@ type VDomEvent = { targetchecked?: boolean; targetname?: string; targetid?: string; - keydata?: WaveKeyboardEvent; - mousedata?: WavePointerData; + keydata?: VDomKeyboardEvent; + mousedata?: VDomPointerData; }; // vdom.VDomFrontendUpdate @@ -208,21 +208,7 @@ type VDomKeyboardEvent = { location?: number; }; -type WaveKeyboardEvent = { - type: "keydown" | "keyup" | "keypress" | "unknown"; - key: string; - code: string; - repeat?: boolean; - location?: number; - shift?: boolean; - control?: boolean; - alt?: boolean; - meta?: boolean; - cmd?: boolean; - option?: boolean; -}; - -type WavePointerData = { +type VDomPointerData = { button: number; buttons: number; clientx?: number; diff --git a/tsunami/frontend/src/util/keyutil.ts b/tsunami/frontend/src/util/keyutil.ts index 25b6c8dbf1..04c0d2048e 100644 --- a/tsunami/frontend/src/util/keyutil.ts +++ b/tsunami/frontend/src/util/keyutil.ts @@ -17,7 +17,7 @@ function getKeyUtilPlatform(): NodeJS.Platform { } function keydownWrapper( - fn: (waveEvent: WaveKeyboardEvent) => boolean + fn: (waveEvent: VDomKeyboardEvent) => boolean ): (event: KeyboardEvent | React.KeyboardEvent) => void { return (event: KeyboardEvent | React.KeyboardEvent) => { const waveEvent = adaptFromReactOrNativeKeyEvent(event); @@ -29,7 +29,7 @@ function keydownWrapper( }; } -function waveEventToKeyDesc(waveEvent: WaveKeyboardEvent): string { +function waveEventToKeyDesc(waveEvent: VDomKeyboardEvent): string { let keyDesc: string[] = []; if (waveEvent.cmd) { keyDesc.push("Cmd"); @@ -138,7 +138,7 @@ function countGraphemes(str: string): number { return Array.from(seg.segment(str)).length; } -function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean { +function isCharacterKeyEvent(event: VDomKeyboardEvent): boolean { if (event.alt || event.meta || event.control) { return false; } @@ -181,7 +181,7 @@ const inputKeyMap = new Map([ ["Cmd:Shift:ArrowDown", true], ]); -function isInputEvent(event: WaveKeyboardEvent): boolean { +function isInputEvent(event: VDomKeyboardEvent): boolean { if (isCharacterKeyEvent(event)) { return true; } @@ -192,7 +192,7 @@ function isInputEvent(event: WaveKeyboardEvent): boolean { } } -function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean { +function checkKeyPressed(event: VDomKeyboardEvent, keyDescription: string): boolean { let keyPress = parseKeyDescription(keyDescription); if (notMod(keyPress.mods.Option, event.option)) { return false; @@ -235,8 +235,8 @@ function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): bool return true; } -function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): WaveKeyboardEvent { - let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; +function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEvent): VDomKeyboardEvent { + let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent; rtn.control = event.ctrlKey; rtn.shift = event.shiftKey; rtn.cmd = PLATFORM == PlatformMacOS ? event.metaKey : event.altKey; @@ -256,8 +256,8 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve return rtn; } -function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { - let rtn: WaveKeyboardEvent = {} as WaveKeyboardEvent; +function adaptFromElectronKeyEvent(event: any): VDomKeyboardEvent { + let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent; if (event.type == "keyUp") { rtn.type = "keyup"; } else if (event.type == "keyDown") { @@ -295,7 +295,7 @@ const keyMap = { PageDown: "\x1b[6~", }; -function keyboardEventToASCII(event: WaveKeyboardEvent): string { +function keyboardEventToASCII(event: VDomKeyboardEvent): string { // check modifiers // if no modifiers are set, just send the key if (!event.alt && !event.control && !event.meta) { diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index 459e6fbb1b..b5b0849a5f 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -39,7 +39,7 @@ type VDomFrontendUpdate struct { Dispose bool `json:"dispose,omitempty"` // the vdom context was closed Resync bool `json:"resync,omitempty"` // resync (send all backend data). useful when the FE reloads RenderContext VDomRenderContext `json:"rendercontext,omitempty"` - Events []VDomEvent `json:"events,omitempty"` + Events []vdom.VDomEvent `json:"events,omitempty"` StateSync []VDomStateSync `json:"statesync,omitempty"` RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"` Messages []VDomMessage `json:"messages,omitempty"` @@ -146,18 +146,6 @@ func DedupTransferElems(elems []VDomTransferElem) []VDomTransferElem { return result } -type VDomEvent struct { - WaveId string `json:"waveid"` - EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) - GlobalEventType string `json:"globaleventtype,omitempty"` - TargetValue string `json:"targetvalue,omitempty"` - TargetChecked bool `json:"targetchecked,omitempty"` - TargetName string `json:"targetname,omitempty"` - TargetId string `json:"targetid,omitempty"` - KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` - MouseData *WavePointerData `json:"mousedata,omitempty"` -} - type VDomRenderContext struct { Focused bool `json:"focused"` Width int `json:"width"` @@ -204,56 +192,3 @@ type VDomMessage struct { StackTrace string `json:"stacktrace,omitempty"` Params []any `json:"params,omitempty"` } - -// matches WaveKeyboardEvent -type VDomKeyboardEvent struct { - Type string `json:"type"` - Key string `json:"key"` - Code string `json:"code"` - Shift bool `json:"shift,omitempty"` - Control bool `json:"ctrl,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` - Option bool `json:"option,omitempty"` - Repeat bool `json:"repeat,omitempty"` - Location int `json:"location,omitempty"` -} - -type WaveKeyboardEvent struct { - Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` - Key string `json:"key"` // KeyboardEvent.key - Code string `json:"code"` // KeyboardEvent.code - Repeat bool `json:"repeat,omitempty"` - Location int `json:"location,omitempty"` // KeyboardEvent.location - - // modifiers - Shift bool `json:"shift,omitempty"` - Control bool `json:"control,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) - Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) -} - -type WavePointerData struct { - Button int `json:"button"` - Buttons int `json:"buttons"` - - ClientX int `json:"clientx,omitempty"` - ClientY int `json:"clienty,omitempty"` - PageX int `json:"pagex,omitempty"` - PageY int `json:"pagey,omitempty"` - ScreenX int `json:"screenx,omitempty"` - ScreenY int `json:"screeny,omitempty"` - MovementX int `json:"movementx,omitempty"` - MovementY int `json:"movementy,omitempty"` - - // Modifiers - Shift bool `json:"shift,omitempty"` - Control bool `json:"control,omitempty"` - Alt bool `json:"alt,omitempty"` - Meta bool `json:"meta,omitempty"` - Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) - Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) -} diff --git a/tsunami/vdom/vdom_types.go b/tsunami/vdom/vdom_types.go index cfce234243..56664a13de 100644 --- a/tsunami/vdom/vdom_types.go +++ b/tsunami/vdom/vdom_types.go @@ -97,3 +97,53 @@ type VDomRefPosition struct { ScrollTop int `json:"scrolltop"` BoundingClientRect DomRect `json:"boundingclientrect"` } + +type VDomEvent struct { + WaveId string `json:"waveid"` + EventType string `json:"eventtype"` // usually the prop name (e.g. onClick, onKeyDown) + GlobalEventType string `json:"globaleventtype,omitempty"` + TargetValue string `json:"targetvalue,omitempty"` + TargetChecked bool `json:"targetchecked,omitempty"` + TargetName string `json:"targetname,omitempty"` + TargetId string `json:"targetid,omitempty"` + KeyData *VDomKeyboardEvent `json:"keydata,omitempty"` + MouseData *VDomPointerData `json:"mousedata,omitempty"` +} + +type VDomKeyboardEvent struct { + Type string `json:"type" tstype:"\"keydown\"|\"keyup\"|\"keypress\"|\"unknown\""` + Key string `json:"key"` // KeyboardEvent.key + Code string `json:"code"` // KeyboardEvent.code + Repeat bool `json:"repeat,omitempty"` + Location int `json:"location,omitempty"` // KeyboardEvent.location + + // modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} + +type VDomPointerData struct { + Button int `json:"button"` + Buttons int `json:"buttons"` + + ClientX int `json:"clientx,omitempty"` + ClientY int `json:"clienty,omitempty"` + PageX int `json:"pagex,omitempty"` + PageY int `json:"pagey,omitempty"` + ScreenX int `json:"screenx,omitempty"` + ScreenY int `json:"screeny,omitempty"` + MovementX int `json:"movementx,omitempty"` + MovementY int `json:"movementy,omitempty"` + + // Modifiers + Shift bool `json:"shift,omitempty"` + Control bool `json:"control,omitempty"` + Alt bool `json:"alt,omitempty"` + Meta bool `json:"meta,omitempty"` + Cmd bool `json:"cmd,omitempty"` // special (on mac it is meta, on windows/linux it is alt) + Option bool `json:"option,omitempty"` // special (on mac it is alt, on windows/linux it is meta) +} From d3d66846d3f85bf471651af1a16a4aa1bad7875f Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 14:49:23 -0700 Subject: [PATCH 020/134] got hello world working with new model --- Taskfile.yml | 12 ++ frontend/types/gotypes.d.ts | 1 + tsunami/app/serverhandlers.go | 12 +- tsunami/demo/todo/main-todo.go | 188 +++++++++++++++++++++++++++ tsunami/demo/todo/style.css | 68 ++++++++++ tsunami/frontend/src/util/keyutil.ts | 1 - tsunami/frontend/src/vdom.tsx | 2 +- tsunami/frontend/vite.config.ts | 10 ++ tsunami/rpctypes/types.go | 19 +-- 9 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 tsunami/demo/todo/main-todo.go create mode 100644 tsunami/demo/todo/style.css diff --git a/Taskfile.yml b/Taskfile.yml index 5e25f0967b..17cad4e27e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -438,3 +438,15 @@ tasks: ignore_error: true - cmd: '{{.RMRF}} "dist"' ignore_error: true + + tsunami:demo:todo: + desc: Run the tsunami todo demo application + cmd: go run demo/todo/main-todo.go + dir: tsunami + env: + TSUNAMI_LISTENADDR: "localhost:12026" + + tsunami:frontend:dev: + desc: Run the tsunami frontend vite dev server + cmd: npm run dev + dir: tsunami/frontend diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 3c09656649..0d70582e48 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -837,6 +837,7 @@ declare global { "debug:panictype"?: string; "block:view"?: string; "ai:backendtype"?: string; + "ai:local"?: boolean; "wsh:cmd"?: string; "wsh:haderror"?: boolean; "conn:conntype"?: string; diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index bef047adcf..bbc5f8a893 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -33,7 +33,7 @@ func NewHTTPHandlers(client *Client) *HTTPHandlers { func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) - mux.HandleFunc("/vdom/", h.handleVDomUrl) + mux.HandleFunc("/assets/", h.handleAssetsUrl) } func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { @@ -138,21 +138,21 @@ func (h *HTTPHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpda return update, nil } -func (h *HTTPHandlers) handleVDomUrl(w http.ResponseWriter, r *http.Request) { +func (h *HTTPHandlers) handleAssetsUrl(w http.ResponseWriter, r *http.Request) { defer func() { - panicErr := util.PanicHandler("handleVDomUrl", recover()) + panicErr := util.PanicHandler("handleAssetsUrl", recover()) if panicErr != nil { http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) } }() - // Strip /vdom prefix and update the request URL - r.URL.Path = strings.TrimPrefix(r.URL.Path, "/vdom") + // Strip /assets prefix and update the request URL + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/assets") if r.URL.Path == "" { r.URL.Path = "/" } - if r.URL.Path == "/wave/global.css" && h.Client.GlobalStylesOption != nil { + if r.URL.Path == "/global.css" && h.Client.GlobalStylesOption != nil { ServeFileOption(w, r, *h.Client.GlobalStylesOption) return } diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/main-todo.go new file mode 100644 index 0000000000..17d37345a4 --- /dev/null +++ b/tsunami/demo/todo/main-todo.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + _ "embed" + "strconv" + + "github.com/wavetermdev/waveterm/tsunami/app" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +//go:embed style.css +var styleCSS []byte + +// Initialize client with embedded styles and ctrl-c handling +var AppClient = app.MakeClient(app.AppOpts{ + CloseOnCtrlC: true, + GlobalStyles: styleCSS, +}) + +// Basic domain types with json tags for props +type Todo struct { + Id int `json:"id"` + Text string `json:"text"` + Completed bool `json:"completed"` +} + +// Prop types demonstrate parent->child data flow +type TodoListProps struct { + Todos []Todo `json:"todos"` + OnToggle func(int) `json:"onToggle"` + OnDelete func(int) `json:"onDelete"` +} + +type TodoItemProps struct { + Todo Todo `json:"todo"` + OnToggle func() `json:"onToggle"` + OnDelete func() `json:"onDelete"` +} + +type InputFieldProps struct { + Value string `json:"value"` + OnChange func(string) `json:"onChange"` + OnEnter func() `json:"onEnter"` +} + +// Reusable input component showing keyboard event handling +var InputField = app.DefineComponent[InputFieldProps](AppClient, "InputField", + func(ctx context.Context, props InputFieldProps) any { + // Example of special key handling with VDomFunc + keyDown := &vdom.VDomFunc{ + Type: vdom.ObjectType_Func, + Fn: func(event vdom.VDomEvent) { props.OnEnter() }, + StopPropagation: true, + PreventDefault: true, + Keys: []string{"Enter", "Cmd:Enter"}, + } + + return vdom.H("input", map[string]any{ + "className": "todo-input", + "type": "text", + "placeholder": "What needs to be done?", + "value": props.Value, + "onChange": func(e vdom.VDomEvent) { + props.OnChange(e.TargetValue) + }, + "onKeyDown": keyDown, + }) + }, +) + +// Item component showing conditional classes and event handling +var TodoItem = app.DefineComponent(AppClient, "TodoItem", + func(ctx context.Context, props TodoItemProps) any { + return vdom.H("div", map[string]any{ + "className": vdom.Classes("todo-item", vdom.If(props.Todo.Completed, "completed")), + }, + vdom.H("input", map[string]any{ + "className": "todo-checkbox", + "type": "checkbox", + "checked": props.Todo.Completed, + "onChange": props.OnToggle, + }), + vdom.H("span", map[string]any{ + "className": "todo-text", + }, props.Todo.Text), + vdom.H("button", map[string]any{ + "className": "todo-delete", + "onClick": props.OnDelete, + }, "×"), + ) + }, +) + +// List component demonstrating mapping over data, using WithKey to set key on a component +var TodoList = app.DefineComponent(AppClient, "TodoList", + func(ctx context.Context, props TodoListProps) any { + return vdom.H("div", map[string]any{ + "className": "todo-list", + }, vdom.ForEach(props.Todos, func(todo Todo) any { + return TodoItem(TodoItemProps{ + Todo: todo, + OnToggle: func() { props.OnToggle(todo.Id) }, + OnDelete: func() { props.OnDelete(todo.Id) }, + }).WithKey(strconv.Itoa(todo.Id)) + })) + }, +) + +// Root component showing state management and composition +var App = app.DefineComponent(AppClient, "App", + func(ctx context.Context, _ any) any { + // Multiple state hooks example + todos, setTodos := vdom.UseState(ctx, []Todo{ + {Id: 1, Text: "Learn VDOM", Completed: false}, + {Id: 2, Text: "Build a todo app", Completed: false}, + }) + nextId, setNextId := vdom.UseState(ctx, 3) + inputText, setInputText := vdom.UseState(ctx, "") + + // Event handlers modifying multiple pieces of state + addTodo := func() { + if inputText == "" { + return + } + setTodos(append(todos, Todo{ + Id: nextId, + Text: inputText, + Completed: false, + })) + setNextId(nextId + 1) + setInputText("") + } + + // Immutable state update pattern + toggleTodo := func(id int) { + newTodos := make([]Todo, len(todos)) + copy(newTodos, todos) + for i := range newTodos { + if newTodos[i].Id == id { + newTodos[i].Completed = !newTodos[i].Completed + break + } + } + setTodos(newTodos) + } + + // Filter pattern for deletion + deleteTodo := func(id int) { + newTodos := vdom.Filter(todos, func(todo Todo) bool { + return todo.Id != id + }) + setTodos(newTodos) + } + + return vdom.H("div", map[string]any{ + "className": "todo-app", + }, + vdom.H("div", map[string]any{ + "className": "todo-header", + }, vdom.H("h1", nil, "Todo List")), + + vdom.H("div", map[string]any{ + "className": "todo-form", + }, + InputField(InputFieldProps{ + Value: inputText, + OnChange: setInputText, + OnEnter: addTodo, + }), + vdom.H("button", map[string]any{ + "className": "todo-button", + "onClick": addTodo, + }, "Add Todo"), + ), + + TodoList(TodoListProps{ + Todos: todos, + OnToggle: toggleTodo, + OnDelete: deleteTodo, + }), + ) + }, +) + +func main() { + AppClient.RunMain() +} diff --git a/tsunami/demo/todo/style.css b/tsunami/demo/todo/style.css new file mode 100644 index 0000000000..89d4ce82c6 --- /dev/null +++ b/tsunami/demo/todo/style.css @@ -0,0 +1,68 @@ +.todo-app { + max-width: 500px; + margin: 20px; + font-family: sans-serif; +} +.todo-header { + margin-bottom: 20px; +} +.todo-form { + display: flex; + gap: 10px; + margin-bottom: 20px; +} +.todo-input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); +} +.todo-button { + padding: 8px 16px; + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-color); + cursor: pointer; +} +.todo-button:hover { + background: var(--button-hover-bg); +} +.todo-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.todo-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--block-bg); +} +.todo-item.completed { + opacity: 0.7; +} +.todo-item.completed .todo-text { + text-decoration: line-through; +} +.todo-text { + flex: 1; +} +.todo-checkbox { + width: 16px; + height: 16px; +} +.todo-delete { + color: var(--error-color); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} +.todo-delete:hover { + background: var(--error-bg); +} diff --git a/tsunami/frontend/src/util/keyutil.ts b/tsunami/frontend/src/util/keyutil.ts index 04c0d2048e..625cc1fc7c 100644 --- a/tsunami/frontend/src/util/keyutil.ts +++ b/tsunami/frontend/src/util/keyutil.ts @@ -246,7 +246,6 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve rtn.code = event.code; rtn.key = event.key; rtn.location = event.location; - (rtn as any).nativeEvent = event; if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { rtn.type = event.type; } else { diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 486a12064b..09544faf2f 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -432,7 +432,7 @@ function VDomInnerView({ model }: VDomViewProps) { return ( <> {model.backendOpts?.globalstyles ? ( - + ) : null} {styleMounted ? : null} diff --git a/tsunami/frontend/vite.config.ts b/tsunami/frontend/vite.config.ts index d2f1d946f2..4faaf3cb18 100644 --- a/tsunami/frontend/vite.config.ts +++ b/tsunami/frontend/vite.config.ts @@ -15,6 +15,16 @@ export default defineConfig({ server: { port: 12025, open: true, + proxy: { + "/api": { + target: "http://localhost:12026", + changeOrigin: true, + }, + "/assets": { + target: "http://localhost:12026", + changeOrigin: true, + }, + }, }, build: { outDir: "dist", diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index b5b0849a5f..0b3ee35395 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -67,17 +67,18 @@ type VDomTransferElem struct { } func (beUpdate *VDomBackendUpdate) CreateTransferElems() { - var allElems []vdom.VDomElem - for _, renderUpdate := range beUpdate.RenderUpdates { - if renderUpdate.VDom != nil { - allElems = append(allElems, *renderUpdate.VDom) + var vdomElems []vdom.VDomElem + for idx, reUpdate := range beUpdate.RenderUpdates { + if reUpdate.VDom == nil { + continue } + vdomElems = append(vdomElems, *reUpdate.VDom) + beUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId + beUpdate.RenderUpdates[idx].VDom = nil } - transferElems := ConvertElemsToTransferElems(allElems) - beUpdate.TransferElems = DedupTransferElems(transferElems) - for i := range beUpdate.RenderUpdates { - beUpdate.RenderUpdates[i].VDom = nil - } + transferElems := ConvertElemsToTransferElems(vdomElems) + transferElems = DedupTransferElems(transferElems) + beUpdate.TransferElems = transferElems } func ConvertElemsToTransferElems(elems []vdom.VDomElem) []VDomTransferElem { From db9cfb1ed509275a026a2186b557b0ab92da6a8a Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 14:58:12 -0700 Subject: [PATCH 021/134] add Title AppOpt --- tsunami/app/tsunamiapp.go | 2 ++ tsunami/demo/todo/main-todo.go | 3 ++- tsunami/frontend/index.html | 2 +- tsunami/frontend/src/model/tsunami-model.tsx | 3 +++ tsunami/frontend/src/types/vdom.d.ts | 1 + tsunami/rpctypes/types.go | 7 ++++--- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 5ced66abde..44de35a11f 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -38,6 +38,7 @@ type AppOpts struct { GlobalKeyboardEvents bool GlobalStyles []byte RootComponentName string // defaults to "App" + Title string } type Client struct { @@ -116,6 +117,7 @@ func MakeClient(appOpts AppOpts) *Client { Opts: rpctypes.VDomBackendOpts{ CloseOnCtrlC: appOpts.CloseOnCtrlC, GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents, + Title: appOpts.Title, }, } if len(appOpts.GlobalStyles) > 0 { diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/main-todo.go index 17d37345a4..b15ed38c05 100644 --- a/tsunami/demo/todo/main-todo.go +++ b/tsunami/demo/todo/main-todo.go @@ -16,6 +16,7 @@ var styleCSS []byte var AppClient = app.MakeClient(app.AppOpts{ CloseOnCtrlC: true, GlobalStyles: styleCSS, + Title: "Todo App (Tsunami Demo)", }) // Basic domain types with json tags for props @@ -45,7 +46,7 @@ type InputFieldProps struct { } // Reusable input component showing keyboard event handling -var InputField = app.DefineComponent[InputFieldProps](AppClient, "InputField", +var InputField = app.DefineComponent(AppClient, "InputField", func(ctx context.Context, props InputFieldProps) any { // Example of special key handling with VDomFunc keyDown := &vdom.VDomFunc{ diff --git a/tsunami/frontend/index.html b/tsunami/frontend/index.html index cd14fa1d91..95f7db0085 100644 --- a/tsunami/frontend/index.html +++ b/tsunami/frontend/index.html @@ -4,7 +4,7 @@ - Tsunami Frontend + Tsunami App
    diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 76cd5cb95c..10782eb447 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -540,6 +540,9 @@ export class TsunamiModel { const vdomRoot = getDefaultStore().get(this.vdomRoot); if (update.opts != null) { this.backendOpts = update.opts; + if (update.opts.title && update.opts.title.trim() !== "") { + document.title = update.opts.title; + } } makeVDomIdMap(vdomRoot, idMap); this.handleRenderUpdates(update, idMap); diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index bfd45a3da5..b2fe97918b 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -13,6 +13,7 @@ type VDomBackendOpts = { closeonctrlc?: boolean; globalkeyboardevents?: boolean; globalstyles?: boolean; + title?: string; }; // vdom.VDomBackendUpdate diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index 0b3ee35395..0da48e4409 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -167,9 +167,10 @@ type VDomRefUpdate struct { } type VDomBackendOpts struct { - CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` - GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` - GlobalStyles bool `json:"globalstyles,omitempty"` + CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` + GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` + GlobalStyles bool `json:"globalstyles,omitempty"` + Title string `json:"title,omitempty"` } type VDomRenderUpdate struct { From 89635edc300b6dce9758ebbbeb29556cdfeb9e80 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 16:50:44 -0700 Subject: [PATCH 022/134] work on tailwind, pass renderopts through render funcs... add UseResync hook --- tsunami/app/tsunamiapp.go | 10 +- tsunami/comp/root_test.go | 6 +- tsunami/comp/rootelem.go | 32 ++-- tsunami/comp/vdomcontext.go | 13 +- tsunami/demo/todo/main-todo.go | 28 ++-- tsunami/demo/todo/tw.css | 267 +++++++++++++++++++++++++++++++++ tsunami/frontend/package.json | 1 + tsunami/vdom/vdom.go | 8 + tsunami/vdom/vdom_context.go | 1 + yarn.lock | 164 +++++++++++++++++++- 10 files changed, 493 insertions(+), 37 deletions(-) create mode 100644 tsunami/demo/todo/tw.css diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 44de35a11f..1b0a65f131 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -34,11 +34,11 @@ type SSEvent struct { } type AppOpts struct { + Title string // window title CloseOnCtrlC bool GlobalKeyboardEvents bool GlobalStyles []byte RootComponentName string // defaults to "App" - Title string } type Client struct { @@ -257,8 +257,9 @@ func (c *Client) RegisterComponent(name string, cfunc any) error { } func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { - c.Root.RunWork() - c.Root.Render(c.RootElem) + opts := &comp.RenderOpts{Resync: true} + c.Root.RunWork(opts) + c.Root.Render(c.RootElem, opts) renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() @@ -277,7 +278,8 @@ func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { } func (c *Client) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { - c.Root.RunWork() + opts := &comp.RenderOpts{Resync: false} + c.Root.RunWork(opts) renderedVDom := c.Root.MakeVDom() if renderedVDom == nil { renderedVDom = makeNullVDom() diff --git a/tsunami/comp/root_test.go b/tsunami/comp/root_test.go index e7880829c3..397b1dc24b 100644 --- a/tsunami/comp/root_test.go +++ b/tsunami/comp/root_test.go @@ -85,14 +85,14 @@ func Test1(t *testing.T) { root.SetOuterCtx(ctx) root.RegisterComponent("Page", Page) root.RegisterComponent("Button", Button) - root.Render(vdom.E("Page")) + root.Render(vdom.E("Page"), &RenderOpts{Resync: false}) if root.Root == nil { t.Fatalf("root.Root is nil") } printVDom(root) - root.RunWork() + root.RunWork(&RenderOpts{Resync: false}) printVDom(root) root.Event(testContext.ButtonId, "onClick", vdom.VDomEvent{EventType: "onClick"}) - root.RunWork() + root.RunWork(&RenderOpts{Resync: false}) printVDom(root) } diff --git a/tsunami/comp/rootelem.go b/tsunami/comp/rootelem.go index 26559ba387..6e5811f4b5 100644 --- a/tsunami/comp/rootelem.go +++ b/tsunami/comp/rootelem.go @@ -17,6 +17,10 @@ import ( "github.com/wavetermdev/waveterm/tsunami/vdom" ) +type RenderOpts struct { + Resync bool +} + type RootElem struct { OuterCtx context.Context Root *ComponentImpl @@ -136,8 +140,8 @@ func (r *RootElem) RegisterComponent(name string, cfunc any) error { return nil } -func (r *RootElem) Render(elem *vdom.VDomElem) { - r.render(elem, &r.Root) +func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) { + r.render(elem, &r.Root, opts) } func callVDomFn(fnVal any, data vdom.VDomEvent) { @@ -177,7 +181,7 @@ func (r *RootElem) Event(id string, propName string, event vdom.VDomEvent) { // 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() { +func (r *RootElem) RunWork(opts *RenderOpts) { workQueue := r.EffectWorkQueue r.EffectWorkQueue = nil // first, run effect cleanups @@ -205,11 +209,11 @@ func (r *RootElem) RunWork() { // now check if we need a render if len(r.NeedsRenderMap) > 0 { r.NeedsRenderMap = nil - r.render(r.Root.Elem, &r.Root) + r.render(r.Root.Elem, &r.Root, opts) } } -func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl) { +func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) { if elem == nil || elem.Tag == "" { r.unmount(comp) return @@ -226,7 +230,7 @@ func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl) { } if vdom.IsBaseTag(elem.Tag) { // simple vdom, fragment, wave element - r.renderSimple(elem, comp) + r.renderSimple(elem, comp, opts) return } cfunc := r.CFuncs[elem.Tag] @@ -235,7 +239,7 @@ func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl) { r.renderText(text, comp) return } - r.renderComponent(cfunc, elem, comp) + r.renderComponent(cfunc, elem, comp, opts) } func (r *RootElem) unmount(comp **ComponentImpl) { @@ -272,7 +276,7 @@ func (r *RootElem) renderText(text string, comp **ComponentImpl) { } } -func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl) []*ComponentImpl { +func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, opts *RenderOpts) []*ComponentImpl { newChildren := make([]*ComponentImpl, len(elems)) curCM := make(map[ChildKey]*ComponentImpl) usedMap := make(map[*ComponentImpl]bool) @@ -293,7 +297,7 @@ func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*Componen } usedMap[curChild] = true newChildren[idx] = curChild - r.render(&elem, &newChildren[idx]) + r.render(&elem, &newChildren[idx], opts) } for _, child := range curChildren { if !usedMap[child] { @@ -303,11 +307,11 @@ func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*Componen return newChildren } -func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl) { +func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) { if (*comp).Comp != nil { r.unmount(&(*comp).Comp) } - (*comp).Children = r.renderChildren(elem.Children, (*comp).Children) + (*comp).Children = r.renderChildren(elem.Children, (*comp).Children, opts) } func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { @@ -337,7 +341,7 @@ func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { return rtnVal[0].Interface() } -func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl) { +func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -349,7 +353,7 @@ func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **Compon props[k] = v } props[vdom.ChildrenPropKey] = elem.Children - vc := MakeContextVal(r, *comp) + vc := MakeContextVal(r, *comp, opts) ctx := vdom.WithRenderContext(r.OuterCtx, vc) renderedElem := callCFunc(cfunc, ctx, props) rtnElemArr := vdom.PartToElems(renderedElem) @@ -363,7 +367,7 @@ func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **Compon } else { rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr} } - r.render(rtnElem, &(*comp).Comp) + r.render(rtnElem, &(*comp).Comp, opts) } func (r *RootElem) UpdateRef(updateRef rpctypes.VDomRefUpdate) { diff --git a/tsunami/comp/vdomcontext.go b/tsunami/comp/vdomcontext.go index 63063951db..2710ec0d37 100644 --- a/tsunami/comp/vdomcontext.go +++ b/tsunami/comp/vdomcontext.go @@ -9,10 +9,15 @@ type VDomContextVal struct { Root *RootElem Comp *ComponentImpl HookIdx int + Resync bool } -func MakeContextVal(root *RootElem, comp *ComponentImpl) *VDomContextVal { - return &VDomContextVal{Root: root, Comp: comp, HookIdx: 0} +func MakeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *VDomContextVal { + resync := false + if opts != nil { + resync = opts.Resync + } + return &VDomContextVal{Root: root, Comp: comp, HookIdx: 0, Resync: resync} } // Compile-time check to ensure VDomContextVal implements vdom.VDomContext @@ -52,3 +57,7 @@ func (vc *VDomContextVal) GetOrderedHook() *vdom.Hook { vc.HookIdx++ return hookVal } + +func (vc *VDomContextVal) IsResync() bool { + return vc.Resync +} diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/main-todo.go index b15ed38c05..4e456d0771 100644 --- a/tsunami/demo/todo/main-todo.go +++ b/tsunami/demo/todo/main-todo.go @@ -9,10 +9,10 @@ import ( "github.com/wavetermdev/waveterm/tsunami/vdom" ) -//go:embed style.css +//go:embed tw.css var styleCSS []byte -// Initialize client with embedded styles and ctrl-c handling +// Initialize client with embedded Tailwind styles and ctrl-c handling var AppClient = app.MakeClient(app.AppOpts{ CloseOnCtrlC: true, GlobalStyles: styleCSS, @@ -58,7 +58,7 @@ var InputField = app.DefineComponent(AppClient, "InputField", } return vdom.H("input", map[string]any{ - "className": "todo-input", + "className": "flex-1 p-2 border border-border rounded", "type": "text", "placeholder": "What needs to be done?", "value": props.Value, @@ -74,19 +74,19 @@ var InputField = app.DefineComponent(AppClient, "InputField", var TodoItem = app.DefineComponent(AppClient, "TodoItem", func(ctx context.Context, props TodoItemProps) any { return vdom.H("div", map[string]any{ - "className": vdom.Classes("todo-item", vdom.If(props.Todo.Completed, "completed")), + "className": vdom.Classes("flex items-center gap-2.5 p-2 border border-border rounded", vdom.If(props.Todo.Completed, "opacity-70")), }, vdom.H("input", map[string]any{ - "className": "todo-checkbox", + "className": "w-4 h-4", "type": "checkbox", "checked": props.Todo.Completed, "onChange": props.OnToggle, }), vdom.H("span", map[string]any{ - "className": "todo-text", + "className": vdom.Classes("flex-1", vdom.If(props.Todo.Completed, "line-through")), }, props.Todo.Text), vdom.H("button", map[string]any{ - "className": "todo-delete", + "className": "text-red-500 cursor-pointer px-2 py-1 rounded", "onClick": props.OnDelete, }, "×"), ) @@ -97,7 +97,7 @@ var TodoItem = app.DefineComponent(AppClient, "TodoItem", var TodoList = app.DefineComponent(AppClient, "TodoList", func(ctx context.Context, props TodoListProps) any { return vdom.H("div", map[string]any{ - "className": "todo-list", + "className": "flex flex-col gap-2", }, vdom.ForEach(props.Todos, func(todo Todo) any { return TodoItem(TodoItemProps{ Todo: todo, @@ -155,14 +155,16 @@ var App = app.DefineComponent(AppClient, "App", } return vdom.H("div", map[string]any{ - "className": "todo-app", + "className": "max-w-[500px] m-5 font-sans", }, vdom.H("div", map[string]any{ - "className": "todo-header", - }, vdom.H("h1", nil, "Todo List")), + "className": "mb-5", + }, vdom.H("h1", map[string]any{ + "className": "text-2xl font-bold", + }, "Todo List")), vdom.H("div", map[string]any{ - "className": "todo-form", + "className": "flex gap-2.5 mb-5", }, InputField(InputFieldProps{ Value: inputText, @@ -170,7 +172,7 @@ var App = app.DefineComponent(AppClient, "App", OnEnter: addTodo, }), vdom.H("button", map[string]any{ - "className": "todo-button", + "className": "px-4 py-2 border border-border rounded cursor-pointer", "onClick": addTodo, }, "Add Todo"), ), diff --git a/tsunami/demo/todo/tw.css b/tsunami/demo/todo/tw.css new file mode 100644 index 0000000000..584081e972 --- /dev/null +++ b/tsunami/demo/todo/tw.css @@ -0,0 +1,267 @@ +/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: "Inter", sans-serif; + --font-mono: "Hack", monospace; + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-500: oklch(63.7% 0.237 25.331); + --spacing: 0.25rem; + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-bold: 700; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --radius: 8px; + --color-border: rgba(255, 255, 255, 0.16); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .m-5 { + margin: calc(var(--spacing) * 5); + } + .mb-5 { + margin-bottom: calc(var(--spacing) * 5); + } + .flex { + display: flex; + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .max-w-\[500px\] { + max-width: 500px; + } + .flex-1 { + flex: 1; + } + .cursor-pointer { + cursor: pointer; + } + .flex-col { + flex-direction: column; + } + .items-center { + align-items: center; + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-2\.5 { + gap: calc(var(--spacing) * 2.5); + } + .rounded { + border-radius: var(--radius); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-border { + border-color: var(--color-border); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .font-sans { + font-family: var(--font-sans); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .text-red-500 { + color: var(--color-red-500); + } + .line-through { + text-decoration-line: line-through; + } + .opacity-70 { + opacity: 70%; + } +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-border-style: solid; + --tw-font-weight: initial; + } + } +} diff --git a/tsunami/frontend/package.json b/tsunami/frontend/package.json index 7ac38ddd69..98c7914245 100644 --- a/tsunami/frontend/package.json +++ b/tsunami/frontend/package.json @@ -25,6 +25,7 @@ "tailwind-merge": "^3.3.1" }, "devDependencies": { + "@tailwindcss/cli": "^4.1.12", "@tailwindcss/vite": "^4.0.17", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index dab9465182..8954ce307f 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -403,6 +403,14 @@ func UseRenderTs(ctx context.Context) int64 { return vc.GetRenderTs() } +func UseResync(ctx context.Context) bool { + vc := GetRenderContext(ctx) + if vc == nil { + panic("UseResync must be called within a component (no context)") + } + return vc.IsResync() +} + func depsEqual(deps1 []any, deps2 []any) bool { if len(deps1) != len(deps2) { return false diff --git a/tsunami/vdom/vdom_context.go b/tsunami/vdom/vdom_context.go index b693116ff0..0dbdd4a9b5 100644 --- a/tsunami/vdom/vdom_context.go +++ b/tsunami/vdom/vdom_context.go @@ -18,6 +18,7 @@ type VDomContext interface { GetRenderTs() int64 GetCompWaveId() string GetOrderedHook() *Hook + IsResync() bool } func WithRenderContext(ctx context.Context, vc VDomContext) context.Context { diff --git a/yarn.lock b/yarn.lock index af94e825f9..b98d85fb21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4163,6 +4163,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-android-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-android-arm64@npm:2.5.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@parcel/watcher-darwin-arm64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-darwin-arm64@npm:2.5.0" @@ -4170,6 +4177,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-darwin-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@parcel/watcher-darwin-x64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-darwin-x64@npm:2.5.0" @@ -4177,6 +4191,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-darwin-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-x64@npm:2.5.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-freebsd-x64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-freebsd-x64@npm:2.5.0" @@ -4184,6 +4205,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-freebsd-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher-linux-arm-glibc@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.0" @@ -4191,6 +4219,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-arm-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + "@parcel/watcher-linux-arm-musl@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.0" @@ -4198,6 +4233,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-arm-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@parcel/watcher-linux-arm64-glibc@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.0" @@ -4205,6 +4247,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-arm64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@parcel/watcher-linux-arm64-musl@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.0" @@ -4212,6 +4261,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-arm64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@parcel/watcher-linux-x64-glibc@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.0" @@ -4219,6 +4275,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-x64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@parcel/watcher-linux-x64-musl@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.0" @@ -4226,6 +4289,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-linux-x64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@parcel/watcher-win32-arm64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-win32-arm64@npm:2.5.0" @@ -4233,6 +4303,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-win32-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-arm64@npm:2.5.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@parcel/watcher-win32-ia32@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-win32-ia32@npm:2.5.0" @@ -4240,6 +4317,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-win32-ia32@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-ia32@npm:2.5.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@parcel/watcher-win32-x64@npm:2.5.0": version: 2.5.0 resolution: "@parcel/watcher-win32-x64@npm:2.5.0" @@ -4247,6 +4331,13 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-win32-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-x64@npm:2.5.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@parcel/watcher@npm:^2.4.1": version: 2.5.0 resolution: "@parcel/watcher@npm:2.5.0" @@ -4300,6 +4391,59 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher@npm:^2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher@npm:2.5.1" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.5.1" + "@parcel/watcher-darwin-arm64": "npm:2.5.1" + "@parcel/watcher-darwin-x64": "npm:2.5.1" + "@parcel/watcher-freebsd-x64": "npm:2.5.1" + "@parcel/watcher-linux-arm-glibc": "npm:2.5.1" + "@parcel/watcher-linux-arm-musl": "npm:2.5.1" + "@parcel/watcher-linux-arm64-glibc": "npm:2.5.1" + "@parcel/watcher-linux-arm64-musl": "npm:2.5.1" + "@parcel/watcher-linux-x64-glibc": "npm:2.5.1" + "@parcel/watcher-linux-x64-musl": "npm:2.5.1" + "@parcel/watcher-win32-arm64": "npm:2.5.1" + "@parcel/watcher-win32-ia32": "npm:2.5.1" + "@parcel/watcher-win32-x64": "npm:2.5.1" + detect-libc: "npm:^1.0.3" + is-glob: "npm:^4.0.3" + micromatch: "npm:^4.0.5" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-ia32": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10c0/8f35073d0c0b34a63d4c8d2213482f0ebc6a25de7b2cdd415d19cb929964a793cb285b68d1d50bfb732b070b3c82a2fdb4eb9c250eab709a1cd9d63345455a82 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -5586,6 +5730,23 @@ __metadata: languageName: node linkType: hard +"@tailwindcss/cli@npm:^4.1.12": + version: 4.1.12 + resolution: "@tailwindcss/cli@npm:4.1.12" + dependencies: + "@parcel/watcher": "npm:^2.5.1" + "@tailwindcss/node": "npm:4.1.12" + "@tailwindcss/oxide": "npm:4.1.12" + enhanced-resolve: "npm:^5.18.3" + mri: "npm:^1.2.0" + picocolors: "npm:^1.1.1" + tailwindcss: "npm:4.1.12" + bin: + tailwindcss: dist/index.mjs + checksum: 10c0/629abde6773c630fa72e653e17e675f5ffe080d720d99124b5361e684785e43d7f72e36ee53437a0e53f91c8ea61267a793cce8fe09b3a03288083d4efc19e5d + languageName: node + linkType: hard + "@tailwindcss/node@npm:4.1.12": version: 4.1.12 resolution: "@tailwindcss/node@npm:4.1.12" @@ -15865,7 +16026,7 @@ __metadata: languageName: node linkType: hard -"mri@npm:^1.1.0": +"mri@npm:^1.1.0, mri@npm:^1.2.0": version: 1.2.0 resolution: "mri@npm:1.2.0" checksum: 10c0/a3d32379c2554cf7351db6237ddc18dc9e54e4214953f3da105b97dc3babe0deb3ffe99cf409b38ea47cc29f9430561ba6b53b24ab8f9ce97a4b50409e4a50e7 @@ -21300,6 +21461,7 @@ __metadata: version: 0.0.0-use.local resolution: "tsunami-frontend@workspace:tsunami/frontend" dependencies: + "@tailwindcss/cli": "npm:^4.1.12" "@tailwindcss/vite": "npm:^4.0.17" "@types/react": "npm:^19" "@types/react-dom": "npm:^19" From e29e47f91e6e152846c03211b62200b55c7d036d Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 16:54:21 -0700 Subject: [PATCH 023/134] hold renderops in vdomcontextval --- tsunami/comp/vdomcontext.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tsunami/comp/vdomcontext.go b/tsunami/comp/vdomcontext.go index 2710ec0d37..a9594757f9 100644 --- a/tsunami/comp/vdomcontext.go +++ b/tsunami/comp/vdomcontext.go @@ -6,18 +6,14 @@ package comp import "github.com/wavetermdev/waveterm/tsunami/vdom" type VDomContextVal struct { - Root *RootElem - Comp *ComponentImpl - HookIdx int - Resync bool + Root *RootElem + Comp *ComponentImpl + HookIdx int + RenderOpts *RenderOpts } func MakeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *VDomContextVal { - resync := false - if opts != nil { - resync = opts.Resync - } - return &VDomContextVal{Root: root, Comp: comp, HookIdx: 0, Resync: resync} + return &VDomContextVal{Root: root, Comp: comp, HookIdx: 0, RenderOpts: opts} } // Compile-time check to ensure VDomContextVal implements vdom.VDomContext @@ -59,5 +55,8 @@ func (vc *VDomContextVal) GetOrderedHook() *vdom.Hook { } func (vc *VDomContextVal) IsResync() bool { - return vc.Resync + if vc.RenderOpts == nil { + return false + } + return vc.RenderOpts.Resync } From 0f776edd801de03c520f3790d08aa7c48dffd69e Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 20:56:44 -0700 Subject: [PATCH 024/134] deal with static files (for prod builds) --- Taskfile.yml | 2 +- public/logos/wave-logo-256.png | Bin 0 -> 9793 bytes tsunami/app/serverhandlers.go | 44 +++++++++++++++++- tsunami/app/tsunamiapp.go | 13 ++++-- tsunami/frontend/public/fonts/hack-bold.woff2 | Bin 0 -> 108008 bytes .../frontend/public/fonts/hack-regular.woff2 | Bin 0 -> 106236 bytes .../public/fonts/inter-variable.woff2 | Bin 0 -> 345588 bytes tsunami/frontend/public/wave-logo-256.png | Bin 0 -> 9793 bytes tsunami/frontend/src/vdom.tsx | 2 +- 9 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 public/logos/wave-logo-256.png create mode 100644 tsunami/frontend/public/fonts/hack-bold.woff2 create mode 100644 tsunami/frontend/public/fonts/hack-regular.woff2 create mode 100644 tsunami/frontend/public/fonts/inter-variable.woff2 create mode 100644 tsunami/frontend/public/wave-logo-256.png diff --git a/Taskfile.yml b/Taskfile.yml index 17cad4e27e..124220e38c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -441,7 +441,7 @@ tasks: tsunami:demo:todo: desc: Run the tsunami todo demo application - cmd: go run demo/todo/main-todo.go + cmd: go run demo/todo/*.go dir: tsunami env: TSUNAMI_LISTENADDR: "localhost:12026" diff --git a/public/logos/wave-logo-256.png b/public/logos/wave-logo-256.png new file mode 100644 index 0000000000000000000000000000000000000000..d360280f1df6044b709b13257bff6e7806b31882 GIT binary patch literal 9793 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 WfZg^0S>uBPprxj#TBmFq^}hgAr7bZ4 literal 0 HcmV?d00001 diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index bbc5f8a893..c77f82d1cb 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -4,10 +4,12 @@ package app import ( + "embed" "encoding/json" "fmt" "io" "log" + "mime" "net/http" "strings" "sync" @@ -19,6 +21,11 @@ import ( const SSEKeepAliveDuration = 5 * time.Second +func init() { + // Add explicit mapping for .json files + mime.AddExtensionType(".json", "application/json") +} + type HTTPHandlers struct { Client *Client renderLock sync.Mutex @@ -30,10 +37,15 @@ func NewHTTPHandlers(client *Client) *HTTPHandlers { } } -func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux) { +func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, embeddedFS *embed.FS) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) - mux.HandleFunc("/assets/", h.handleAssetsUrl) + mux.HandleFunc("/files/", h.handleAssetsUrl) + + // Add fallback handler for embedded static files in production mode + if embeddedFS != nil { + mux.HandleFunc("/", h.handleStaticFiles(embeddedFS)) + } } func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { @@ -222,3 +234,31 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { } } } + +func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc { + // Create a file server from the embedded FS + fileServer := http.FileServer(http.FS(embeddedFS)) + + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleStaticFiles", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + // Skip if this is an API or files request (already handled by other handlers) + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") { + http.NotFound(w, r) + return + } + + // Handle root "/" => "/index.html" + if r.URL.Path == "/" { + r.URL.Path = "/index.html" + } + + // Serve the file using Go's file server + fileServer.ServeHTTP(w, r) + } +} diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 1b0a65f131..58e2e67975 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -5,6 +5,7 @@ package app import ( "context" + "embed" "fmt" "io" "io/fs" @@ -28,6 +29,8 @@ import ( const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" +var assetsFS *embed.FS + type SSEvent struct { Event string Data []byte @@ -132,7 +135,7 @@ func (c *Client) runMainE() error { if c.SetupFn != nil { c.SetupFn() } - err := c.ListenAndServe(context.Background()) + err := c.ListenAndServe(context.Background(), assetsFS) if err != nil { return err } @@ -152,13 +155,13 @@ func (c *Client) RunMain() { } } -func (c *Client) ListenAndServe(ctx context.Context) error { +func (c *Client) ListenAndServe(ctx context.Context, embeddedFS *embed.FS) error { // Create HTTP handlers handlers := NewHTTPHandlers(c) // Create a new ServeMux and register handlers mux := http.NewServeMux() - handlers.RegisterHandlers(mux) + handlers.RegisterHandlers(mux, embeddedFS) // Determine listen address from environment variable or use default listenAddr := os.Getenv(TsunamiListenAddrEnvVar) @@ -450,3 +453,7 @@ func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) { } }) } + +func RegisterAssetsFS(fs embed.FS) { + assetsFS = &fs +} diff --git a/tsunami/frontend/public/fonts/hack-bold.woff2 b/tsunami/frontend/public/fonts/hack-bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1155477e966f2967366ee1ae3f658eef0448a51e GIT binary patch literal 108008 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

    nBsD%Mg&;1R|sj`5v+!&9N1A@faH>G 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

    +{y5>Qj&blr~7F|ff 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

    +<#}>?RfSLWb^pdT4|$;d~i` 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 EKi!h~=l}o! literal 0 HcmV?d00001 diff --git a/tsunami/frontend/public/fonts/hack-regular.woff2 b/tsunami/frontend/public/fonts/hack-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..524465cf5113597c83bb73e60fff0bd64fef0644 GIT binary patch literal 106236 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 NE|#bL1PcHF{s%4XDq#Qs literal 0 HcmV?d00001 diff --git a/tsunami/frontend/public/fonts/inter-variable.woff2 b/tsunami/frontend/public/fonts/inter-variable.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..22a12b04e1abd959c00f9d8429ca78ddae3f7dde GIT binary patch literal 345588 zcmV)IK)k(x3)?&Z1Rw>P9tVN`cw5r9yx;dlbbX~{SOrpEVhw}JvM!>mX<6qj z^6hr<-TC(;{yRGluA(V4f8(u#uNRs2C2*jxUei+1jN;HRw%wS5@ zG&8kUZ!~MQRxM51kvmk&oH#0znCf)1=`2G$qml*Fo9$=hEd1Pjdyr)d$s(vRG8v(# zQeDc_<@qvtSXm8g8P?^7i?E5ZVq!sI^i|bu&CktkPo_JGz8jr7BhU5M`skueEMafp z_K7;hzF-692q)9n!NGI(z>A^H#jcdh<{9P>gY#1kN7=DajDpFWwA`vEi>I%OTWH4n zDN1LS#6HQmN}A}enTQjO<7KtO`R@5^v=(AqAThbqH#yBWjPO<=MNeFQC%m|-e=n}j zet`Ekyp_aS zClEaCoCE}QlETJ@3TJ(Kbre_E-y-iwFFbmJdRGJ@QQSmV{5ZcKVU?%tPBTL+9u(qd z>`N#FQ-9F-8xi_ypnd%umsPEgSiE?zV&dpK{y}FZ5q@pAYCQguU zcpvd;gZVv0Qpu@gMm7FL@%GY0Bo%#~q@4PR^!B&n?MPSJCt1mw!F2?IO@nGrV z?(K$;Z@09A$%FF_+vO64LwLfp2~Y7g6&>Pws*Ddr(k-2$Ls^wM#VWT zXip$aTPhXEt(@*mC<~yyv~WI+ms7tAijCBM_pr*gn+XBuFIH(1%n-IP^;+bjwA)W{ zW%A)do|v`?dPcsRxXy%(<1*fz2$ATOnc6 zS#VC0mj(kxd~)SH#oJ4B6z1bfc`Zrq<-FC)UChd9$tazM@}Y39Wg7ht8PL@TA7wZ( z4&V8HRrK@=f2R>FltzEABEEl~aul2bP3syA1RHY>jtC9HWa-?5BZ-}l#e|~QFVV_! z>Q&9?25{D>8RqbDxgsvbaf}hs7U!8V>^O>AT?EXaD{?$U*wLzZQvNK2TzkY{#f4+T z-xsr(X*YLH6O1&Px~>~l>J~Y{mQI!YSRns=clwn}*wYBgS**a6p25TtXvB@mUhAtN z^{_XT#Bgg_&f?epTYrQf@ms8t5&DrC`TiIwm$34)J04{gH)4F~VtpFVkD;SD=QJ}@ zN%du@)w6|na-I^%DV{tLR7~oFKKTLkj(7Aiu9$v|odNy8HZC>R^eLw1LclSSRwy~YFHtw&Y z8gCF^4v_wrk$Y4}jQT5PSbO&;UizPoFYtWf8=Okkhhkh-|6t^=RNK03sn1Hg5MN_F z6rX=5X%6N#2f`CKn=R25?%}hNu9Q}^WO4=9wVLxHN2ogdPwq%YndOzFek3gu@1#xC zqxhtJ6cm4s^I@~~ZeOG#RsN4{!^@A^?p`N5ldHm%B0QFG+j#lHX&cSoIBXueMzEO} zDbtrSmTwwZD;CMQtoqd|q2dgsT5vvbWh@UW@M!~y$WgJ&nsBb_wzr({6nmNIi0)nq zdqh$yb<|xDS?;h)m%j?4g@#h9xp=WCJbdoplmIFfPNy(oOk(eZa}M3yhoTZkOYN^j z{#|fi#)E4HE?43~!^Jt$bM>04Q6;07Ct!0!JJ8Py=I|GEQ6=`;aH8_1x{CTI)D}E$ zwnO+kj+IUtocLxvE{wv{kfMapKVWMPS0`##xR=61Sow}dC$;kMWj?(h&K zQrLnPCMS_+CR|d&@W6TBOp1b`E}W}5Uw6V2wR$Ql!qw`^WvO)31VypdgsZ~fSj|~2 z1@TQiQK!Uvg~Aoet=G=%sC*J~5en~K-@nUb24BGY`nPB8FS0;x2m~SHuDy-4R6>YA z3OiY`mmH+_D0FJ{ijzEy`e8bE=1XyvZ;R|MS=6Wo56LKdTAQoME8e#F$Prp!vtM)j zhc9|({-3HIjkc`~knMyo;veAyLj1kN5OP3*2xv8{-rd8aeo_A?@9a4czZ~ij9<$g^ z7T%2IMfr#+Q4_lS=lQjD?)x);B8g8#n{-*I7%14e3o0roCSevBSdAFFZ8R2Qt=L$F zx$}-E%h}iK?wRSC-KW#sMb|U@0YdUaguwF29t1lcvZi{a_nxn}+H-iQ^bi zM)40cRsYFBa1t}gAX~O2TW~0y=yakJp8&~<+^hql{;eB->^h+S0e95)?{`35hwZQw z4e)d8w-Ev&n1OLdpL%-jP%r=K-FfzT?fLJF#=JYXx9DJu6^}bmtHsG7R&zJa(JfZ3h2SSB4E=s0 zZvXCG0PUaM{w%wgup1uc)<029Ops7AH(&$SEFxL6WFs~h zBPu0IN>ITdtUC2P<2+B#yH@YKs{gNQh?eUAoY)X!h?*1#>?Cc#4A3<%eZrSNa>@7zEO`l9fVVZ*^fr6d^d<~2mo7tfs!X5W zW&xd$m+39MMXgqV-_e}`u>cB?38xb)4$uxp_x<+9FS#wV`vC=xE);0X!AQKoh%Qur z8ULxSzCRIh158&IJFAU>yXTWj04u29fV-LZ$rZ#kz!i}{@t5{y9%ufl@&?=X2o`pd zvlZ3bgY!4}fxp?C+Fb)&Z97{N{cC8E18{0&2mGA<|D1@SQfRT(+3L34yYIItZLf;v zB#?)oaR;NjRp+Zs#v;+&OAsIc6 zknX1}%Yq%!)UqH}3Dj=Xs5raoZ=oC0Dy3$WRhSv;XA+FrJn@jCq+3Q1?f08|w)e#h zv7{vuBbgjvb6}m!;Yl~2cXzqFtU(qlA%ifdf+(O?Hx3lnsJo(~sHnBtYS*@7`CET$ zt+lm|>PD-GV*P`r7~R`(L{H_uSTuJ|IOV$^amA z>~ieN_w0Vwa355pMZ8(X?pm9pgDqa zN|a&%En`fwfL99xeBOTTF=wvn*$?BMnKtLK4sVXT!x8)LT+zW>rkHD(xz3G>=+fvw ze|?ItTdKEe_0z4j4x(CgjUUm}`g&E>-2)`alI6S5 z+8*t<-Q^r7!YLk(0tgI%VNr0-7& z#`An*d?QJcc9J9`Z$HoXjErQAj3h~tq@5&5k|fDUk|as;eb;qe*BB!i?Ii6aNjvQW z9gL7unjbwsw+FXmC{6yNsIZA`naWDQ_uwxYlPIP~7m?7H22Z){|Lr7cW_C8*5T8TN zfSH+KBg58ee}KQtnTs2cM0Frtlc=@1~_+GKaNB;4_|9^Y!bDvw` zB}v_1{ePYyr-ZzWt?rpjof)g6(!_wA5;8JM8Sn^@kqyurHg4}gWLFgXnW+`9;tVB` z;yDxKFLe2?&~z7YN%Y?6cbt3sq~f=>z27o7fd3Ii*EhYnL8O)(fXb~X}_sm&ZrQ8CY7dBBG3KK%2Z{GeNDW)>Rk?t4A}0o|uenmXy))avB_ z{r_zK@G7Ury8N20(Ct&9x`+;<8APW-Z$)(x9mHSi44!0k9)GZ!R~^q=__<>7WL3VQgpHi+k4|L^8R(@<36 z*-y|Zp!gLs7x*HdtxIId6@)0<_8>bLmSD)m0e+i3JRTLBi@xernagmUZ1+>#c6VqK zWi(2Qm4r4SHdwK=!3xj~3uS}NXOO}F`};k%PRY0?TQyZ>-}lSlkOz6aJ4ErL1Ubb< zP41chchgVJY3H;)Q976E#OzXLf~D-eymKYM4m=b3L?ll>eke~Yvy8CyG=bbn7-42^ zw9xP?Z6(%2QBbIld-VQGvPX6c>?JH%$vfeJ*k7&R4`~4$-9RL8`h^@9?Dqp^FlfjM zmXbEC?^x|WDu_4sF_WLo43Fcs7y;u?heZlBN<(XHg)99dx3{*x+X9Xt!oVpEJRZKE z`@E|NN{|971RLLskFcvczP{tvxIaEd@Jmwiof`8Fe6N$eTzt7N%M24B;0ZTkFg?8j@9h_y{d=mCgf1C5`8@xns$KsFOlts9 z_6+F)$|+~fagU|+<6hVQKAm4%oqOqq2OabzyGnxZDgzS`Fu|Vvh6_J%E!p^)pO8=ohZ+;y z0oywT;X$a_<#PW>3c)(;`(Bce0<_vpP~AO2)s`&J5>#)= znm+#kd($k-@-_SUfA7A?j2G`^0+A6Q6&V3ifk;XUM3QnK6Oq?~|2J3&#hwyKug zs!djHo9$lqKqhG?0Lv!{NG^yf`_OW^r^}vg`|D+M*DUMuHNW%M+t2sM@cjQTzm#^} ztytw$tI@0h$jeZG#I`fz|Gk=?P)F_XpOODKCe%zqJ<%y4FEb_p4Iq)3paTNL|7R|3 z4~-&?uzaJvpo~r_wpQuPFgrVh1x~OFN`Z6Eon5)D^m@0& zE=PegCnzqUcn%4w8vjoB8~Cfq3FneOomH<;tFWMgQ&@-4C?SBVmL>g)d^_mpjnq`o zx!@C`Xy=y9+{m}goaE6P&*XwDgd}Dea9HsC|4JFJ13(Ku3ouWwpXjlP4nE^5yi zAao&V3T?Ne#!-4kAu&^K!0!LAQtjKm2NU`MS#Bp-(}Pt0=Rx|zO}U1kYFLYQb+>Zwvww< z{$GIy$;0?Vcj|L~!`Oij1k)zg-TU5qbk4a~lCLC#WCdg>TMjsK&?(Rbxvpe6Wjm>Q zaVqsXP7y3W;=qGceNGwxX2hRy);#QC^DwW0Mu5{lnDt=ZtH(ne%(`duCQui%EKUC; zD1i{>6V`_8`FZ}&RI~kmcZN5Ma1sEey9`I@UDH6k6r0vpRI`6(Fq;NM*rjzKsH2p5 zIdTC=kAnDYn0BuGu9B~59b)aScj>mz{PX?Av^}yKVsK~GOH%m@P4gBh0f>i>tMM*& z{(W`RP9T;xf!yN@8fb)Nf%GwIH3~G1sA0_Zmqqvot8y%gA}R!rvj`!+ep*s>JdJbFH&ccYw{+kK-Z3-RJ5%v6h64s|K3-v(tGZIAAA5QHE<#H z0HnH$TIYihfTZpo>!Zy!h_*lg><@l^(17GL36eb&r8Zt>4BJ%^!29?h1xkJ_*j5@3 zu9L=iX^e}eHpq-mCy1P)btc?&Yt!AzHNDP;8-yDL8wVT58*jS)r)gH&=nqg7nh^o(wY9NlT8GsdO^_8EYaP;;(JO4jCgJoMgl zx9-E>z%tnzNanq=Fw!Ih*=d|ubPV`l!xLsv3-0Cv+omS8Tbj35q^DjR+V3V8U#` ze7MF)XW;2u)@;}B+AC6g9*J?=Gc`kUxDbuI5O_$1tUX(sFhq==r zHK+^ZhJ`7BMJ1q#KmyGWU+rzS-_Goj9a3Z~C|ptq2MVM4M~W8czm^(J-EXMNOk!x$ zr@$$p%M7}1_VP*u((`+#8MWqr$s0MjQW+gNtuk5*0f$H%MXS8nF90n;*a^M_ftYG| zc)RHx{4KdHpzLAb$53#zANNywmt(^x)E@IX>nvCWys|rB9tA z@c$=%5C7Ia%)HNUTs5Mus5l}bDk`d~#u)Ry=6wBn&I9Rd_e%A(Y;DGfAbi0LVlesK z2I)#NBf<5PzLp?LFo#*pQs0yR_?&k4KePX9tShdlsEDYjs2bNZ`-T7Ce$Vs2$6Oyj z=jC}^xkyMvM1(|ygoubl_W{*Z>i<LJ0Bgz9-#trVf0cF*jRr;3jrtQV&JUf^8q4D!tsptP}u?=o~kj zJG|SAT$Qt=sRQ34-jB$m6d~t$51p10-{FS z?dNlUWX<{p2eDfn;j=THy-2ECqn514V@Ka}0k)`1_U- zWb1!HR4yN)wKhU@#}yF$>`xFZv-d%)IyJ<$R|K((AaN0rUZ27z4mz8~~5;07|jJCozO2hg@kP z2^~ssLklLfVnerFSfvowD2E}nu)*!HjUe2`7@lVfuL*{)nc*-$9M=x#&_K?wi~zSg z1Q2clpyGgu2R6i8-7E*lEDP|br@LG%~HP#i03u(}TG8?dn%*LUK^Zrn40_s-#4 z3H&JE_<}^w6C*_5GfIf5M@5L)hb6?E<0r&{r$mSar%s5o4+av;4ha$$9y%me9XTX! zIjN9%^$+R@pOq*mM^Jq&R>e2op?Qxnasu$~|Ozmzr)s8lKl z3;TmLzhEaYI0*~xB0`{~5F#bCl^((&LnKUyfeQ(UAq6$0p@$62&<;CvB@RPb!vrN^ zlBzIWO_)O#cJPLOu)gj`SV6P zjSggg2P4M|iCSK2QmcbUp_h>={E$)PVWXJCC8ZrbDgBsHzthKYTu45SR65*gXKib* z`dWA}o68r9C4yHUx80AnoazM#0MDWf0PhCimjM0+m^A>424H;vYz=@N1h9Jm_XpsW z05KRK&H$7Fpltwo8&DwtDg#i@1Pl}a3?=}yO@Ofxz<4acq!I)$;1nkJ)NnG!eiE02~0ZuhVsi zB;R6shQDJPuAJwg)2qNZH-V`{@E|bcyFhHtyq9PggD+gv{Z%Lc;BdeO%U1!3C3{z! z)i0U%zz6^i4=gHbfAq6g{Z6O2=U!MUd6iuHMERsS8b}@OLt|rZH`D-t!-F<4{v|`f z2GCx`f*$`zttVGjwrx5Ws**H!(sypc#9@OzR8Q)W7K@5^IaBMadSjF?zfwIv_NT0i zI-iky+R$rJ3jhHC2h9+UZrb#n)9{_^j$gj+^`hgryhhkj4}J}Ms0JVt<41YzO&r}q zT!7MR^`lGj_H2cM{eQw(WwP>5G3O-`B~lPMh=#>@SVF~6J#+s%rOSCD=DsWEj@5(4 zfC5l}gcNbEYPnPzhQs6FB!1k4%f+#eM3<5CBBSa6#mdq9Iz zahQgPrpz>jtt{;j2o9Jgrh{T3U`f)KeRcq-BT$VXjUYe}MkTdPvnnkKW`LB_GzLJ3 zL_z0>;}UQeCNLI=X8>bKx*+G=a*l&E3lWA0LI|}2!JQJ+<)A1Q_t}GsuhBOjxG9(G zGcR&LBA|mHy$B}cKKKQ%^Ke_QBE0#T;HAe04&y8~LY5=}f`l5=B)}{N7_w}sQb!S% z7}Zx4QD8<8kcb8(G&h}3=ODd=dbF;}I3)fhTDkvV)$j6N^)tJs&a}nUJ1Wjs}CyF&_df#CIx~c)7}gm*<=C))QlxMg+!~Bpy;` z0#Pi1F(jHHCMrClzzd8C5ZZ>Au*FUkkj}(P~_|AX?<-9O2DDy1I7Yl&JD8oh~zkER4MNu>wr_40*6lVt(f4UW{BXa<^ zjDGSSJEecDg&j+V2Loh9BwQC5X+V62HH;gv0qp!dqsx3SzHpEpQ=ZnAla}wr4xKyu zhUkCBPSJ>hG-S;V8>~307|87F-~J*4^lZpWdUCC;{$Cwv@iW>|=zBh&c`MNqh(!7n z2vZQ{AV{{Ft1>};HS=qWgv9q>;oS=oq0kH01r?D}z!QM)o<|8DK26jJ5ZDkTv=T~4 zyQ_#Y35a8T$B>JJa$m%=l0muf2-M|-buL@maF(mrbz;+aFq% zef7i3(nknnW=R&a1_+T=7SM3x{}j?-3@J;lfQXf?C6#7LeCNk|p54*J$P(2A zKRoP8(EkhE!vG2OoHJ|OE#?$#e*;C;C2IZ<1CEgD-#Z`N zxI}viX5xk0PiA9Zn*Q|A@JqQ*_YJ>H<^<)JyXVT~S1yv$$Sa@BmrAeF=Ju6SMc2XW z^9K+BzjyG4&XqHdw{?f^UYM3H6?tZ*OZbYu_Q?Mi|6AO*{NtwbbouE2h^Xa3j+|V! z20`Dw3j}Gn=Xlzla{4^Fr(k<;w&p&GmJAHYo86+DrQ!$KH+4NT@Y89b@4>g_ALKry zccKR$y6Tqv!~VZS4+vjd)bRIn9?nh0Zaf?xioBkJsmOJNGBp*TC_Ih4V@oiS23meh zh8E@@bxhRiL3^3&!m&qOUkQs7Rd`E%TJk8WYIyw7plDDqq#YIYJ?4WoleMdn$R1zw z*R`iZkt5E(a!Ewl)n!%m;Og4@fIv%oXIIzXgdm?@m7Zif*ZxPv&(_Ws<&L$)TN>@_ zzpKn6*KOlK`cmndG_3PU;N@7SBw;*RY)~6dzx{Bf@qAM1GcuFFZS)P+`;CFFMwc;H zWw?#2vU|flA!0XJ^>K4uGCsTM@2U4~oll7UTh;+da?3s= z620!4si_A;=CJFT5s2P6Sb(nWZFl6tU}^t?}(5eKoiDf7-BaZ|wu6n7#dLdefd?Rf+EfJB8d{Or9tA zQu0iz7@~mA$`gWosvW95-LTKP;$8c}H}SlVbnzhy-c&R?y1@W>ODXX;U1klWDWo@D z^virEW;&)bvG2{3CXZOKVG8N8?Iuo_f;bcoJvSTll2)tHC-bD?(<2kQm;yPP9Ztwy zi_laY=AikGp650@KN8iLStAR~*h~Vmq_*rZkKAvJAMAg+srta*Uza(^s1mIQ*$=h? z2a2+Fcn?~8bQshD-O(R2I>(Pg!k!G4V?F-k)EgGi3CT$o^viI5feRoN3=vx7Bmyb9Oigh?&;S=b z<%N(`Tnw#w-UyvU%{87#n-Vor*2GD%i40R^Q>gLu7e=S@bU9jQ%ROjS?bYqB^x*0K zjmD!>c{2j1N@1|YvJw^3SrL%Z@>Xa5=;4T7&=ujXcVfY|goR0v# z#1?)en7-fgrugiiW})=#HW`^evyE1KXOTCDC1*)>fB(60BsN24v*&4Xr9%G_MK01i zuW!uLM^_qi!jtZ7IH;y=!(Ke>^YL$>(bD)@0|r2cs?QHo9*s~=5AtBK4|wnC+Gw%^t4aG%=qN&oT4FgO#%}tX2IJG zd}V{0kj5UGaYM%7Q-Dm_$c#^EM+#pnt@7y#G(I)ZK%Iw^``SqVFX`9-r^GexHJy;V z)l`w|wxdjj?&`BkPNTh8^eKRsTy;~lYWlwRO@*GXIo97rgM;a-uaU0ZaYw3m*#2w1 zf5r2;HZZ_(ZCj3_)RXH4m-#i1Hw#3Og+>OCMLcIM^RN?Gng z5@z;DB94&_0gpDB?qcWDIpV_f=`;?W$Y-ICJ{?cvn6H3~i={kr!ORy#0N^laUtgA7 zc;_*OVQFn^Z}8`Kzma#a~-(D*;=|0vBU^ug#3| zHk)m+QXWHZ?G40gtc8q=fmDs97-W?6aL^|!CjoBy^!cVoUO2X zpu|Pac%_)1Pk2+2$cpx4*&B(gYZ@1u?*~S-{Si2!G9QCuh%Sp8vw;wx05>E;QJJuS zFk%tPDG@55Y1HEfBEW-+$XiP#z8N|z&O&8MoMZkh$+2&C_i_=@N!ngyxR~f56E*kC zs-vf`58wO#{H@V+)Nv)eEPPEf({_cq;-9kib<|f^D>Y+pj;&tbZHtA0B^8T^j*@|( zq5#86B+xOD$(00Ya9v%ul4sW%_99PbJQpiMW(WdYiq*kjY&SpGBL3jnDGE4)|*DfBsPL295!Qv%D|;xU_{f%0s)Al zPxG`NtJ#g?h=7F#phrQ-g9T~`T$^WCX2UJp=VOt^th=p>0;-4aO zi6F&PjOeSd;_yWr!%UAns_}c6zX|}okPCUyM!8uS68~$OW5b2<}jmw zniX$Q(syGl*}LYJjScrc(P_1FXkBFVaF6f_{@%gnWAfkQOEdUB-P6fk_hn-^w-=hQ zG&n&KlTE&L(VEtue{kcq`M>z(hHPDbs)dBJ>BxWop?YzB=4olqGEHq%Np(KFzgBm? ze5OV_wVroQ*~8yoP}vRX(#2JVlYnQ~aSFWp4!ar~8t~|K9wlHQG6IAt1n?O{K}6SR z>E3l6#U1!;Y1N1Y4INr6uy|B73vfe2G6l%vhdTaDcAMDv4h;;g1rpoem+^{yjW8+} zZ%kH_oe4ogVnAE<4e$4)M2=FaI_X)BxwKZ_4J87f+BY>OK7c|=i`@ZFFUR9e%!ie| zP1%%56Kp_%=H)Nuf&gIyRltX`3}yf}c`y#k)Q!j^Uz;n(Q| zc-8?U4FSIYtj%Jxkr3%?K zrl)0cZGA)K)XgdE_7?x3(Lx$&mL=J47$X5|z7K_L#OPtqGDEoyCbn+ID<|IDfV>FcgN=j_z4%1LImdXD3C^76DZuA9)8BS5dz-a1 z3EFolh!XhekoRE&@YX-Ktu zMP3sGyUrM9SgtRH7d&tK{^ z!kza~R!^3SUd%jThKC>5B^Ao6c&F*ds?TtKUF@Nzshn=YYt6rgjz_*emOgy;wYfcw z^WVcWr|>t*0Ju}Oe(5c&3@p{W#wto1fdT`k_QNeg9ku> zxBwr`yx03ffa5c-*(ZWwwo-!AhFtr2xIt165kX7z5kj6YrYW=W-8}YmUJ$jF8xnDg!9j7c|-{V}~fgcZbd8Gw} zj=lEf5feH2N^oz!@W`8HE)Dn_1}q=2yY=C9etpE%zf|)Y6E{N|ZsBWB`?c#kgW&ct z1^ut&e%^oP4U+Hi;hh=Rr8-l0y?TDpRyMXbaSm1Epp57E*BeHQr2_wZM@y0nTkkhq zt+8lam~Va#G|xl|*iB1bG)1~ScT-8tmYwiAn(uD&?Yc$(;q;T!kO=XbnHHx1&M!y! zIAqj}I80=^(1LGOA7qOVV5`k{d$W$_rNi&q^3^zC+M^xPkVC2q;78er$+=9~nDAdg zo)1JDF9um=Wahc!d^^uMUNDG@la7ni^fE_rZ5q};@R(n&t(N&6Y-(qI=p=^}-#t!zW#xg0p>W*UxOG5{u_jL3sA0>LCIB;{bfLAcc^*t4`a9ZZh(Vq#>adE`&z~;v*$r? zasIeZC<@X6Pxkh;pq?H2VUeKAi}xe$@?AlbXS|Iodqlsy)tzR0s$W&i2je-Jqr>rd zDX-;k$UA!)Hj=7fjw4*l&>#Q=7mIFQbLT2x@NF7iK`?;+9j@{2h~R-8%Q+jJ>6fE# zUbHoR(>xZm zv@(6m7*eb!EFLxsBbNP5Kh4iaGMdKcH~H1@L1U&j-R1<=Cpeaf@BBM)X)fd*omtV1`(e#Y?z`x1)H3XewgsEqfpunm`Mfe%(J zj`j_ZQh^Db`u~mH9N?G|HxVA^3>-1 z5vO$J@lO7%bQJKvRqF#7H`WOTuO@o06O<=hg=_%D+Z2{BVZr-VA309G5c!zI93Gq* z45St#JjwT)K_BzuZom|?cugSjf4m=EYL3DmjX(q#TyJ_SD$WTlWO5Hi0X*8*ivan8 z0)DunP0crc00f`_2Qbhs)|z2?`me<_9V+QRz!Tr#KmC7rAD8|@ z2oHGDenAP}{rn~1Vk%Rcw$sIm=`Gs~*Q{Fd=Uvx^v>STxch>~jo;prH6cg`o!(v?Ho4`bw_+M>H z)uC$|z514}-|!4Zd3M@cft@vt-oMXcp6*Jr*tKid?|{0Sy<)ez+p%J=m1SSBd;4Z&oe#?6qD;*4OTCmA8(dB6=ElGO~!h5Qe9Db|ld8fT>P z;?_WDs#19GK+TGxqV0!hoTmK(tT645vmsk@}j0(`)8RKHyLT_e_iL04aS$&g!7V#VhUfU;^TZ+~36|+#X za^&rJ$mwFlfy03}+R#{s4TB2VEWp}&5>q?o$ELaolGm%-bC#>9K2TBqCQRH-;3xSn zF@C!EtSA*yCk-#du=TVRTgfd8&LV%$UhBQSb@kCtZCBK@`7;H`^)JHxC%B2q18@&@ zhsStopEu7dyvcj#9rFd>^5gzj?nNq4*@YKm!MLEMm&qL9-dCza-}`!8UTaK_U(u!j zNHW0mMu?TzulGLKr%Jq+?tLW46G}LU86-UI)xKAfimWU?C_yD?&tsA&Z7anfr4C~@ ztFF}J__&P9WV&-C4>qqbIg<0vMjqtF9w>-HO5u{E=*~%@M(e87YvS%mujXElXId4! znl07;>jY$3Hb8dE^QKeH?^GpG^xzA6xAsmi3)FIA_Do79V0Q!5N!`jCnDm4i77&e3 zC5kf7MX4-+Oyr?BUEwC_LsGpeR-=tacc*M(0IfCJ;B&N9Th-pikLXvO^-H?0JN{75 z^!kQ3eb)L825b-p>k|#lu(t@Hbo#mGm`)eH%wC<^r8e|-oTJ;f&+a)a6URoGJXWw- z>M8r4wJ(5UT;Mk6vAQt)XNc`}=-Zjxtm0p#sI1wE}hE%&V=0)kmL= zoh&g~P8@dJ`toacqE7Ni^3AEvfwEN_xzkBHK%ksPRu>%c9N1V-8@baKbCD1_hZrGz zA^nk{Ja^H9`KfrDYW9k!@mvQbB+50j7fc{M3zr8Z8@t@hA-}LX$Sj==MySfKcVhy* zb>`T;BgnMJ^TfCF6wJMD{5fvgEx6U-x7@xv@t5w-JzczcV1Vf&+I#XSG?}I16?Jbd z#m+?4GaAzO^IV48aPDjojs$$gY_TNWsrBJHb-)h{6ZX80jClyoaGmjHJ*Rc9xPKn7KkkpSu_JSAQF3y`vRkS-S!#af?ep8r_j&W#b5&Qr~E3_7WRES1wI z9r8oTf>2pr2Z5s)nt)ep`bVO?2vs`SOs!nP?g9Y>CmybK)a?xGAAkVss_CsTcs5Rm=ht zE-y%z$q`84c^42r#fj~s#S}ntVBstc?31Ngytq?kw`1P%Sj;lvdZMTa#)xAhJhY-! z7G;;%FNj!ATf@@hq46u2&0i{-ij!P*YNzJ6l$xbHz#Y3&@kf$$@vxqK`1NqC7N0+h zcpbmRHuOvoPytGqQl54AL!Y5D9#ld^S{B=b0+hn@=OWQrqg0>i5`RQXx_T({E(I9d zZ?`sAO3)o9F~bz(TX<$&sj0tED%$>~<$ZAtNz*18aZ5cf8M_L|I7}9XTRh^m__=GT z$`T<=p&%+ZzUHKKN=_Lq)=KN5Sf_HE?RKdl_qx=wV=wNabMFUvQ?}sX(=W{!`zYNV zdGDKs(?kJNzT;+UCboyzh}0xX4j0p!t!$@*mD6YfsE zB?^iSGDjLmcg{v!iGl}+k6U@GDzGY4p%@%eLOUJK;IhKX9dTXgLO(=of!Mt4;?<;^ zvx1VP!^qV@$0}w|bosl_YWuUdS0Z#VE7seKw|Q@4PK3o-#SfV`17=a9w~ny%63c86 zZWA}6F|o;pj^qW1!>cCq9Z9WZmx77ebE!>YGdumO+2EDWXG@(cDz<|j45r5l5O;b| z4<1yRO3fz@t(|q_r)H{DsL5EDm9jZh_PlZ&;Dwu^g00OX>W;2N))n})MPoLkD||)1 zUB!TkYk#rma~wpwVTaz zQFQd~ec{_}qqI_T$(J$gsdIL;TsM^3)yb<u7)pHsu zZKP*|{6I1FT;(JD5C`QMosanf=AJvO2W{vk(P7L>&5tF_J$I1>4JZCiq8cH{61r!t z!hQD2%fxqFSF$l*4MyLe`(Ge{^#-nwhiJSzZrZ2Wj4{MuPgdL@{S46*n(A7={x{jE zK-22^EiXzR8n{h7Xw%pCwYl_?2ih!~olT!^%Wv(17S^ZZH~SW>VQI;{_cYV9MStW! z(Nfwc1z$~Oa)1~4_W6BUv6uOJ)BQCvom7;)W3VVevn{x7+qP|=ZQHi(eYS1ewr$(C zZCmer_rAFi^J8M(%joET9a)(bk*Kb(d*HxI8cox@WT#{fa|^PfHn!Ge&yp9X+36S zI}6vX@*Y%xTTq(gVSHA7vI%@CpdI{C1w_ZKh;+szxab{JpK$kN6~$FXmPWp`V)o!L zBvn+iA8~*+Ps0-lK!=CbRu$n-d}(v20j~tCoIwRy_B!j;Ib?8tU&Az@U2322O=Lav zJGhhjhCaL}`@dRyXtBj@)Sr+L9hd7lcfJj)mBpITW^o{L@C}JsOLua=Eq^pt7c;( zHXCS$6&tD$pRNlJVba}AQf-Z;$2YKT`|$ltx)?m^$bBrYfgCe=WOyVkK8|WQZ#qF9 zE*fe9D%&oq&JbHu)raivuJ|%Vd!I$b1}|D1UI+YULL)Jl^x|d6rnveU2QM1rEcv)R zMb}zr(O?{qS_M3y_~hZuSzm9VKv3)rr1q2!#W)=U7^O^Xi;s`-!T7C~MPATG|r#}f(<<$+`$9QO67 z%I8|PGG9}V0f%7|(1FqKbxhmclRpEGDAWS@Ss7A`&`+6C9Flg@MB$c8P*sqBOmf^Y z(TUojT9{72LPNXH-^lRtwYjaw;B}czf&zSNZ9$9BUgDKrR+i5to4zPKf{ebUD)*Yr zJ~sJ?3_E=$Af8RTBWInxE9}_Rf$W22>K7E5*-wbaPs%lBXalj4GrwFV`+=`XA55)) zQioRdeLs6a;IX5$Xh8L|=WYVotOO^5*;YZ?ipS0QY7purRjf8-Q64f+q{R5s`x-*8uW9xvLPr1xuBQasu%b1jUcC4BAdVS zHLd0t=&B*E-rwWy6+M%WZ@w)0XnR4atRPwC47HlpgVWBfo`c0N+R?|B`I;%Z_|?(DUdpp>(BaIh zEvkxl)+Afst2Wu!bOC;8W<7HTV8>pSGXmw5tfjxa5oN9p~(sJikiAIm~DC)jT}b)m!6f>>FCvP3(Q+W zrg+2euvK>-w^za2vunLFw2+6xYar&W{bo9Q3v!51?AY#foSv&ZA&tqpDn^HV)o zl6y$|#?ON}m}rT6hfW%N&7`%MnFm)XPT;Vt_{P)Nr;{6^HU+?(vcOiNlLEMH6`}jb z8$7)7zFy$P>8!&+=i}(>bh@l)(3O4w8yRs|06hKPpwZMnhJc&EAzFxbK=7J=|H!nW zE<)}}qBqpbihIi@EP`D#vc{$n{_1mktk1Jh6QLEIBuP;tIy*nrSY|02^j(%Gyir|a zmLrYtIcitdWRBhN-*<4qo#crUF{z=G-U#e@isCIpKuIOtc?+`CUA|o(Way7u&?a+j zc@Z4k4PpIF$MwPA9Cv2M9}8T<_uLs*59A;I25!pdQBsb)A><{_s&DhJuKC3By(Zvo zZT}@~nad`oXB1kPTw`Wo7jf@XT?_QCiH$L1RV7Wp(#3@SIz@!WCWVD#T-m;GBl`?c ziEsVnZUzH(X;U^u=(jO4gms7m=|2@fdJ-0AapQq$;ZY#6z6|i!Q-YsSvXZnB!g1u8 z3l-r5GFt9!-9ZG`tJ=9m73@$bhiKC0w?^|HiHOa+N0sa2gUk4%5SDUh17}npB-SCrD{*tE z)I%|4m^b1jRt=L-3mS8R&;B_(`wqLfrrw z2tv0&&piP=Z+pRlU%dB>phs`3(k4^~=A|RPIxAX&f?x1AT%k$I?sNl_B{mr>Qa_s1 zJq1eBjT=r0(d^Oi6Y!s|V>o!@T)Ae46chn>Isq9+aJoiCiZ2DR5yPvr_<$BC@yKhF zNa-CpAV(15gueJpAP~2ry$hJ%h`Cv~$wbx%-$wXEX1F=<8lE zvy)u3tA~2G!iZv)PcUYcFn*&;i`KIPJfOkhFCrOdfRs2RqY;0?-7=*~UKVAIE}-f741e)4F75$=nm{q{B_#Se`k$sHdHs z?Y7u)r!jksdrjjl6wZ|C^P=ZP7*%9G$^AVF!A_Y3Ga!(_O*!s{^CI{VMn*XP6I_0! z*3gq&tD?Y!-^fa*lUu+Z9awa|DZh=b^G5QU<1{9|us9=t5g|9p_EjpK6%&(+Bb#Gt z)w5Di^wVicMB)^%og`eO0Jo2JnJKpf2njAgD}*Gf#+9FSs)B$NkPgBKQj2laZ8;Z8|9Y>&0tEg%_3M$(Ba>v1VNuFRW z=tg=t0pUr%E^8#%S?nmRy{fhMc`>_3y8KGQn`GkY76KTlPSK(S`JF%SO`83HQ z@%&IGeUTCCL&7Q%7Y;+YsO(Ely!Fc09Z$oVGa*{4>f0K-5U%Cj0@)IZowlKwnNR4? z_8ae3^5|Dd!dhOoRIkDr0>MX=%IJsPojw4u$c+&=jv!%M=v|I>Pk3+rqX3YqnZK5S zGV$wsiUq$pxsZ&5#dw8!lU~WsyH^o7Kz>Ol) zD=PRpCVC2qA~eu!Ok)X#c%?zGx{s0#HWp1sN>oRUo{F$c!$08;XbDl*Ne^z7eeXe} zAlsbF%eg{QGdnTF?2nFC-X#4GMcxQcn^%b8j+NOI6*hvY@^n0dxP2A(ua$twNr$sT zWQ?IXwUB|L!kGaggUw!1awxUss6d!MqUQmBU^WSzGq{i;k z;t3gko^13|+U&FfF}0>z@AWSG3M|3orx}MlY1-a$*+q)x@;937RB63DmmLt=j2;~a zJ!?uKq|P1~O(&dZyW?YdUvv4NIR1Fk`Gm&%ikk=S*!!*bMJz$8z?O^nXIu9}B6frX z_L1-ofat&hPGW(2gimh%2$L7 z)C%K=lSZj}+@sh7t8Jm05Cj%*H%(JJR>!sdio(KN0A)^nR_)fD<8!=CU~j|H+7*S) zg*-Y6Vv#WF69CFx1Emz+Wf9u{h346rnw@f+_}ug3hUCy6;N@CQIunty;6DP zNs<*XUL?3lbKfEC9m-5$E-wjIsB`349ls=K20msDMr-WwH%vy?l2;0A%P6JqJS$#Zj97mQi5vRomYyc?b zaCzrn+c&2mPq7kO0WAAgcNSl=KJX`JG1Igp;R&fSQQ7iwrlm^y*9==VZ3~8tHE-Qp zw;mmave#H51TZpS`2K{RNmL=UjFDWy68R(_4;`NYy-*wqOXLI3;Zebrrh3QOB-bOU zl0{#XTy#}`;5nPK*JSjvR%())scmbgh#7=BgBr`DR9rvCxMZA!(Ogk|55$%O$;K_| z^>&5yUwLo%%Nx_jiS}@Q-A2RvQC8uv! z84YZ^%QIiIsxqfkmLJKI(~f9OphJ_X)@XX79^eh4BO`NNkiS6DE2lC|>andT#yYy+ zyx9TE2DWut9K79(3LCt-JFOj~2u9O4%gh1jZeyg&J#`jA(9O3v+dJQ8sTns@sC3W; zDBPXo(D_b#0mq49^3?4lik&Gk1NpQjwtp3AK&ijE>vgwNK5*gPXx_QWOlj`9Cu|RI zF;q5>Xwy^gCE2pm=%?+7W3?Bewt=kNoi38x3sV(t+|~(b4m)wc#5TpL5}%3k0{C7v zFgV$=OqcT~u1&GKd!QQ!2puym&YehLp|8+?Ey877Ha7_*qe zgBdS@KgeFORd)x?knWBW62l2eQr@tkMwApg%T7TGloUC0%EZ)Z=KVw?$tLC!h{GL? zPwkzOdU|;Oz-lK9P5~t#8Ezaq#f7sRDDsC3x!wt$!96a_wh{31??;TmuMs@(wuJo+ zMjW7H7y;$}18n&YB4d0?tQ;rBCy>BLmh0J(uoffhFxukmhq*cD+K{k$Cd2Dzb=d*9 z_~INRx;c-SVXfIYE-6%!2ax)0PE_l(X1u>bQbe1D1xmI#FRse`zEahqGF;rW#dgm! zVB_e=cRYS&2B$D4wN~!+$9n)-+TK(;Via(+ATo|##qJ$l$!3thAd(>+1SZD^Nsfyv zC-><>v$K7&<0W~Dvb^%f6}uDqKnMggaRN8N-TGMTmoQp`C0-jx>6gQ^J&m~ zSenCQTJ#5|jIaZ8W4Q5AE>aM46nJbshx8e4&Pb2XUz)7TqH2(f1+b2}a@EQ55%;^9 zUYu9KJ18Md4AOt-f<#i50={7K<4VDoJU*xM_Z896Er-8EEcOsG8UjQvUOYlZ&X&8u z*JU{xkqsct%nCfnVIE~>a99;pRDok+CxoA@h>$W%GdV+N^)K;7ynp1~z%CB{ehF8; ztdD-^#kMX_h0=LaY>7g%yU_bQX6V6@9wnF-n`SRgP{;m6#)t0BV`R;%Ho&dW2;@@4HGqFP4k=N42|m^@RXpD$ zsvoC;uBSgFP78)J#IP~Ol4ODk$n7)5S&I?|9aIp^^Dn&R*zxsDe*6jSwa@Dfe>wup zfuHET!y{d9NeWt}sHHP=x<^6f5jAm9zgD^&lWx@WE}W2`VNFtU81TuVll9N;323#z zscWEMN)J{`1e@QQ_OV}&WN9PUv)axpty7vs(p;KA7y`2a^w$i0^;d>F+Fryx33j@W z=c-3a4z)bVYtr|g{5CWP&L-n|BRiv?vzWeK$@#9%1HQJ{MH8#J4R0Rlk?hw?vL*oI z_~I2q-|$l2)e57d&kf$|OW}*N7El7re~;G<9X_>b!We3pgM6$u|HFOXL+d+pB}AS) zVgBb6IFYxS`bIt+ZpcM+dk!L%FRnQs82q<<`4Gt9u5+V_ca1^ivNIUN+5EC7?+`uL z_Xj9mH~MdCSr4&>F^$4ejFe=#C5*p-#5jgEp#Zk;9%Wu(@u*QF*zirX?CU8dC^t#A z8XEFYUy~NZ0d^QZ1PFazB;w<}^`g>yggF-N1q-fkQyXUcngmLeo66c4Mm;oAy*S$%b;WyU?DfXl#>lgg{JMZwP9}k>HaAom6a$d!`TP z0xHZxoxmK{Q(!S;+c9vTTm1Jt;b_hK9e85D_WDA>Gf`(GK92tz$cA zWxko~L)TXZEMl&?IL_Xl+5qG52BZU814oF&UF1*nSjMK10vLtEQb9?vHoOkL;@}E+ zT_Iv@nN8f7uCkSK9FVz*=JQzj*w~vBe-O5Qk|48$-d+TRVUw_3-|!8leTEA0)VqBh z%O7HUp+D)6*sB=;B+Ysf8W7&J`U?gS=q+~t%60&Yfwhn(1a#W) zpe*jQV<8h0TwIzG;!?{?K9#v;v8F2~yR@gq>XwP=aUvmfb|m*E17%e5?hVJiq(tEA z3mZ!li6t&yoJ16c1i7Tr=$^bS$5`b5>VaYoYt#YtpkzeM&$;MdN$L)1L^TQa&{BCD z4Mbup^9{s3ekfChesc@1XjU@@c=xTYB>s-m!=ZF>NLBkZzBvr&2J&8bZ2Wu2Sp<)e z*=92qj#I)%rjtE=;JC32Q*yrN2_UaWUyNE{!6?nCfor!Zw ziq1Fzf{it1DDP(KS-UhRYJzfkZzqx&hf|HR#HIRGm42)4;N{|?H96zpR@A7iRNv&b zno%>H<3r4kMrFmU7fHv5u4r*lnqK`r&Z%C4gl&(3JGt;p1B}$W^Y~_D!FHY|7YdR- zV`90WMvwsmV3-z$5gD9zin=$7PZ8@J>Xy$Fe#s;$v`J`n+nPze2y~(9obT? ze-pLEpw@J+A*LdW#Dx)Xo>QdmFx73n%+aOloUzAJja>$?LAbssqVJu@vd3{&Jv93WhL=w|19q`w>W((07IsLSQ zR^`UDq{?l^FWP1HK945rF^19S?fHF+d<%4hBu;&MEBZ-k+N7X5ueI1*>6T}9jbICY zw(gHI(0LZAk-dme!Ly})>WH%;SAqJ+ZvE0{0(uXZrViEE-)1#GCXd9w7UFumwC&pB zdM;v06F!C=0Pxt3IYMoZoS4(h>ZgamzvnrGbFu1_SS_Ww?I`o!j2&DsBpqbKX z66c@!ir5jrLPV&MRS&l#je?$w&Klw26G}-~RW)cei!!K@J3*nF3(dX=!c3^9Fsy<6 z2vGsoR-qJ+4%I6w+@HpztFLe^_?vAZ*hO93FL6pIpbpW<2)n_|7c zS`_`&Fk!@K#8Lj{LSL1&tPGVEx1t-%51aK7wjlY+70~F%dm@27h?p>*mxRe*A+;#^qpSF{UTg=1 zKym&7NCXG7SliAEBAQU@fDQp6Vn|Ym`_dss_@y&nx~G`YA$o%?#>C$m-o&WAPDL>2 zu$4BoLi5%($+J*SnYIi{FkB{KWd$&oh?Fj+HuMYKVtv9e+yFhKS{hlnu;e<|<~_!BJ<1#l1I28k5LGF95wXP7Lbx4^>6Ljih0k$M2+ zgY`|mToz803l;-O?s@cd5z)s7Zf!pnpD7enQW8X7lmSR9eN;orRP7uWW>zI(&xLAc zMqviA0JW!Y3~^FQIHYH1m8^r(+V4rd7Cf8Daz~e9%6GiMF>3L1g8x&-pQ-)u0hd=F zH%9)ZYX=LX5kyI-g%9+&pM?wt!K* zf&eG7s(9xZhZsLO=L96Y!mqAjPF^q~SJ{4Wpq!bUQUKjXDW9-1Bq`Fvc%)tGUc9&z zCWL{jpzpg3(%BGVVi2uBzYakz4;ynRER5M=XRs|50qMgVHT%hdfqRFf9!niIiX*e# zy4}{$^H$m=Ssw_LXGcL`{Jutx8&P4?eY`kxr<8sDLvU~^0T|*@ac`PvqBdf$zR#Dg z^nuPGACMm%F%5EDwgGRUoqs%ugD9YdWi?1?gU#2J-QVfht10BeeKY*vPx6}(7u!d29|s2EF|2-0}xCCR+~$xfqq`NY(6#eNy0Db-OEs%&Ck zuz0=q34ElZu|xEpC$D=zImT3`iYfs{>}JUX4<#*68y=tbaa!6>&=8_7>i$#1tUW; z7&rz9=NI1M{~gMu+5v9Qr`r4cIX>Ni;JZKSyg zwLqbFN|0_dEoxS=FV_|RM4Hg4IyuO&el&y*Q>0r!4|Qz_@C2<55WNom<$t8A7$27Q zwN5oNz*-~!j^}p>tg{3BwQ8hy%7xahLf#J6{+Q6S17@8`zG)RpE!8FcRT9RsXwRtQ z0adCrn}0HKXSUjq+YtGJ9vCfDYSeTg?9)Z3*7S!1dK!%3N}wQ=bLAm7;Abd=&mXYU zOhx7-`T+P&VZ6 za(v7MQ+=8y`01abyqDvQ;p51yZjdeN66_MBNAIfIXd@{~>jkxj7A9d)-KHt%TGJ|p zi6*CPb%d;Tg9?T&v9u16CD*yzQ-6jA&)E7}c@FTUSJT6crq)=TtN0Gq=oE#6qotTC zC&=PJhSr0@@B-;3mLFrVK3;tr$t5Duu;p%AHgBARvZ#o7A(lIQ`%PW*cv~?%L=_%J z>-Y4Wbht;iHY#icME^O0AbesZI>h(Y67w+z z&BAb`z;%i2G~5X5n7_^{FQklU#JWsVc!wU$IwLL)k@Mif4lfPcI0hB$F6`znrpSXv zmE+rOTm6LcU2l1zkYM}>iW^~l&@V5>(kx}P*y$*YA z3E8I18yB-ojU#P>7{kf-do-qJiEUfkmyk*7^G9ORoX-)xp$xedBcTWPxi9jCTH8Hd z2$LFiWYL|#Ti1CePwItn?%211qu+^DX%L&GdBN4g_EzH{%fZ4x8UDCACOD8<=E*>9 zl4e^K`|ldTUpixVEqN{p>5;itbEr>4Z3`H3dsn0fWYszvH;*u_sZ!?$O3$cwBV~B= zME{!U)t8fCrpFq0j0mNIkk%lcjSQBi0`-**^WD)&xXmPU;G2UcYOl>)Gn!!!J{GvER;O zEEQPmZgNzU-^RFZkQKZ;qt& zMKW3iw{uSYZa9%&OG=Kx1G%Tq%1ap+bMK^USlDZ$dt-p>K zxC!6oalI?bsjs26%XLkG8C)`zE92H~N=Bl#Xm1aKrMk{%bL-hHlbVmf6t{KW=vi7F z0{^G$ zxY$)CCb!J%bU^d}5+r@R6&J?90^HQgu+`Hof*Je1Zd+^4T5~N{$?$MMS$ew~8L`-_ z&8pca7{(Uyq4g$TbELCw^EaQL$hC&Yd}@c~_BQ~pIZVY0Eo=J;gPCd<)xsptwK5mc ziZv>v6VY2l@UO$t*7fLCBSK=xU`J0ZtubOjsTSj8{UFHwV^?A(?JK#jLsDBsKm&YJ zL-Mt(ZE(J>qV(^o{TWp>n!GWZLECp%TiNkhQYibmHdvb%>GnV3=?g5s8VEtzLOZ=J zDoYm)o8slX*_}JXF$5IN)CeZxcJ<29%P&DpIAIAty3Ud}bq z^^f024*|fYx%`2Z)vKCrbU{b#Io+KN=ys1Re>4**zKTftNxEgA5yR9!;=`Fr!fXN9 zM7hC7L3EVycmZZn4y!FuV6d=4fBtA?+hA8yZ@3pwIumX3f3)vKHq!^zQIN+cZt)4# zBr4`9wJuxxca7@2#O=7%D*U5gb3Z1*8NzK;lEeRV`dary(8*|fU?8MN+`M<7=bvXY z(SFG}XZ0aG%xay!`Ps@Dv$6TZ*=ibQspazz-c2`gJ;Xmp=0z6bDhq7U*AkZDmIt7D z_R%oIQ{s~;+Ua%TY9}ajGHtJ*Wx9+K%!X(4NR7+@qbCVd*{KK9lnJ4;p|fF2n$CxU zlB5s~6NT<3%gt0e&}0F(Ld|kJWf*PCG>4J@qb+uoc{xN=1k;wcea&sz1J-OVM7=R* z%MIZ`!XL+5RJUr?{%B`_De9-ml8ahfY3?TAE$dY?cH2q`|uxQGDud`zHZd}88hi$$wUx5XNU>(}a7{`FIP$pv+~ zA`QM~55img%k+6ecy|*%X(Vf--^>UUpb_V$ z89wKTAuoanwn5GlpsjEDLH6MS;J)@f!HTJD{6y*>HjsH&z|nl%Z@!f(^&M8|wIShy zQWxEtk@xFa?LUXCT-K#YIm#cjIyR*~O&Xr`)07E+vRSug*j@YwTAw?WIh$e>KZIlGCJ2$Z}^DbVi3gtk$R4&mZNcQkK4dlzVi>8 z?O;W*UO-c{!VybHM)aEF77$%foxmJTVnv|;J>E^m4&OQ)5Wo)-zd*OtxlqW*hkWI= zsiK+RRZLh~NRli^JAhd0&Y3&|=GaR$QMf>)43QQ|M)_xC$ z-?`h@Q)!=b0UOCk=5c1l#p>lcCM+!|Nt&k}L?WShs|Z|%ee`nUOYKIcw1o{KzU9ZLDz`V4YJGYnZ#K8`9{@nYeA^|FOfnmlGee=` zCiY+6CS998PUQB+(3>l7`VPfdC-Eb$JrwU`4GAR~zx4=8FUDi1kM$HQmu`*p@1DSD z>a>iX`loXBVbdFV>&g;r1SG09_d9K}p2~Bu?XHBK&fYCvJiMJDZuhxm-2YxeLr2pN zasBv~v7Q+a?=2KSP?kKr=CDJN$|7tBbN!}82`f2iOs3JBtz}+r8HCu_W$-o*JNB*j-8{KY$Me~8 z=`H|sq=FIadz2=2Y`f@ZW^&SKn<|##){)6}ntq_>9-TF59)}-zKf*U|X_^4zyDJS` zn~eh%2+(~^(3ea&sR0EW$7pXjM72KV|;3+m#bC{%rRYM}Z2&<0(H`cqE5J*yg9GiX< z(;QW(9s){C@p!2+2~6E|P$=RcmvUlSqycrR{GsRy0r+m)W}2HZpq&p~_SRwfSsPyh^pK7RltiadWzeB)iDW+|X{En7A- z+I9932n6wsN#$Sf4wn>Q1c6+PHZ3T01OXsW76$Ok&a^-@&d8YC3GQim_E&?H=0W18 zc9*dX`#nl5N0`9Utxf`&L!XC$)cI{6oFD{K76VG9EvMB}mU*hIZ58~+xqGy9;nv7z>*Zf`ksb~3nKc>XZqL#>`g35R@Hy1vpie-Gp6 z?@jbdmYqZ|VkJH@BSxE$W^3pj(c|Gvdj20Ekb-<8eM19uvwUq(QA<|?0Kjo5Y?}s; zh_0*J-33ZN9-w434|>4y_(bX&WHr+S2yR^z*G^BQree#~Eh`EEBPb6+6*{!;d;w^0 zih#q05e=w{`i8Hhly?0vdjF-1qZx-BD8DgFGtYC&D}kQi|Y`C~y-7lDJ5k zR2YadIM&XS1H;Eez*Lw!C44%oDM-jBo%}(>0}flfzU9$vQeZ=O1iYC8$8iAsoq$k; z{&8Kok#ier?}$!ah!M~?!(j;INpd+q2TA>-r3E?gbpQVYue1~GlA0ro1fQb};`vWh zcAffN6*}T$>M#Zr9+0SMlh;g<#P`=jpJjo-bZuJfYqB?{p~1?)h}F zXy=zcTvnfvHNM|)h$eP`bOaG|dZAp?98{K8abI>b#r0a@b3Wa#0=#YF+ZTL!Gx9Gr z>!f$s_e&vYsi`3Z5nrh(P8D)DD^86K8E(K>t)uO;QPJ=GeZNO1K!C*sMuzU>h@?^T zkI&FK0su-tW!o@*JaWYwG(*PJzHPW0Omb7=twnSoh>}IRCxlBjBmMN`rYb?3g0iCL zJDJbcbibEXAz4nkFB4)?jRoQ#I#Au)cf`fLK$FId&LzK8+VGGQwRfu&|?YX}&@?*K^RSuJ-a+ zl@<&?_;M9m=HE3fmO0T_vg;o@^_n5>g60jri z5<&k{dW9#DNX1jhbikxH7*EDa2}+1~%QZGd1D<$Iofx6@;tu&i@j!qovp7eUPSQ3u zfk)zi*qO3sgge$GH2y1=cCY^02tIix@!2YRk+7vxe-a4+3W%3Uxv*|SZ8xr0i4Okn zU7x10S)4j4TrXX@G7rz4VLx{@5kc~r`u0x_E zPoz$wY8I_rn+C? z`BwM~pOZ(<6N|aM;%YlHj4Tu)VzWAO*~<=P!zV4Mh5|$QlX9RUj_eEc#;d+Yu6jkm`hBkPr)%C9fqu6zi$?>un@GQC^1AEF=<< zOi`R)T&OmZryVS*Qn6gdYCdK!YNtkKGU04GG%;ek6Cfhh+{FNbf(Dlpxa`JI)PqX>Ng0c^HCVZ-xLFDLyVCeo`$jsb2auU&jmqrv*>A zeJH}t#>mQy9wA}~4gjJbB4QszVjhM%-jS(Ys9e#iiL1LXvEN)s4p<-v(uC~wj{t#! zIZR6%`t4Z`@V`b=V6T_A=RF=58jV(?#n46b(%V%Nx}}V%t$T#2Y*PIOP=C^|Y`s ztiV+H5^RFE)4LfjW+lU%;)->rncI3m{8NdID^7c0oVjtqn?1V^tV44Y_}$BW@*I8V z`kF1?a~3-9z3#+_dp5lU_o91D_u?bU{;VqN=2A6`cWv1-3f8ZFAqyp$QV$)F;<9DZ zU-68cv-KilO=P=)>9sC9y=CQQ`l8#4kA5TL)n<9SnaJw(n^Uvc3(F^)ac?@qetE?z zn~jQ>ECMlga~8u6 zJ^VSff3s@JS%3`;*bZqtW{U(mktr1YVLDgPsS$-?hXFDEN>L}r$6UwJ9-ksZq5 zp2#2ml}pgD4})_WCW`k;A(lP|LY{WFR3gVUxST+1Ki$^6I@5V0|J~vxcJ{EtVypJ@ zQ{LGTE4};d?d{ZV-G@y8X}vCrKGi%em~3OlaJ-4FI&9r|UTqeW{7nBcpbPss)1CgZ zZH3#DG>2M-b_TYdQkZ$X@E0;-t;_g%g};mWrK^%p_T}b1GvXfyY3s|Y|1A+-g^n0! zaD#fOG~MV`z-gu=sZb-ff^$V2=_w^m^E(5hGhcBmXD;LH;-_v3ELD3~G%Zyz&wy*P zDBf`9?BM%b~&gVAA`sX0dapwWYb*E|iy-w@Ihb=ex1ygX3MqqE-umbSkAiJ-dwy9Ip=Wtqc)A} zIn}B^c5C}!H~3KB98E+F;^Z)Km|{b5p5?h|TAF&xp=TIk#1K-sbd7w)QbrTEh&&9r zc!cT1%j+IKY!|W$gSgm#qRx%&p$6L##eWw9oyqAmYyd(ybg}=5PDmJnjQ>6_FRm`E zEVa{LOtImP$=>o%>ebQUCgWIa2JB0Rqn2N>UTZMw1q4VGZS{bDZt8l`hyna_GW(!Z z(6$R8t=ax>X57(OGC97gzo<+`v-z@VeoE|SmYn|12~$h7(M*>K^~&W)7xD9w)7<8m zp1ax}#T#KlF@$15#FBYX)j!?;RGS-H8%D4-Z(yBfxy5%%)%-8EzA;D^ZRxVD)3$A$ zwr$(CZQHhO+cr;kpSEq=n!fkFFJ@w9e$|hPsHoUGwbovl*>Ti?b=7vB^N#Cw5EO0d0I6VS^U`R9qg(NfjAGn1umY-Kt zpz?3Q!_?&TFoB*_DOWDhjM+>L27|$3KGB4s!s2p|O07|G7)+2yMF>5n7r5hhtk>MU zWUn1t;Rs|}Gwn;;0ERH7z0wwN9it+}VNn>pm75XmJUjIAF}?I_1Rbu0S~Ik}CkEYE zX=KSBseZ)J@o1taOb!4Z@hk||QxNz3<=2-*LAc&m@j$FsN;w1G8@XHKf% ze>024Bf|fnA|lX91t1!nz=&oE-A5Mmf$1u6&(HXfjlQh|8&JB7K2<3;uuR4vXaWRC z3XFCyPzj7lr>GZenQ>aq(d*qDBr%gb{GXfEQc;p-m6~+XnrSt~FE9%^bmLDl5?*u~ z92#oCS$5>xrXr#utIaEe4(>+~L&gjo-h~uGOcyAg#Q;bgKJwGT)x}3nkE9_^Q8Dej zmMng9uyb-boj}TYNGk1CzK-JL0e}xcK>o+%)WVYz$5AAbH3}C`ak8OB62YkeAb-5W2bX+Cbt_m^;uJAshhT-DAAk`_ zT-}pJOiuB`T;PL<2=0Z5g%@X}9{p9Il2C1Jb$Q_!SE!hc)^!mfj}=k|A{wpX+;+xn zwos%Vsban6e6nzuX2b1q`*6yt_ZObWU!O=M8iQ4TF3FtHWGcNjSh(1b(S!ylkB>Zn zo(4zT8(7ojV)2WC$G>net*J+c6!YR29db9NE>0$Ku1mz zQ@zr09?Qxlbe@e|DGek%0pEykLP$V_U-+LIWG-JZ)$oVZV>XzR{C=Csee+!<6-tAE zoN(nq5vzlfxrsnX#?Q~w*V|jOn?UM(;(Dy$q2GHm|i`LHjZ#zuz+D9E*`LSn@esL1Hp{zCcmYS=k^ z#PJkLnAw1IKc_l&cnc~J-@YPXU~YW9b8<14OvZIeA?sPWrG91f&+R=#dE>Dm{QtlL zTp|EI1hoG^&G((2upRur_MzU{(eCNy;lBSrb|QtE>3Xys!bgrMS;Ww;pzN%qgK=U1 ze_g6@1`u_%ELnHI8G|P7yM3I__PVSG=PT%4dhGpM7nL|ua(er)%RMP3AB+V)$go{N z5k|^Bs65s9znBI#M#h;342|qBEL~*Geh}UVi9`ama0SyTgOSRT10o70wH429e`G9v zJmc&&aMF&rcm#AjOi@BCVN$sQ=X^Je*x=P98wmtL!6Hj8>Gk%{N>8U~Mj%45FuHig z=xg8(a9YP;OV+Cm&$8u1Y9M&xF}XZ}Kv-Z@E4G^*BEB#PdPMjlv)TMnyAcm8I{s6> z1--tw+0EEGuU(p+zgEG)DX<~&ZqugF`=nAPP@y=EbH`s#vA*ZM_E^|(Sk|9+AmJ|w zm5^j&2wD~Ec zSNk6q1J-L;!H%2U{}n{&SRXtzH?h?}yIM>o>o%vD^Q6+FUD8(o|opKl)SMv%gY8GzPa%yC{*0nll@vY*27OI__2l78S&7QSK{%1kr_RmhNIo!dL$i? zz&}1hX^~-;Uzj@8H#XF@`2txZA6+R@&GCrIWHHwjyJ9~N3*+WIDW&j8yO+P5 z5p7GHnuhxN%9=Y8fXnu)25hiL`Q|Se#wpvF>jmHKcK!D zl-+8DIsL0hx^-wDj6oASXp+&i4ntv2$|bUBhfbE<^E%bt4q^79q zZ*sc-=7s;Su?iznS6|>@XKQ!d1(9(dn%gdN{_3Rz2+{-b9^*s%C+o}o2Hy9kGR5?O@FtEjv}!8vDcoXJML_|(Ln-#9LnVLE!|Q-0Oi-5}j!5xE~q1mYO|RAAnok^Q25n(63YViq?trmCV1^sIYj0wTKE#J=X0hh@f8gdXQy^Rgh-(Ezx>{2*k66wgW)q zqss^h%tB`x6&Kn40QhvMW)aW*%vPzFPr4sIO(>c>YtSfgi}lArq!k=_=B_+%ZV1TU zH;EC;({plTSsT!F?)Y|3mDASAAo>Lcwn^(Huz#3f1VKXPQ&EYO)NCM%{9;2ngnO+g zbN%y{M6se406ooFd~D!}w_|rr!Eu*d2U??8MA_r$-Vg7`8Q1meJTzItc(Ljhqv*a2 zte639d}0LtpiE(-pV#sOaZMuiF_|wVD7lWs^8pBDdy$fI%k!^9F`CoNWFKg{QGw{# z1AJam?X2GC%8~EKgTtE+RlN!!)SXg7<+@GiO|jei9OZXs6N6&lDqL8iSojIW*pEl;;k`E+;*ytB7> z0m~Hl?6Yelknz2@u^QG$+PaH5seJD)cTc4T+xAk+Z)vQrO!ST{En8WQiJzy=@`@6) zBCpPar(!66a)yNe$zoi1njSS-f;et6-i{VK7R8l$J&g1C`$3iqA_l0PU?6jUER6-o z-(-fU9U85>hA8VH7Ig(81Gd>r^5Xvh8)%%!sI92W7jnC9j&eKzoxd#W%kYrW$++JC zfa?0eRJ2=9QT64$2F=!P#W2|b-(4MA?HG%Gf0gb3K{VSQX$1D7-7oi%kf{^%5-(VN z5>)~oL0A@GztGyN#2xyi*t0mm zD@ZnwSO6@QH!(Mejs56JBWZe;+hO|ti<&@8>3DV4MALtyDX;Eim-<>9E)OSV6A(t! zW^|6;-RA0tVv-B3HJBcJ>A+^WN-U~RhA@W8y$IXO#uIV9rMK>lvRvmKo-4TlrP3tu zqNzveqV!MUsGZPs%g6%pfw@)(-7S9ZZx3FRqHSpvRc$NnO6B#8f`#r7NvFg1VU1TN z8_B$pWv1Si634NZ{WaXu*Q4!`P-TeKPBOUW698fZdrY;)1*&rCHRz+f+}n&#}JwNzBbJ1KHKw>xnU;`1i!lQ^klW zVJQfBl>M06Wz|di9r1Ujz^7+Uw7N{g(z?KmP-@|m(e^bE&5)hPpZS1ZzODFQE18N1 z+s}WbYNg)(I7oX*_Y5-1PW>s*5c^%1`ylqSZ!Gv#g30E^~u(RRJavU)|JRjRY4cwLIWBcOAbv^cR_hNq-6??%$eB zE%MTP=8Cp6Aa~+~++&d+TksFfZb4=iYB?9#t|4hd_AZDm94@!lm7tgvr{o%c*3XTGfoaS68Wbm|EWzmRY5H zwiF8NmfRAIJVI+R{E}}J1Cg{(Hr72u?Qe7H@80NSTNa7YhEZ;^PxxoqGpPx%@zx}* zI(^YT3o&CDnPm1h=U%#gWz$Or0w+FQk}}a%(4FVzXjZWg=7%G%kG7iftScewF7>_h zq3PAY5a^**3x`o=#>3jE?i^C{ahXJ6h7x}j`;%7{I{)md5f$HAZb1mGt3ReBBwm9{ zpQp7143`HNS=&y=jgNrjQnHWK|L-#Ff@?O7S zL&-tpMR}#rtliQG$VG@}V4XF5YIe`lwEiM+SnujJNjK($H$MQPydX_qVSN&1b;ZX={64JZfpafpDDTdQo-C?1`7 zaJ@Bjl{zMm+2ICv$FVRxzHuaMHuu|J-x%xk+Ga}$>M~zF--)(_cPs$4kU4jZIwq0g zlNu-PFl;>xw2uKTz*Pk030}6DxrrY-F*>x|G8;a|Ku%^JoXvvqd?U2JCYc?MyTWJ< z%6ONknm=(|e8;@H8|6&EmZLywowrYf%-STCSK5EX+t;XJn7<%fHNLJ?z4Z_6S5s$H z(jw)jnI^>{vxtzL?6+uu4xO}lpIrozW* z`ZlcCUoa}Bs`&b-sbY<0hIbBw@CB_P3HiUTXpzERFFHV1?n*{%4||Fr(kyV2F1iHS zXn={oR~?@91Pu+PSq)3qnzX0La(t`1>1|S>E4nMfM%sBs=wwmiPL&HPD2CeSiYIAL z$~4$@#Gjv?^dA$MYpc=LX@f{gYmxa_d5r8Ql%vw_H!Xy`JV<_8?La^yHn~st=@^5_ z^3J)9QL%I4dRMDD4qnWCHa2-G-g()4|;#XA_0Ny;_yU{h`^B$uj7Cxy;%Qb8uWSm@t&wz%rmq70mhr#onsS?^K6&y#7DCl0oo1EC0A*bNpElfZy!Q|j zCol`kCW@dbsymt*VvUI_2d%oCj?e}20_BEa_Fkp&cZ2YsCE^tx1tJM7Ll2iE6|e#% z5l7HO6~Pa-6&nWJ!i?<)%k9#~`Nzxs@Z<{^PMR1J)aB8VyB?RPsL#4>RKb`<`K2tA z9Kn4Uu~Xj-8e&*@Z_RITw1X%sia8JP#)Zrq4_$ z0+R0M$miOVlVYsz3aJ~rxmx1S{=+=60C+j>P;B?l{%-IVDxl`9rmeuJj+e~ggeQ4l z$~JmO+3e2+f80c2VSyKB*1Laet`^7;-aS@FFQX0M`bq@zKJiEJWz3g2&H#wv`k-cW zXAw=URMv`KB6Yqf4?7=jCF~F>rc!=!KLZfK{dyNq)KuVQcL(zktt&YAYA;B>iE+scGGzE5}ZEqPzG7wK41SpDS?EcE!w{?Qv(xo(iBN|tF5{+QKd z07akDT=*EuU@{CqlFGeXgJ7+P7ptjI*is*zY^CH|`WXiX*Tl_XnSQ#>#XINc7aS}p(7 zf%FPw0E3z_^x{P}xol!&aSWKbGieBGJ8K>XneoE=1cQhN$dlk&$>G4z;_W5oOUIqr zk~(60ZOC%6Y^Z;9qS(_CCUQGnuX$9N-nm!`RV;mLR)hLPZu4Gl4YP>7E7bZG9fyHF!^wsygDLO6{*T(j1p%-;bPk0A$1i3aO5Oi zN5XD{%8-ec_EoVJv2o62I8k0rNugzsH0og<1X0tl41~C`OgLgk`ATJMshPA@eL3vT zRVi39z3EsR<}N5p3$ryiOBwhdRJDM43erWZVhevO5Da%`1fugQ(e-hoYIpzULrgj@ z@#7p~0!bo9NR2(`VnM7e#DivqzXPJo@a6Q;T5+m!LKS25Sh7>LhHaMJd_lt-^xFoP zs5>h8DdgbZ!j|BLE-Ec7g#cJH?7GT5d-8m<@@miYWtT=B%Yu0Sh@Jm~?tz^ju$B zN7G5QWAK&JC=ok0W>ltDD>mq#F>MUx@k%QA(Idley($2eN%MZ56y6QS;2Eb3Pr=#AX)j zBD($(_P*stSu4E8)VP!qlE=SZFEU&Ep*AHTm95)5{khZT1#G|{J-o2aHO zFZc+Tmv)D2@`%i@8NIO_b)`G!yt%h2pF<;%w)Kj|ZObBn%}<&;k(snrSuUSc+} zm&=znW<0jl3`@c_V}$rb(Gvjej+6e6;MbeOn8 zAfXa<&@(T5vr9L8Gi%>}GXh0o>x!Ttxh`a6EBI9~0Im?RNi|n&n_;z@Nh^D6l2-0; z|A*0bkvi(rx_j+?o$KK%*rkt`hZ)wXaG$JLD`Ru8L;VSV^r-*d+8;5WMhJP+z-ZhX zUeX$mEZ_?Kq5f&m1 z*yt*Mvf(+F6AFx6o*{_kcPNvQ*@A731{v*Ji&y=5?WyYGgl=;HbgvWCF14xn#yR0Ya7~=s zkaG~#LZaK8bv4Bu@rnN_9YTaOORj;zOfV1A zDa`I=yUhDd8QY^cfpm7%V%B@JG(<^gGBpQ_pTP{^BS#^_wciln^{>0P=e>l2R{Ba| zG!Zenx>68^SPJ}A9Ly3e%rJ!OI$%SvM#C$%jr(iY4tf{Q?Dm&%#s(L%?Fg&kUFe>K zyfxg5=p$cqY}}q@kXoKCOd~pGP?=wA(1e{U)FV2Sk(S@&q4lbidh_SzD@|m;2CfG1 zme6(=e6&M3hXf5J8u(8HBALv`{6GPJg;8)32qBQ*G5+wWpm(|o$W{{K1Y;edcR`FI zim>~$&50k#Pv=G1lR75JOPpDkG(b2i4XfpGYjK;u`|gcOZKh=+j@5X?j63m`bLqo*|?uE;^9)?%Yl?z~dQ=VuV=!cZ>AGlz-eV4BTg znhP>|oAnnt*Ci1RADt&is#D~u75{B7Fi4C@cBodaDH9@Dj$21z=4-l4!?$O(H=i#m zo|;W^$;xk1jG}o9ii$d3Mf}~!dWtPNQJIBN3rX4|C;KWJkjK&52ILnR+;e+U7!c%HN*s zMQiF?s%- z(I7u;G{6ibUMIW_eW-=uX3ID zs3*v5K$G*qXI;rR*{ZSd?+isxyrSV=%pPmY1}-n4KQq?%4m^=;HZpG+e3A&}9aGrA z@-X#==0HeK=QKITY%Zy5nLbqQIv0VU zHKrRUr)bJIL`SQ4t}Tq2XlAaPcsAp>B-h~?xQE(7abo_)mdyBhH`k_(FU=Ccm@c9` z<&b7(J(cK?)?LC`#SlyzglHK;pH3Q&K$=fJ&M2fb>^X`;adW3?Ub-Ey9T7F%8t%x1 zAdc$HDANKl{=+Rr|IDyFW)QwHqd}9(eMAs5hUE3bEs}B(AwPGS4Mg66k?)188c7KBQ$SRfyvO__5;Si^i>KslfYc@{o z$sS1e>6w&JP2*!;ta=iKiUOT%`BIYRbU2Diok6L#kjuLC46Y|cmWwEIDi`W7%v3>J zr8ctcL&RRNkGud0uo6QA1QtRhrs-@LSo5B=oKgS2-sFGt;Hn1q=fO2SLCv<%@)>K!rewxWuoxaW8H|F;w|Cr z11Q&M5w%UFX@K3-@#Bl??it2J*o}xuw3=Q50hQv;<->=&ICKjy`6IuT3svSCA?u?A z#hqO?xQ{XG#1JcOS{{yo-Wh|PgS!Lj~b{C)*9 zG8ZJMv$An|L^+GVeOm`P-K(fu+GuJp5rN7bUv@iq3JTnagX^a5T(l%wD$Cl!8)s*V z_iKAQ=N_EJMW$_%B@+|z7POHT8Q(A2D2(ArUEpn-h@?>bs9DQ}S7?R5L_$+!j7dy( zh92q|3{WB^TYqDbb}5w2r}9jEYnF6na%Z0%P27P|3#TodFGD~8UA0<@dYP<(356q& zf0jdjG{BjbpJC!Ax5S#YW9&W4q3|ic*56 z1Wnc=DMCi}kl<(DG(M@zfUPuV6wBma`xSUN%aj~5>KG~8GfGz0J!4=Oc~&{ctIqZ} z|F5}xDSsq~@tI?L{)S@7fdE(p7*drHp*Cc2jsgW}aF0-Rq95T5fJaA+^8tpL`zH^r z?neb>oL@$K{sfF|t(=5IvqT-!B|KNHA>p|ASTM@Qxb~-y{^46(Z(TgxeY`%UP&SM^C1d5`<{pzclgV{FbUspEKxrs4|FtI&Z)CUxepVS1n0rCuwUZ#n!Kq z>tK(tgqQRO!?khZ*z_Du#8v;=1cB`mh=!m#Yb?w&L={OmX5|z2S$5$lVV9a;G%#x< z@2GMJzvN5WcczMFol@6FB)7FFISqJ-CGe3%QG~u&024*{2l^@0K0v)pckmZrn?ePz zQU*{Ah(lze6S!qTT{w}Sb@m0aCi5x-h7cJ%fyF4bw+jSfV>meFP0zS}pUL|Xn6K+Y z=13_H6&OCx$|3;nv4CL%j`IKY`}uGAQh4X5hTPtYs@Q_?NI~E~7ZCvt;?@EcPVh*r=q>-V5V>HBG$$X^X(a%N6lr%jKh#c zW(^a@clNoh5aZ4U_=6%*n7}Sw2c;j_ zAl}b5*#oNATlx?>q+pjOV>%%?Mnm=@*~Wj0NlGWx5(3{)?Ds~sESXZR)@-sFGUd>U z_PlKQplfT_es$^6;w8G}()YFdh2;ZZeblk~oO|nJE2fJOXm0zvt~1cuE{_@4^zCXW zP5Zp3udDLqG$cUtLftKu)MXEgZzd4JytoTi!w~qC!jI0Zq52aUV4v=}RhPu)7Y{)< zB-L)19YJ47Y+!{pNxQy3lJ_tnZ%z=mLs#Wi^JsAXvBqnmr8lUT;?~zR&P#xP=b8B% zXVSU7kE#dQZd}B}R` znRNF=-du$fWwZp%BHfN8MW&0YvW4WD;vrG*Pian|99l_HnT%t#R@l=3>_e+OT1nH& zj8k(ptMdxTn^-ZwsJs#aW#RFlYTqa8ExWGkj6q}TtB7})3_@tY~{I%Or7ZrNrV6WOKM>MnvhY^`j!3G0ys%DPzgSsC)3DlkX+7_b zlaiMkz8Ws!m3K$pL=C;4aOok#;b{gq(<*2__?>a@bC`(dDgZyp;MH)Ch?H!wd)<@W zry%!*3?Z{8%8Q1oePM2byz;_flBzdBf~uyDuzq~-<`WmqhRf}iJ|Gu>Umz&oXZs__ zAn`YI*_A7nFsqMzAbqUpiW7wiih_9SWYN-^g}KNSr>F(J%HvMyD)MsLLj-i@9!a$3XcI{wjF6HT)lxxj#_;qmsKVyaE*-5_P9=uqx z$nlJAv5GBIto(-Bp?(W%9o4nRVKeKNN9m7-eh~6snILFbSUev?LZTSdkFtC^1X{U$ z>G0$hu3$6#7c0Bl&EBA_AAUY{=n-u}V6rF={T{KH0v~}evZ4S5#T-07V!MqNO=Y7V zn!RTCp1jVY!rMXnSoH>pvI)w=Bt4<#TpR-=N}$C-bY>EZ@miFW0W56qpDrPBV+V5Rm?9zheYAAk_8*z{j{p>$#&w>LaD_J{4@!EM z;e|<()PDyXtbKX7;h1WGao>bgR90fV^o7 zS4lw&Quqc&biZhn9})iMJ`_HHi#&j#EPx{|kN`c9!YqL0Bmj5&(-pBz^X^krQo*{6 zys{&F?=$n(ep)yw#%Y3>!!={zKrLHw64o)$ImWnZ`EX& z<|djnIsRX`CrTH4D7@f_hJ3paMUj+wo@9?jypC52kQ6A)7{Rk@iS{N$EF_)}2di{w ztac*=QFQ^#pS;Xb4&Ihe*ZMzL4w&q1hD~t`;U%@3OAzI7~uc?0BK-J0E!#80GdKdzYBAZ zEPY_V)OTiXqMs1&UHl>yr#+^cy$#Ua5Q}{0H|9QVKks$9YEfOi_sR0Sf0Oyu?LWVy1i)G+jd`2wq0I+{1%Z~n`_(1dp?HX zr%$2@q&nY@y3g_(qgf?uQ4^_RG15M>Ax&W5PV87Bfham$2S~5LEx@5>6$crcJiuMn zKd3|KX9x39jZRppqNbL&+>4`(vk&8f#-{4$YLHS<_i?fx1^tY&A-_s8n>qJX(G!UH zC`Yz8581Te;A1F5At>Lq!ePdu99ZL^(4$!pyK$`>fN)812x}oyQ zPaO@euN^SP^EmqcQH0b7>RbHjAF8^ZbX?fZ)cG9HW&=rX4R`cF&&!WmS-7L$rjO-1 zpG<=}j!GbRsmdlnk1y!ZO9o4Poj#JlORI2d3aH2W7jeUkm^DU(a+RQzhBqTt&~OAX zM&5T3;Iaz}=`&zI6GA~j<>Qa&d=c`#xZ`3nv+%&(kQ~otu#c|BXMbe;ztcvwi&f1? z4?)XaeKU@YXF8|5=chrT7>w93N|M(EIWm7W@$%=NB`6fe@3;dsdjszjixpB<9(lof zp^zdI;G~YD{|?2M8K7Sl+JBaFJ>GSo^&G(=l+B!6IK84kIeSXWzC%KU9Z{lp2?mkn*p7`~NhxhZ~|( zDVEgV$dZSV63Vort!vpfJXao{{dP?09GlVfULkpsIQ%x58LUd%O!QnRb^I%6Z%!K_ z(;EP4dy;a-J@Z-(7{vl@(CgnppK_7k8pu`lYj z0ARiW=fuMYUWC+dY~V-E>qDnTT!fMT+GePAAgs)}nLKrC(5^vR&C0DpF{rfNq$;1c z*ejaxYcLI-=nUuTp?F#C%-UPiS071diG@9aBjzl#thM3ko96Dgv4h0ElvfaAN~nY( zGnjfkwb{yq?47+hXzsB!!-PlqG+Ytlqnc}M#)Gy@hDI&lX}LkJs?VZoxP(p3zR$NR z0HszcxkA2sB1OF>NAG=AU!1sBa`2^q(IZ8~L-5gn*fY2O;?rTgl8*@4rYp{Xw^X$_ zSy3KkaS@7_oDF5Y=Y7Y;33Pk*%Npq{d=s3l^DV~3BR?O`B(*`HeM&Ukp|nB8++Ebl zbE0u9o4hn7oZcUf!|AVVgSKtP*HtQmtq~pG=N`E&Ds_F7@%$mR?e}|Auhrk8p2-AQ z?R47wyaM&-0gPYtH<E9|(<`F`RXga0*~&|5k?OXA4XY`3 zt2GyOjB5S$USOoCEHOUau@dkO^=ucA5aHrt;ZYrjwTY7)6i_VfCk?aj7YZyvw_H}} zjrBw+Ai06I-O?-((zK;OF0jGJ=8A2|+a{UiQJUd*>jsX>yiy$uSf^&0&}dZda|nD(a|DVGh$DomuqOIqx}y8=lHv2k{%CwTchQT>d2A z7i;>=G&^Ln+CrwvWLGxTVq;Y=Mp;cndyqq^BOIQ0vae+Re?&)}HFJx&m%QEQb2qlL zQ6J=Rhyv8FM(yu9ty?HA3rChQ5Q1xSGc4lzFkL*vfB^$2KaNUb6_t95J}mos2Ym;8 z2fv}dLs8?$WfZ~@#u8!N_~4PsmU}3BpP1YDRpxmqUsMev%D7icnaGid7)TCnsXn+r z3U7|#pHEeDKzwhFlBXssO{!5%5G;m$CTTGML3_$<>AOsq=uf0Kf2Q2v0Ta`Ee)6#C z7JLvoQj};Ze04NH#zB8Ziw)+JByY0MJV-o|w`aFc=o<`10IZ z%lQ%G*O`Gl3_A%i;drZTZvTFJjMaJK0{{+9djIH82MUnljR)?TAZFwEEua^DtM^`? z+&#uLQAvIXz5fV#~@K&RdY1EdY5`&Pfhq1#BFjN zMn+f>CyUh}m}wzPOe9q|#!_lw{D|3Vw(e=ok)nW*5jIwVvGT*h{B0|iNTonVgkz2j zAP6$v4^ZRAVz|2~v(+YFEyfJP0R`oR1$xI4a=|>f;4)hgj;p82Gg{^wKI5OX3D9tY zFRo%M41NBUG;at4vathtkClNjRkbGDhNw0rao)l=9KemijMCi2oIlghX^Je`7H#7% z_t`A@s?c5+>3Oi}f-bq1fRvRPt7B!S|JK;P`!}STyBn*=>LMEb2*)hivB}dGe|_tH zSK;mnClQ(F^APtqUj2dVyVpT*Bq+@L z&t#wdoxNQoctnK6pZ0h~IggTplAq}Urg{$pLw^P)MyC76ho=T7M}NyL=JJAWgWx?q zvwzfnx|;L$e%G&es=1D}Vf@;t=F)Q5qdp{|YHfgsY^Kj_mHygJSA2i?{9w!N)z$sV za6LX_<=P`&f6useZ_Q0-xqfby-|AfN8nJnJcJ#XPe5?GjD}|X8k{UpkUY9CeWO32? zjI1?txZLbK8YwJ+roHXGTsP!yO+|b0eE;~1r{3he)T+F0O8xed{)PYcQT<`{a9HTg z#L@ZXZ+ENILQvm^6&i;UvkMObhz*X9ke z1eiLZ6ya`?V?fckkdlsRl1=5|3P;W*1A$=c-Q$ag`_r?Jlw9GKF=G+I-OH!FsaodV zR2s6@bH_VOL_ofITu_`Z@UDX9lfG8r9jne-$#U~sIFmR+x_A9I%pFvPHl-z@?aRRn zl!L+d}=k^`lf5n&RTe6680XQ`?PaVlIG zNB4ZL>K2;6=qA6>XLMt;qf~Ht2fE1GGX#YM#3<#WVl`+5lHwcITVa2~P4BN!H-g1#O9dd`Qs zZ7yC5k`Rg~B|~9P>bN+fUIveI`SR`?=d2o3^3Vp_3vCD!mDJgw_$RhiN-?Xt7jL5rJPYTH-{F}gHW5>eZ${`IB@sQpZgl0 z$0DEOn!%~B8L_$jeU~Axc}%|(=6esH@Z4!s;gbpgA`0_}BUHc!QHrY~L{_(Z3hqX) zT+xJW;7D%CO&kz=5<(0Heioj|70D`*fDV@>zKsJ9M@31_$tkI9B!(N97(J88A|E0- zuZ9++ZrWahMNFTxyCMmj>a2DiQtnVzl;@|+)=#jFZ0oPFZ7@)4Jdb6e*{~c>{`vSC z>7m_~j-rWlOW$r~Gq9P;4eF?*x;i+`Cw)*NGkw^&ZsN?&qQ56F?JCpndql`hQSID| zcn-{niPxTClowD>UR3gZ_i2~o6QO_YSuVA*2aQav)@ocwsaUdcQwK)8I8tD+B3uL# z-@aM~5~<99jfUEf)!&zyxZ#>66+wmUC1L01lh=D>pbJDL`cN#O&ajH)zesp7n9a5& za~h2leJ%Ryfd6=U?^=WI3m!$$OU}=pl#+Gl+MjkOiHNDP=2-GE7gWrKgYeS1cTXSE z@(^w%tqyw(6f%95xSDc@Jx&)geXVS7&UQNO4ozbM)5w;*drTcd3!R~!P~mi#6~BEX z?1rFQgxW4_F0a{y+Rf4mS=VZ`Sz}nA;=-$jmg@cXA$!~VHv0XcdtVTc;j(V2xv6m= zHQ%yJ6Q}p+oza!GhG~(*LCT^U=m~jRVDedlU{8_mKAbE_c+3FO4~@{JfmRqAgCeT< zAWb94*|O@ETOImZkre|$!GvO*6R?nWR3By+A+|sS-QiA2S>{IY0 znf$pL^`<4klR$zu7!hl_Nml==xcX#%@y>w#W)PP-VUqUw|;rr0EzStVw-$M28EErb{+Hw&TaY4o1*tzj`1 zKauJOteXRIYl6S=h+HJKk$?s|$91ZEiSm5B%rf}+Gq@(E3qi!LJ@tW-3L zmE@Em7j5eaJCC4*{q|FzX!v43er4+cf|p%6WyNc$|@fajf6O zx^YOUsD~r9vi8P|-pUB~Z-7K9=j3Jqy+3p;z%}Nmp}$YkB9+YVTdX+#UfN5R!8mS>@PWtp!c?!IA4yZ2~36 z?^3c{(pc&75)Q3%?Frn{BX~SuvYH=MucA3)_dl65cO1Vd`|qY$+6(gZ^hm^#JU%B| z8;o6Y4i_vN_WlS0qU+AFg6$Y;6aoPbm%@&Ag%wuXl|Lq;G)u443teFGO1Jo= zWJ@z@pgp)`n#njTyAXxYqi87o;um>C%5LwhQ#2Om6N5RrnyWK_IkhqM3HsOg`C6%W@Bp|?=rkOEoW(O(H=rCc&Ly#CL z@>FQjV+IuPf?CaR4Xg$(FjwR z#R8Tu0UC72Pr1 zUFSyPP8!c$fKo@~R2i@uD=$^3bPTHEi|Y8HCIP5TFxDp=8vb!VkC^an_JV`cV=2&hdSNa&h=i~+tJP%+g(%5?WwOL-`w@B ziEfr#|3H&ftvYN=iyzT@S2onCmgUuU=}VS~N=rhD~$)h=09?FBt#2pT|{K?6ot?j#ubxa+>28b?^D`4Dg3h>f$?z zpB(qdKq1^T^>c^=9YGH{NYaR_NH3*M1()i&qGGhnN0OXtY6hdfLDF zIG2104TpoA{rnL0ps@ohK@v!oMN6D&kt6{C0D|>5P`_#Vs?Re^bIG`C?pO!WkhjZ| zB#|U(W@ZKefOF2dX_kv&|9>=PXR>S0z5_=(x_bHshDKy#iixS2Ih96dFaZ!^!E6qf z#}^1iVu@6Spcqcb6)Lq-yXx`hII5 zckc0JRcI$WjcJVI1-RBuCCDs6=%dqMje;}I@q+fqxSa6}=x6K8WXbr%R&Bm74e9X) z(@Zeg=Zk}2UsW`TLvbV_R1s466#?bLOe^T5jrQIuO$kj$)nvvBwQsZ4As}Z>$=w>I z*Zfh{#;Gc}M|}PK9k$Wl>SvKRb$e^jZ0W1h%j4C>hFl?6?zzfK1N*h@@9}v|;Re0S_15*_e3y{UFf?2uCq z^A2~|UhE8=P5Kz@9mzNDgT90R9R5gK{y=__Ka@X~OY&z%`(n8+go8YpzBK@7F#bl+ z-p0EhgAYc`qkMt`emrshl+;_V3Ol^h#!C!X^KNYx)mX_XLoC$MUt8_D$l{P1>=TWmWMW;0~R~Ot$943KwDtv zb~%0l;cV}glR-TvHlU-`a(&@3%xG;vo7aZ4cAZ+Aw>KM!At#bq^CqaqGH>>3E;e0g zk2;^jnx&^El`rbk5EsQ2Q7^8GOX5aydXsRiZwM|ZiJPJ>xauystk<2tQ=`J!1a-;j zV$VQz3=!|N?VwVdn?@)s17MJea$y1<2`ZeRR+?ij;FM>S_fHg-OLXR@}I z*(fDSno^;N6rJ*Gq$)j|RJRAo$!JN9Cwb4z57TU~)MviQD=mn-rnPgA^3)T_gxWMT2C3 zCw6w}6pXt;{i35liBhPUphbfL9V#=^0JnJ);6RBJHMGh1kBQJWFsV|9uW_Y0~Xh_E< zg?QAMpsDs=WQ*(|=gTqKEh}VGIZ3valjRsWGiNWw(0tsl2&DN zCS6~C{_m#$-1h7|Z{g>|Pdch92Wvh8RX^TK9Jio4PfAo(L&!kH${IojW2{;YAp-y^ zY6uw|$5mIO!P_T}yMI4!^`D|4?o{_ll1P#?GcyAKz&Yn!-YVj%qG6UUk|arzBuSDa zNs=UzBuSDaNs=Tq@jL(A&~1DeO=aWd zRiv|^ZARY3q>YmExU0DF&GvBO8*Jlc?Qha-_OQG!r5i{6`tFrmK8%7NU-v_|hgQ2= zm=at0!_p^BYrpVJIeFs8rB9@*m#)Zct6Wy|S7${?@2Os4$g=dN*5$F6FA2l?PWFhy zx47@@dr9~453K0a#iz?(ZW7wRY)jrHDH7=?*`Y>#e$;$xD@XSJwx}Jxo4a4rJ9={O zpYh4cE#D0N&@PT0`dqv2o9lwtb|@!8x_4H}?rZZ@e(CEr44H3wc*sldy!DU$#wry< zMr!Y8stu25pGISv2R8Swcx1y z&9aDu`+6nc6Hh1~F1b`&g7@C4O2uc3P1yTpeFc%}eBcMt!^U$u=6evlmfrJ1A>s`D z9k-GgHOc?&ru^>vyU!B_&P=A`E>j8r6z}*N|BTDEjTN@>4gMSEQ?~y$*ZO+@Ltnm( zpSa=uMVG%l$$;VCopSm9j9-RB0|HlQo#7ROOg!)aJhncNDLAbYH2$}p2wKnKa$j+H zQv7U6S(WHcy?Yp-Z31rkklz-7^ zMMbA9^FevxD{x(r|7*}nNsIMO zbnKV$l3($Bl{o3({%@W+-gb2Wj2Vm?@dORm=PAC2Z&L{Yt9rcLbhQ$vqN(^J(eH_QN8DTDUy-m^l_ykd zs^(pLOzm6t+ZH#CcdjG;1C2FJmh3wv;mdpno3VN2b%fL7{m>)cLT!p0eOr^D@^P8Y z<-F{B%@fxhd{@lqnV?%NTek+!r?=wf&LpQl4>KCRqjDa9OSO|TelcTEINAGnME2Ga zv&x^zFi)-Jvj}wi9GixKkPK+xyyPE{e(K?Skp1wZ^+xgj zbsF)L52qmn55c%S_3-mZOYDAkZ}c_wP~cBq?^b{R-!Hgm>@t-e^A3*B{dk-9A?91J z-FK+1Km4)>|I#JH?2UcnWv}+!KgVtj%xkBPZFZ+`96APrKbesTsVz&0Kl$rDXFrocbsR7O#KY=Yh1`^p@&;tM`ol_cv?*T1n^z&tviLTEAL& zcGBCaHvajh%(L;K*{8E?t{L_!gPn7J?)JBx3tztE3GFYa8JGKNAy^dvTFdx~mL35} zeo9B@5#YabcACK3{}{<ki<`cg zfly9WW{xt(Yh24VK(iuuW*n7Ob$w*;Pz**&zIA##eKpAJx&|Huv*%Ft8H9HT`Wb-jl&>8IPHT3#9QxC` zuxbIRwz6(4X!8&@#ev7eiR9yy$5_q*K2PQ1a(K3Sc*Kc(z3LJcX!Q;(n8RXc_@o0? z`B25oVDjN~`Q^_1;U{Y}@Of9#&?7AOOM^@h_A7-~&NgUe>_KF)BZw^|O$ZVT8NeN6 z;t>V8FBEQ2jtO$@^mOATLtVYj{?{}KNww;1mMpUEdb^6p6+JEY%Z6G)YOFtq( z2iu3&!OSS(2Ll@iVe8BgTjSW4}!>xs~TL`Qy(6fkOYBB3Z0)P4~httskYFmcQ zm{78kgzFC4^``x6NZB=@0~U@F;pPy~5$C4|?)C<%iZ#dPEEa&fO%Gg6rz1lAl--2_ zAK#HKQBo@v zwS7IaXWZ}sv_Spiz-aBr&fcB8gl|V(XbZ~UJIYkl`1dy}JUyqjs(OgzjUSDNedJ%=j6|&7J=cbUO0>&c zp`X1+X@4T-XWu6>OxrIS?(N%{=lGA&IKOb{S=hwb%JQ^ z;jOs3p?`(icsFk28C_^rFV9=@lXcow*4>vV&S+9+e|%tUD3FZ_Q`GOT6HK4;d{J&H z8PBcHb2)_RcQzw;UR|y-_~)4pz!3jC&uVJF5_@-U7J%{yo!?8f|52&pf|_ilgfIAh z)DlMK1z-31Pf6(o>!TKzm+I|RS?NPxXs$U9)M@&;mQKtMN~(*hGV`|}s6yz_QeI4H zJsC!S}ij^6El^Ct^vcAi-0y0B2%O zOvI&B<^l3+PJ9u5EqLy7w2&nY#B1~QUXLc`!)rBcu<)Vf1%K2FLQYB?z1EoDFM~C~ zd2$^~XMn#I;&dH_dtG>!;Ultq67|J1V2bp1^lLLwG70YZcn-^ElpYTXOe*PaVze`c-+?%hUX)0 z$iln@*{qcYn1h=#PKMX1$;Zu5d1qwE_0aDUm7S#QF3@Tf4BQ>qGd7|t_tYgomX3Ss zykRS%d*YAiyU^TI+3UXuukYRRDU;Cd;|)rcOh53>W}A#YaO-+!(LUJsjvUB`dpzMV zu1*2!+p`&u)`m(I1O5I zQTRa|&9_1NK(gUd8_fswZZcy22a89;rF4Gs#^dxA=>B{go7c(wV*E=QQGOLeazFWB z?XWF20sBn?&a0rmiN<+@JF)MsHH>{71K|L|(L?{poe|yl#3A_N-}>3pwA4pYpYmOP zW<**hF={rqO+r5r#qjDb8RES9=x=0mdZBH2WYw1EBYw2!Ziiy>U%dcgP$KC5LFo#- zh_Tw^DKlaJrE_eH2B&!H*6?h;7u==0 zoKX;h@-cW+0h>AMqH^kw+yLDUT;Kne?x-`N@02?X7H_FTDs0U8Aj63g7iAZ-?&fZZVax&|R$Oxp2OV)DK>d zfecrB|0h;R%lIex!P?H!yv}f6A`*i8UWAc+0e9KxKH*ig-JQDJ7JUcbw?^Yy;+Hw; zF7S>8eT8&{c5?M~o@e}cZ{OevwoMrXP2S}B(xJq~xpA$1cF#~?4=P%6+KIerXy23V zsOAmpdz5i^yuTlE3hB>>_roR`d24f{mMDE;fiZOflhsUUkB$G>FftdONA=B!cr(hh zbni9K*w4yEns8o~HPKGTypMuy;+f z;d!B4r5ju%tN?z6=bdF`s`iW&aXC}p#4~=_v&6cjJ?v=cEfWlg0^A-T;f|$ zSGdCkAgugIVU_)0UOpMF@(|>EkHatJH(+NhfDih6w735k{u3fwzf^#KLTu|-sJMr} zmXB~G^0>R9giD4O4m8mnl~Bmoo;lZ5S{U9qSK1>T%(k@IfpCX?Puk_5jkWledLsXf znRvUsoF`%t>0OILDF%ISLRJTP==~-7#8?NQjSmy zahOTZaR&ZSkMst`TQ~bsPp}%y;l9kbqRGmweUIOQ>D-0>H+#8ZVR!hT&N%eCP?vtm zKe9^cE=%9#Ll)WE4*tOZ;F9h4_|H6w2+U}XNaAE{<=!6)h-hzLYP(z_DeJD`;sO)ZMM8&A9;-E_^0eVp zcg?fShst+dQ(D2}^b7k!LFEra37&$6orH4XiS)LL1#4K38|JX<;N2tk5EH`)CFTIK zHD1RyOf}k(a`tg7o-#byJXhK^-c^bY&&pbg8JbeVCm;=ON8LhcM$I|SGS-+m z#H&Lapwgin$q^g~ISz6dM`7e{0xq!*;ceTgP-vp=>O|dhBZF_8-`R%*2Cok#mtG`l z3Is4sF)RQ~D=5Si!8qrr$2&f+Ra&&$450}8yF1#6VNkOTj^Nvff1AKx#=Zu|!mfQI z2kTcQ2zi~BG)!LjGAY*@a(E+Ecl)hG8&Lbi_drdm#nO(1vn{>PP4mcR^<&}KL+p|l zy1@mUuBz0mxUnGjR@)X%7ndlPB_F95O2Hz*9fNp8yqYes{`Fx&2wRci!4(}cens;* zE{P4X?X<-eXQKxW<9yw{Z@Odg+7MVdac#F#h3#Bis&)s3!PU)WT;TZE4p<9+p493Z za4Zv00i%TO4l|76>Nju^uCd&72P>f_b{d;FTTxqF)gqt-s)*gw7{lySnqYEp!Y>Aa zE{>@c?Gk{cP}vQGh2LMdyz(4jpc8Ie8lzjE^5m}=!m=N3qU1(eKOFGyKv^4h`*7SL zYbjv(-63?qSXtIlq>{xR@?<_?F2WHmJd~aD6vF8~VGd=m2b*vil)`KRgL{sB+zO82 zImE^AfEdLI!*a?|Et--rw|EKg7FsIKAho{|Y=YuFp%^K{pvpL6V>&bkF%n~|GdFp( z5q@C3KD(EMcysUg@EiO2?i}kJ;S26KC#h_;fq`c<_xPIRi+6T*z$*YXF0>Rwx5ZcF zzQDhncVls|rG_T1+Px>GkAZgKfbW1Y7AGvb?ibg<5MhpWu02WAP@NGtA4^k`+qH@8 zh~*Ctu`&VWSP(x)S&-V@t}0cPCCS1~O%4>Jn5bCZx{5LbtaJW06IS2}f1saM#-+#` znH~ejpT?ktSNN|pN~#Z^b~!C0N31BXNG6loDt@wBx}_iIaIG2aE+QO)NZKg#GR`o` zm@!+4Sm<1q;?F|zpW-zJj*boadMNyS3BHiLyIW9}+4wE_1&IcnxMs_i{lSS&ha9;G z?JTZzR>*ApX^MH%H1bqXDilZD6a?-yZd|C{iT!a23*m=65Bf2Ud#RT6{v1gUX`&vb z;i~aidoy|g+yPTbS&2ab;0xUGkjF<;V9?%3xWZpJ56r+Hr!P(^o0lR+s;tdXe{W)VW55|o3D-hr`@fO3=?+&=d7X2c+hF$9e%K?r4( z6flP^Fp3TZijdIBTmqqHg5lG75rr4aGdx#31NjFnH`+YvcF-X!oS?bhc2|18eM;gH z^m!JhQPKr_9LK*iE;MBWdkj}qFRpr%A!mCoIG!Da;bN0B#z)GLzl1{&8p2C}!a2jj znOf2-A=Xhkm|VRjRg?`W=~iYoCj^35_7LQ}`@(nh=G{~u0cpt|wxl5e^Ea1pCY+#6 znaYpU`{GIy*(~RFT;MaeOh0_i$+wXcW*FfLnEJh#L&&j_H{e!{mvNX2I>HK%^ki*I zpQcT9Od0gw;{+EROKB*ZD0ak{9IQ za24rwg3H%tl|)r8Sr}m_Q{eeYG`&VyDMcblV3&7D8C;2ngf<1CpLFBax&>xAYaCH2 zs@$CJ^W$ds_)Gwv8qVTm`>CL&n-~)M)44KSrmsD|em>IICD%|FGC|L1$X#z1-wG%x zJ0~wFv*g&Dyr_osUjGC&7BPd}5OIp3I68IvBvcq06eX~02JnH%qhY>Fg?-ZJP!bGQ zGL^?>o`Cxfz_{4e2zu&MV$(Xkdd73GisoOdCS(my(-A(aG4cJ<}4j0?bNlkmGq0&iMliF8#^SZaC%wRE*#k37O z*Vy1Q2AN;`I*)hR5-e3%Ly2IIA!t6kVjOSf)+9f{bYY8?N{V7zK32xKUDl#cp48Ot1GmYFjK}~ z-4=?)(ICW{Mtpe$T`IX1RXeEf{x*il>nsE~cHAWnRb>^-Mlcv&ko1?KFCqBLBFcUcTlb*YV2RJlG;H z{%DcmAx?&~$*hrH3hjBrz948{;6e6oFzBWhm(c^+c~F)3@uTpkyR!a#Bj|E& zN`fYSvj^FCtZ^e~H+&=Lp)z4_;04c5q}6-C4PXfTw`3R(_X$hn0MGyqKEN}@a)JxM zy9yM;xmCgb(XeTY{3!J>#r#2A3+n5QslO=XCsd-T|S^= z<4pn6DXgx{Rv4e{1SE953W^|SHDl=42^Ld0yrI=LxoEWN2^01w0xw|bbcF%{`T%G_ z7>j#TDzVS#gWOgdxeRlfTVD?QGre2i^w>=RNSX_zKo2MR43KYFh?e&@=#s#p$Zr!V z29zF{XAj@AKcqXW>ElqsL&;mGR#SR*tUA~~oMTdg&>?(tncr-!{aRR(%OV4E0$C5_nn@vU=;^B3=rJipJ7{gyTN zhiq;nEHEl}U#0P*0@8(LjQ+KH#p)%{LlG@*1wQ8ded(9_m0}et)u_{PJdxyHMYNZQXyjW?d(*iJLZgMJLiS&ZHJtD=d;hf z-tTwZh`)tJ8Y3*wVa1LUH!WU-j6ni{KunkL0{&ZGYt>`US2$J{2n~H+$0XBNg6JFl zje?3PrJRbYs;8?wb-GirL0oDew@1SxQ!=yjUf(KoP|`7&lBt<#vt}-5Ix|^cl;vnV znvU)Y6QOeH4(l$zCWZ1*S{4o-*>XyrmUFTzHx#t3-|bu0%(Y>Q;(UC|M%Z{Ztgd*a zR3q&`85-e8PrM}Dq>g+mN7T`N%+>unb~FX%j5DC*B*4`Eneqf){*D1jZdvi?CKOW z^$<3o0fu?)zKrs`!1+;49`lr+D@6`XFGN}x_1NV@U8(n`UwH4sC)4a)uG8r{AZ_|a zda7#c`$=d`T4%OgcPl^80}`r#-xYXXR6`Q?6qH z*bu=jJL487e!FuAf~F6){pNG=+0&HMWB{7asO|?6p1_m4_34haL|mo}lxMIK6_X~hEp$J+&0kpU}KLwzr;DYDorkgu%I$s1iw>r1j zIzTfXKr@;l=-(gZowc52o+SYQ(98|c%zVAFN6^CsS}CtvUn@GzKgmDF2d#dwWB=^> zz(@En_rOP;QbLc39{x#v>u*PQr@STs{0Juv#v?ejegt=nV|Cw1dD+$KW5ho=|yJHNH^XRj+}2n(RIexZZtfijY zm4aqydvybDy<}|~s3xopR<2ZO)y~07*~&Frw>g>0c4^RDvw8fduE1vBnm-TxGH^~_ zJNombM)sf7|HMkMcnR`(A?N=mqbEb36&P>wJ&K~!y2*sAxU1o9g$y~FlrB9 z{?@wM$z(R>Vf}L~i|}JWpmR?{hJcc2GM(!@=NsaE_dRg?aK$D$)6Fp1r2DHYykwpA zHn{1a3I*)de;CZ(Xw2FaR>g*$T&!HigBRO5o2`OlkOg@V2T}7gQBfFSL}6zD#e5~U zEz!y(t|i*V(^2ttbOIfdP$#F_e(Pyyc^X!}hF72wIW#h-E{?55akMzDmc-MhwAx;p zF4v>FhIOS8J!wo&Yu{Qg8;xBzw)dvwgT6JycP91yD12iopZQZ@P)J}>i@*|ppio9a zrIau!CtSW^x;{U*{BprsnJIptOZmrIVH35gyU$BJSqlElTDOb=z~r@%?K=_ z>E3i|Y86c9*=PG}h2#yL|37s1oxkti6_#IWX%RYu)}GiN8ar<Rl(LFT)9Rj%F9>vrnib(&BAvHeYD_mIeRPF_J#Ne_~c=;gWk@?Xid?-MJ`>>Qk2 zia%Qe;x%t2mi2y_WpSZgGbNfXU*Y}Adwc%A$9FSLC)in_vBvqD-k!-N ze@6KiPPY%4k}4TgL86E)8}${LLTC5MJpv;nkNm^Jaqd)FS>;uTR=8lXiYYBcsx;}c zWXpL$Ug;ZHeZ3Cau-OleI%3f9pST<5^GViY{x3TJcH^Gx*`Dv^UhT!+FW%z6$cKFL zguCMVyw_j$X}f#DuPj;8KfXl1n(gWCEi6UwlmV$iqz+kXs5GHV^Mfy|WP^)M8}@(G zSiW0%&X<1$=Q!utFL!Xh^v%wnz|R%xZU5SoC)Q6cANywMv&`@2`D3KMG2B_k8*=*k ztFZ@4NEab}#0-%#PTg+syd;Y6HgCJ#`&;qk29}a3O4Kqu7(ub7 z0I*D=H6?9Pw5Q^TOpM6Q$T&vjVRT-`%%=exw2F1V<--P=C*^^Ui6#si)8*3Nlb=e;l&dCyHg^H8f%Rz_PLV{NQk#&_%1 z-L?&{+om5KV|@l+v_HIemwfU5^d-CO>vr4M?~ZTSUEjESzG*4GYiMP|C?8hEaAr-b zXX$#U(>J~TePnRD(=*JDnj14e%fi6oxaHYa=E&A?@hYX}t1f-H>al&puVc+l_qp3& zeCzG6Y!ZQS6r1J2G#^&x$EpHYofm6dVr>}K<-q!!*bwl}>ddRb{F*GN#lqSws>9-r zD5%TLCTS?;Z*9x#C-8=Cd*gPzX+n2w*PFNJE!%hJMBX~Fx9z~&cj!|)Wnibx*~jMY zjCnh2{?1vj^R}<$;pRNryvKVx&qAqL1j#aJR%WSGrCNdp8 zp2a7mq-A8~DS{iAklU_HSbcKXP z6@Q;TynnfdgUOLAPrd?L+V&1!|G!`PReIcXb=7-P10ODr|NqBTT$QhyGM(vJtrj^o z)u~N=8oSuPkk@{l*R89IpX)EzRCqDGi1*;#dIY-JIl11FM`*#KbsHgl?dDJ<;b;9W z{KavjlZPxDH@^j2zS_2H&;A4PI(H9^uOxbNV=h|M$o%{WuFY%DzN zC;P5H@WY+d1Xj|Gth3F|CCz?MTDlQ-xZ$y-le;i((dGWd=Hr-zn8oxn#3TO(z4<6x z1kquZ`0vSsDG)L^0LY_JNfI*R#t8SVex~35|62FP$~HaGPv;L|i?@H?ySc&@&9%&V z=ACc;WiPPY zcAGqo;6?>ph`(y7Jh3b`%1v@hCb$`H<$a=mbF~h<7J6aQocA`u))hm*s#{xieJF0i z4%N+rI^vib>u7ZdW5Lz#k9Ir0QM6}}>-bR=bC4k+gT$QQs^$Wema-zA#Ji)bc9$t; zRpodmI8p3)@2M_!hr7Mv6MtA)TG)N7Te^O2%i1sPQG34XVy%}N#O+2Q=>%mdpR=$o z&<1aAWb5SHZ*90++?j1lR}~Py6n%+~rXSHU^eZ}+enQ95`RI7N2A!ZVDkr}GUMJy=NpwE;Ll@vsbRiB$7vU6iF)l)v;5u|E zo<*181#~%HM_1q-bR~X4SJ7qYYPthmLsy_{8EEJ_1~R&yv4L*D|Im$;6uOC0LN`;o z=oZQr-Acuw+o()*J5_@2pxV%#)GAs|?VuIZDOyPY(kg<8Rue+BhNz&mL<_AWx@bMI zM;k~W+DNj|CbES#lU=lhoT0nOHM*M#Al*Y(p?m2zbRS)f?x+8try2L?89Em|OH-id zXe#tP-GW}ADbb5`J9>#hfnH{8p;s7K=v4+WdX2tEuQQO)n+#0!7GoE^%^*VWoc-(4 zyT^MBIrKhb4ShiJ(T5B&^bvi9K4zStPZ%=jQw9n8jDA3$Gy2gNjBfNLV*q_My|;?s z>-}50zBASLQa_kmKRTvfaF*LiSQIifjfef@Ki{J zJAu^jG)RN9Kw5Y@q{E#-dUzgWz`a36QUaOb?jUn`4P=2wfh^&*kc3Brtl)K!H68=9 zf!9N}cr3^c-T>L-aUciy2FMXF2XcaMf}HURAQ$)+$Q7>ya)WP!-0> zf=W^f7b_ADiF(G%6%EJ4bo>e^wxtUwuKPJqJlvA- zj7KXG!AY8g9w=EzQYK*lO4TB1lQ4oZge9{RYM^#+%SU@!xPUsm`HqgXa0PXG^B-CC za07Kle!84Qxz!ajl0At$P&YHveG+{@J(Q&9B>IAKEJ&|Oj0E+zFu9W$1fB-&a7u|9)Nf@3f(-9bZo%m5mSy%{!%6+px7%ZQUGw?<-rMonS^ z&}auTW)hV^V;#)6NmK!icPJAku@PvZi}_^|_kezNH~Euz4OHM>P4Xd=Cq)i4#m5v* ziU>5-r%Xeaq5ztXD}ah{5~vs+2hAYOK{IhOsD$(Y%_6&jX2Y|fIq)QCE^ZH+2QPu< z!*iih(h5|Db3o-}YfuGz2wFhef)--a zmBgm2gl_|_#*cy4knKQgO>fXTN{`U2p9me$1{14NN~)d^4v*USf}2e?>997VFk3pJ zBdEHk7icS1XWKLKZMU6`9d-yic5)5YE^N;3XLMbAP@TQ+`Nuw5tOVNMiZAFuPfyT6 z+|HqicmZ_Sx72hL0y@&t4pe*1emLrwnBzFDNdTQ_O$z9@mf4`+TPB14xaAQ3-2Xnt z_{Tp+A18$eFN03ufuPf-Gw2NQ=vm9p0^P&2f&PO< zK=-i@s1X)}9*k`t0!M=$VOP*&_!{U5UIz3Oz5{xuR|7Q>^FDus?*hF@hrWD7cz|A| zV_%E#1ic~cL2t<@&^vqs=sjrx`T#G5KH~nMPw-vPXS@ce8U6L%$2b|-s)JoxyP&TxnOHwjXRtrv0}eo4z=4D>I0$tG2NQna5R?sWLHL7PqW^$f8E=4F zlcu(j&;YkJrh`Mt*@PY8WKVDe90-n-XTnjWU86`@uWXEf$RlN zgp=STbOtz?3;?Id2jNsQtZDC8&u=<8-waU^a3+xrZimW&+Y>#&9nfRoj>b#iPLvW3 zvbN4WL%>~-lCCG|(M|XNdT^g@9^7-!Il}eYdOwr`xpGaBC(q>72j{AKH1KjV6}*CM z1zt%D@G7!5cr_&-yw)5FUPrFo)~B(RBD#XB$RzMaGX=bf4Cv8F%#VyN+DD?G|s(+FR<_XAsm7@P2Y6_yDyf_#ia`e25$cK1_}P*O0@( zN2syj+Nm{j^f-1k_x3n+zTguy7lD7H^8x=(7XtnqZe=)Se~++w){cJbVVrHVVFOv4 zN}&mYtr6E^dzT?QXRj#i+c(C{t$tN2;7kIUxuxQm+DBsaF6@s)hjWlt}`xDGLPf+<19;T`M1{ z3k2||E)pOxdU+AtToMvu5RSw(>z9FF) zgG4blNi^e_#GqwBV(DuVhwdfu=xrnc{YR3p9ZA8LB$WxDq%jqfbhH#m#-C1?%p>cX z&^8-%56QuQNG^H{$z#GG`RHF#fQd*UIvPk3W+I=lI4NerBqi9ElrmwFG8{}Qn6ODD zt|e9Yf>bl%keb(P=+pD5dkT2#4gF6V@D*ufG9X`AF(#iCLmq7&($VpbuCA$`p5N#j z`7PPl0~8Z))|7;7ffU^A!k0aBM-5D)>AutPL&h=b0mnLI80NpTWy?8k39rYFclms? zcL9F%GRz?45I3(~a@Spdy62wr-g&RC zJ^+Mw1&ELEdO&=FjRlC$*sFkO#yJ9rZ{QJt_zv?25I@WM8y)8Vjo;h9Zv-24+yWp5 z-6Bk|69FXCDe_;yRv~VR2mGFrl7VVhlU^E$(RQ%;0Mgx91bTXf%fKs#k&XiZGJ~L) zcYyl_A=0~QWIww*-+kR7xsdtq_tfCT`M;|-s}iIef1 zH@62{z+vy1Wow-eUO(kR(vB%!K>{+;Lr6?ax$euhM1$J_A5)y%s9W+k{VG zHvlNYhl=#2qI|DtKQS945NQC4MG}DG5S;-O56=cr0+InJ@n3xA1yTS|GU6#fDM%4O zsl&>aO3$IYv(7eFm!D+U0;p?}wg8mXAm!p5e7o!x?5xf1iF$?4d;LLgrj z{qU{U%;#{=X>e1tYvDe@zyqR%hXf0cm~Q|^_y8w90Kk2^5(JFvuyC#x9vu5qAdpuj zDgq#}^MOo10g7`1sASKLmY1a~_km%=z@+F-gf;ClVB23DL*aJ80k0(;_}9)`0=y}q zgC#N+>C{U>W<2s!m0JqTMmg1AsDw)G+oVpr95lX98m@LFXbB$D&**Ll0rbh8fe^ts ziPr=nLB=GtX6QN)iyKu~DIuT6Hti~~gT^s!Y~hp~*R*j(rXz8mj@l~=C*$dMHF#Ud z;Jc6Mfq&0FC;|d9Rt^qTuGh(%C^X17k%Ecp2Zh2Z!WY#a1X++$Oz2QT%us4?gEBJR za?ufj3hEJ+9BZM9dO|hFI;f#uMJ>lhsH477?}35_3hjq9KA~71J$r$nNfWDP&6rxW zuxZtbrA-_AYR4(KJ{Rp8=%7D_PU3(r`V;6TPUxZULND_byr5H{kGS9^eINQsCJYcZ z3=%gCG1D;2yoC{F23|1}Fv`rrYi1JO3>!x{%h)E%xN#}odP`)&1g=SwJnyEk_w}^? zmor({gUqIlEJ7(f2&Z_ldy+|99!~oS9#nz1te~W{ilV}&qq9j*ZwG_1%gAUSizQ-Z zBjMyED?eoQ3v3dCLek#mZPu9lUGMt7nVFxJP9rZd0#1}61nIH36dunn_%bSD>z7(B z%Q@%V@4SD&{_pe1{@h+%05B5G2N0r&K^UC~B9t76qLTnZ$%7a=8N?|CkU-~yB&7vX z=rWL|Oh5)*4ziRf$e}Aho-zXkbR{TK=CBW41xl0!D5DjiLV1HKS_x{D52&M6ph5Y9 zCRzj$le~qn_a?rUQ2Z4<48vJP87LVFvIfAn?Il03|r!i@Cv%fWaU0Kmfso zK+Fq41P_9-E`$&k2*r93Mpz*n>q7)#gGg)uQREXuV}FR5a1!=?`(s4E*hzDfg96%CJ$i%sjMZ6#z=K+d% z0~(hChQvS)E`wYW3wgL4@<|+EaRn5RcqqgJfFt#Q$AeHr8i0U@pqMlQ5f4KN`2wYQ z1j4T7*>i&${w(iSAm^!43)`iP?d55 z)yW&c>6}xe#->`eF4U=Wgp2D*y?Q^Y0md*PK_h;HCMINP#y_Ej2?biI7HA_0&`wUF zgFHhgg%4d+8gw%=gPw6}dtqh{JhT9ykHUohQMn9cAUHb+S_Cjefq;*;0T56~FpRbZ z7@?416m17EMxnqs+8$tnLWD`Q1HcsifN8WNzzhWgvj~7W3Kr(kVt@q-4i?c8fa4TE zETOfqOa;ISS_dbnKsbrk!zvX7YiI+kQ^Bx-Ho_??1Wuz(ut|l&7TOHkR2ZB=Ti`4e z4(HHcaDK|S7eIf*#i^XV1U7`rgdMJ6Be+U9;2JiD>x2__unF8CTyPVc!Y#rLx3L-A zAv|ywo5MZA3-_@FJRp4V5L?0{!VizJ6+9sVu#2IvM+D(1wuWa!2%h6nctM)sB_4xU zqy=8%ad<;o;Vqtkcccy8<4O2H+TkOfg1<-ye8SW4nRLS6_!z!SQu`Hr0^cUS{SH2b zACsK@2|j~gll=V+GgkPAnFajIj1B%{W(gq=CTuXk0zWEY2dG0|@xF1=DVZU#tj=gs zL4|B3lS;K%CXEV(28RF`J$3pNbUbsE^} z{(j@nuBQfTT3xSwJeMoi3w`wQQr{Rdsm@SiSQ~4s{xyz24)z+*jfZ9cx(V3pKsOPp z0Cc}#Zvfq|&~`wVkF5u~0z@*Pn}lruy2;Q7B|=JN6EXm=g_fE(^l3 z3t15@a3veUr>$gbbjxk-)x~`ribh!D5yx7~v9Jx;%wdl%^b z7~b9Cep%h0o0<17_EF^T_;=C@r<}IhnK*mlb?%(!6E3*m%XQI)D_Gs7&19EdR;Vtn zPTEn|XnT|T{WEFzqn~l7@oRPOC%H+0?!V-w0J{6v_5C!8hlx%B-J`a*oyY3Q`@Qa- zs%P)JZa0SY@>|n)oeef^cCWYVW^cV!>_dE-^gy#O9@keTHU`jrOKcpV``+Zt4?jqL z`pKvMC%GAb&NN*9oFg=?-eL^1>8#3}mPfE)p@t<%ZCTMy!kRUk2gmmA0=r{hu5sX` zjx%RBxWv-6DW{-ZD(%vT8nwZq;YQG+b&3w16ZGgI*C$#YU_TtkJkH>ryoCp!-QrhA z5CTUqZc9ihq5(zW<{U(b_)L^2)GNk5Fo+X}M1llFk|ZIN6p04_iqc6E0g5us<;ap% zNshb%3KZl~q$sPD7(zaa%17nNP^mH^<|?Y~K&MUv7EPLDY0-gGdL-TfMYEPi+_;hA z&K(CYUKDxrq0E;*H35R?2^K<2s8GDZgz*wCoR0{R{6vWoC|ZnQvEqeEkR(pB6j9Qo zF_bQysSFwHWXj}TS)^bOC}y`+LkQj`G{%bGbUSB_hS3c@N?VpXLI zpK8_E)u_R!RxLqw>aeI+kG~p7-Ud)?989;0UpCB|G~>~#6_Yk?IJE1)rc);_J$f+c z)r(%AJ~Rdl5HM&Etsz5%3>zj|d)!$2?z5xCK^#uCt!q+j2Pi&|xHYSQfN5C)4xE1w zAXo+hvIH5j{{<#RDL@r|%U=i(z$r4?B|(l{B8s4#f{O|jaM5vT2@_`T#YR#Xpo%j= z13;C~5d2l8vSX1tb>lQ>nxREoFCB&k88J1%jF~Rx5jw+>NRd87i2@ZJF*5=cPm&&h zsK%eOQVpj3Lw5vY0+kAPBJ>nGZ^ zd&>&>&7DMz9+-iu*aiPdq322~(zsnl??m=4fGH-aP(V zCMh4FbeXsxp!77-RS zQT0TM@W7xSt#G_~gXE{5u>JB2$Zx-)KL3zF!GO&&C_Z3w{-MQd3(!UZ4lrR55H>{v z7=;413_`2`TZZEP0=D`f;RI|=2p0p~sU&&;?s`q|0NnEi5f1nn;LEj202`xEmwuVRY?m5RjNVMsHLM$10#*TFw>-ogJ#X#v}om_O}iK!Iwa}TDMgoV zX?pa?)T>XneuMJNSWx7vJ+1cbD|6sLg+oWG96MI)#Ho5`E_`w6(!Ou;VEq$RkqQi~7%p5L z@Zjl&58ny`1Wphl^if1yOkHAjC?Nr1ixjOSbYygqCF`T)Xoe!solSX=@`+Y~I$l$#kbD)< z`i;-3DX59H8E>px$7SOj9$Iyz)_)v7#x6|%{5z*4XQ2Jydl44IOYnan*d-*|POTOy zUDF-Tz9X!9U<9Lr5Hw2>Utb4lWr2_I5vbAt7E^+;oW5B2e|w>@E+@DlgrxuTOAV&} zmj$Y$B(AfPPjgF?Y!rXNvkUE%85FRygDPCOvy&0{snA7(!98|%GYU@>dKhB}mz}+g z!;(MH=L67TL z_HP1BQMnYy0$C9C+sSYZ3F~k~nXI&Z{+?(mY?i=m?q8W9u+=}aifza33C4FqXHX2O zGXQ6xXhC*#B@8CVGSJ_+IpSkLJrdhPv4!)=g=oJc_L1+P56%YDpR}oojZ~9UmwWI~ zUy}xh({r@@*9_f z@iict+(L^TyNqJW9-}|0kJBE98N}V}98yVLnh10bf9X*}yx;tti91B;7tDKG^guJ+ za>qZOL^_T;M0iFMlT_RYC^LU_67W#n#KfaXihxG21unl%2G|YL;hoeF`Vho5wZujO zLiHTzHOh_YM%X>MGU6af6bxG_)w;+FSVwBcYCU|oeJ$iCvZnx;3JoO;x~n$K4xkj? zDr_#>%0U>=>}KHI7$?F`AYjfZJ3kAu(XaC;mFhyDC>t+vj9!2t?k%C`IJjZs*aeMC zlyvJ}Ov4nVa_!&PF+TWsdqEG_E+O`eR%Kw0C0xSu%jU=nNgqev_XyqBhw4((bJ)Q` zslq?o)+1>jaaH)t4KsBaJ?^JDa)P<9|ClnG#@8sw$Txo)donfUBvnBQ>j*MqBUzfV z%^cq@Ck=pwVmQ9JwMibxl-}_!*`Sc*2sMRM3g=XyQxp+?&I};kPEEXlWS?=Q8b6wp zpercHX%lfMQV+@bYcDNz8ACz)hhVDddFd6$A8|ErFB#ZCe4M0*uk_9YrjHor0#GQBS zdjkDHG9t}dpM35GgTD#a6n9Y21z@6l*|U);t^i_*GO~Ep`9^ex>`ej94Z`M_HoH$V1>qA|tf1G$~Et=gy*(ky5NM<#C(DJ~s zdNnZ*9IoM3C2L}lp#aatcx(SLQYn^)8ptnsub>(|!uc=Bj=dOOl9_OENE3JvNvSpN zMU9Afk1a7K%1|LEM4n=d6jNM4bE1I4IjM`hn)PG*jyW~Xf@%YUfA&~k6o69oLhKa1 zQnRUPs%d4cT4fs|i;l#$HiM{o#z^)t^mKn)P0a$QUE1{W49Oc zx2&Uyz`j&a_&=0s;$h5SM##}&)U&2_%~1AzDQVy1QTE=ymn+Oin0)zi(`B3|M1(Vj zXX}7D-Z0*1E!P$oCxGI+1rZb3v#GfNmuYH0bP)moFnPJ$$OSN;XKcrJ;7!4C*tFD+ zp`!bj+Mdm~)$(}_)O5nN{}<@PlgHy$rBtAq&0El5G=fBajvn#{h4$OPe_iI%{FoMNsT_GzjUa=#Yi;70X$Sm+s3L&4EsJ1u+ zrS&_t;=YJhBp0~f3D|NqKilGIF+m6`Fl&iRiD`p-v+yD$mW3T||0n;sM2}AELM~~g zeI_j-U%L0`I)u=qkio%XSs@^j!aXpm2AQ*n&Drc>m#q;Q0>B0<*V5LTXX@Zx7Fw*T zla+rFQd&UP3Lb^CV8JX5oDd|ebDS1V&WenmytNdtBV#VAgj<3zVc2gW!_a)$Wh5GK zY{JNbEViA|L-y0hnNQ*>OwRPIJV5__aBg7{%VI! zWaAt{B#i-Z(+OQcxJVN)G7zc=8`K(v*T5~ob_rUVkH?k`6<3jb0namx!RB;X7p#ZV z&b9%O6F;4+S&Cd1ubB^V^KkhFWlFtpEpW*L3UI;}<4K3brCzw=(cb!0Yiby~(G$n{jqnnD@FTV+ z=(4k*w8AcwEdTJSKe<}C^;A?)uIm<5RL#41-7bP6|S zKc~ig_CvX4Cy`z})5b!Snl7I5**Wb|Ml*T7seeR##_@rfPYOpEQ*lFcr;q$5x?5m6 zQJu=mNw%n2v+e>FcgYyO*M^4ATIb1#SsOtsCm5iX&s0(*^X01Y5{5T^v;oA=uR48PX3bQ`4T*Sr7D| zi5-ej4)Bi^3vKHVpk!F~OKmBc3WfqfaW~29)OStnnW2 z+FaS5&LjElLjEs)57jru1Df)5t=(vouM{8lorSF_lFpurr}7;l>k z6yQr;b{vgADlO+9#r@S8u{pZKbrEL zC2ipdcD8dHh;mQ2>Hf1sB`2EOmLn|I=B@?-AYk|<7!R*Q7w>#hgl&;>tMs4~tF4!LF zQ(GuPdcu^qI^qqs+FISxR6hl6&;9`Z}1j)!!#O@w;qAs!SI}H+`)$s$C>Bp3BC|_@nGt*tZ~G%k6X^iSOZ2~6~{;A@Q_@1 zh(hKR{w0eoYcTLN)6=j*63|a*yc(Yy6Jb4175A@6a0!OqLO*nlD=qTJ075m3p;EMH zKCcysA<@jU)|TwvN*G}sCeSP<{p>w14Jg6u57e+OmYU<4H;QK)g)_DF0M?6Q~$mZ8LrE%=~3HUn$&+LCCPZ3(q&Nd z;S*)}@R*i-v#+g;U1%7|vK&|V@C{bwisj1X0hh2q@1K#&gfUQwVOXKlLI^(iAZaM| zc9RJA(6lk~n9pd^2S4wgJ}*Bf3Dc_xe18EAXwFkZHrf@l@kC#1HSEZdqn8PkigKW+ zNUrk=h8wmC8nQWhnRUt0kS5cM#RGJX-ylGhLN|-SHripV%B2`aM|Q`|g{P)H%a%qu zW`=6Nt&rlKvbpfozGwLu8J8W*Hm35~(ktmO(!K;A9QA&}fQBr87?M_TLCtPZuma?+ z;j8if*js*D_c=bI9;tI4B_XmX(VibJaAX$ZB- zhl9i?eY!bB*hw#T4Nq?~pXUv5f7jtws2AaN?SmuTxZSk;QfZ+MFQ5Ts77nV*rQxVH z&WU-V-XTLm4upEs6C65{UvSH@ut!clx^xg zXSFq(&(wKgYn~%MQmCN`D~ezGC^g=~8*=r+`96EiJ>LBrQd6EVT6c-uEot;86K$Xs ziV!7_3xlq!S&(F<-u*PB<6@RqWQqFnT+8ZLpvA0YA+pmS#-^wj6gB3wtT1eVVIzuw z>Y}XW{jJ9F+aip?V?5Y8i4BNWeD6=a%KQ^U7<_|W@3?2uwp1(WBc(A~SLFVh%AbL0(aMrafC2r+9Q$vWZcdDYa2BHhdTh!}Zh;w(pS3j&9>h z>hjyiU8hqw?2xkN!q1r*j4C$3Y#A6yT{(9N!(`5QYlMOe zX0kYI?SP!848?3$_koqbkhRZOV-`boCHf)d7 zX>QUD_OdIPWPnIoe-{+D$q9w`Og4}*oqB+u=(5a+YLH3tgkRvsxDe}R@ zWoN%~DG26Ep5c6=S4C<~a78I&eL|I!EwU9P>SeO{m0snS!;HTinD*DeBR!y7wQjoNcv%}Bs`a>_-&1N#C`W}KI+vIIAxm>Lq1U2HwDAk)VguW|s3hcQAi zRXLvMl`2U$IxByQ%eU+W7LF`A$zNWP`VN^Blcf({uU%0Lx+{xGTKTA(!Ij_mRCKl6 zaFp|Ho!yAnSJ{n2-4qi`Yiq&56&Q0dTZO1ZRbi|6#fmm$af#K|$K{}gP4{@nS>%|X z_KavFM*~CaL-^h%BJ&RNYD+bxRHmFX#)~a;wHP@z9O*EQ53~OE;9&8L9JJ2R4II9X zVy`Jbn)n*kxLTodko+LyXP1WPKshRv@33r|1Tb1rTpsfCr>SZi zxf7L+SGZk0*aa6`^o}}+KYQgE1=udrORoseMDuw0so=PzlwJ#55&}t z#_(iJg`U*!9Cd7724T@@bF_!Qpr8c`U_xh_$8 zA8KectFg3Hwyy+j#fsJ?>H&`RwXT<g@`R&un4CQFVs-IWz*Jod+PGf~jMD{`4js;J zPtGBN)|3@ME;!LQ>65sy+o5UOXQ8T~1M+En8h#oR#8p!dD+VQUG!(+MYi5)zsj=1v z88PxJC@NIueEmdhz`>P{(_~w3CHQgvk#;^U2SJi3Vs&-$ry0rPI}H#7PI~898epGs zx}m#I%?A(z9EgNENA~|k$@kht%Ro8QVc_a2n*w;QyKa`9Iz$P-J#>WqESQ08nFUal zmrc6k{dWO6h|%q)J14!bVk>;|)I8X9X_ege^22%b&eU;9T>6{w-J3j{tgN zLp>hxaL_>uM?VZdbPP7f(;vVeJV9e?STAyLZSp>7tb?D2Lv|oL%u?3_eardYrt7fG zr4w}CPqT$Kxw0~f?w&82h#aLR9dYEd*SQk7@=HJ6pyW&czx?X*&sHSq+hwaU_P(HX zBdj}hsNuNjvzOFyB5YQ<@0sd z;@_-5JNobG)bDKKcru>sdef4lC8-v!%r+;*&tvOx*m4H5-Q3zjIr&jz%sIvR+BT;dZwE4vuBX;qc19S2HGNdrCjHvpf2z$jKi1bNN2kzg zUVZOma8F@E)!TeLaMk)DkafW!rbpT$xLNCwiE#UGp(FE zRMbr;%Umi}87>K1r;x-rRA%7kGv|xWaghm1Wa%c4K^@u2GUPsLacJ*`^n*;Y{GNW# ze)!U5)Q9X!DsIfY)~oFPJ^S#jCZdSb$*ee;`rTsw#AK!9Mda-VuMn6dQ}Vo~p! zM$RCe`e*1OP5j%+XRp1ACdX_YLINN0m|j!o!&>A%5B^|asXDx=hqgrJbG9stL&SKQ z_0M~(Lz(#ApzF{RUz{v6w6&z}U*Kd7IRm+u+ceng_}3ajTWq-!xY!_ntD1~@d({<< z7!tQlze)<~9#9CjL2=S?w*$K$4cM9|jUhlgd8faW2V|Qtdz?Po^t<%CJh5$-TT%6L z{cTdW@t}d-L2VVcV>!x?y~)(Lea2_4awSPLcLA6XQ7? zPt3Xlpu$|pddVnM+ShYfO{Fc7PXe{|rEx}g?Pn#6RFa#cTy9jRr&9*=_I8kccEi%h9qM5W(x)E8kv@~aa^59?P-&z4 z|84rOgu%kKcO+~TW7CkzBwx)Kx}RS&yR{=*eSha}%4mI!^dD9w9qPjq2tj~W+gli! z$MQ%+?Xhsx&1bObM8}Tb|AKh z1vCwV|AqeHjnBt z=NUDB+}CXyOs34kwp5d!4fg!OYdq*ar_UdyP;cEdsd59P4C)$W5ysBTvq$Fe`k}SH zJ&RgLw5{t*XU%BSLDL+)qJiUD5sHp?HFkb3)OTm-oxgf#0HCCi(1{67x0&0snU>%7 zlXm>tUBQA1d*eE3~mTXKPFmNxMQXmarrWP32BsN#!%{ z)HB(q8{v$M8GJfC)40N{HS6E>qEX!nS2tCc_cYcF`r_`QzTU_3a*t}O9cYW)@&!X- zKEvEDrze}duy!yS8*+}GaCgki&dyJpqlPF#t5TPeS?$V#U2+bdf0F9PoR7&>`~_=?zG~g*2_0M z>o)sAGYPs$$C|*a`Jbh_mB@HFJ9UHjvQF;Sor4%7H+IuKXW>57h`Ap9NQJN5NX|GB zF-#S-4!oQ}lfIcJqrE=o-u*H*V|sNZbOSpM+>!O|887;uKiD46p+29>!fbq=7@{qV zO$brJ7%q!J9&+w`=yP%6#}dg>5veN1M~XUoI< zDG2sFWpiHT3Ob7stah%|Ndedm*(C=j5#cKQFGY zUHjx-zdm~1t5=f?x^g8-8dvEpBm7Y4~6=S+N-Hv_Z+CHWUpSa6_F zU0nGIfgJb?cnOi{z{7_|eO}^OS(AkOaF=%WGcu&kJMpVdI0f>x=xYu7R1iT9Yc=6A z(60xaAuwUwB15RMCv~OBiec(pO9O7FDS26k@X6Z5xRUXp8NGF>84_xu-YZ5w0sjzM zz$oXX2b(Vq5hkltm>R@a>{f@sF9PH&NO{#?V;~m`d@eZ%GHoP%jmVZL-38Etez4-W zfu7HWf)4vW3WE2s!uc&Yam0bjeCoE;S zf#sSW>28~mXi8tcqcnlYisyxD`^ zD1-|P4$E-iin)B_tU%poqsA0$z^d*`GQC(7LrpsWqQ2afp{rAx%jo+X<&i<|e%+l& zMMSj8!QVzJ(9BZK>9|H|nycnwCvynSC-D*`ub`dP)It9{b{J^a&U-QXbu z+EwZg`xwnlv@D9QCq~GQc@c)Uk=XdhjMuoT@&{g|H`P~1Khgyj#e<_yeG97vWmaFE z!XbtlUSBnVLKC73(8_cn#ptoQw{M}ZIaE25I~mkv zWfhCWM3jYyBqQnGt14WZ(^*;4Z)bj>lvVDrsd|=8zfnUw_~3}R*8KaWgXcujXexbO z3Ez@-_c0Y$Rt$>?OdV=h4_eoqLDQSY3l3Y!*}lcG>R6hWy)O1`=pBuBbLhS!5)hze zhZGKH9owh%=40gUn!O|i3RlY`=NQgLrQpK33Fh8V+{bL)3lhH%a@-7u4ONr#oGLS` zg?`FFwz#OP@Fxz!v(+q3k*UG+*~tR%!Q(}kfSVlx6daZlS|%s6YyX6dMMCXV>b~QQ zx2LBU)Kqzj6VA4E+IqkAg2=OoJYU2*6C!M{X{D;Er@PJKCJxS8yw)g|XO_$}GdQNS zLLWTfUhcHRAM>=Y<}4^1&0B)yp?QJ#HA;bI2o1=WSG}Mc)a^;c=AoX@ZhF$yY{qFrd+LRB`KcLYoXEnXkL zH@S1ZjpIj}EzMG-a|{h=gn`G6+)zv%5|=li*N1~6p*<YjyZ$%mw=ksNiI*cS z*571PQPHfB%evD(w8}XLjh2E8B*QwH_~4|NBM` z9IMQqA!B;N%>Wri!-d6NQT67Dba7szY$$qKlb`$W0)ocoel(8+N1AtEJf(E3#v^@R z>wcWIleQgMzd()4BOb1rl-3Z2S~Bv5>zAul3W(9`e^-7^8*-#UCt7%j5@IIC@cSBo-ZeDF1^99j& z{YMIS(Y$P8^Qrl*R+>x>FQGPv8|weTAF+yExuO4+$ZYOH4s^iLiTn>KIR#Hu$sJbk z0BRA}Tj#+v^Bck>ub#~G`Cf@^uIAE+99*kTFj6|zdN2FN!?lXVGxQO%3-yMoH^K7O zw;pt%T_fp=UP4(*4Vc z_WLkzHhDLia*|{i!j8+%Zj)0y`YUMIRGg;Rhn;Q@ zW%pS1usguMhFuLlw;&_3WZjX#S|*Y#SEwiq;R$+>0>Kc1>@hfWEQna8kxR@cBlu-t zq;**Yb)?kjz^Axrm2SscMKzXKc2rySFa4{r3)HvP0REORkiI$TO~~;oUh6N(s*cK) z)ihP;SZh(&`E$4ey;lv^8I3h@9OUW7ud`Zt+Z1dp5gwTf;3kU%ey*ve+um2Oi;Fx* z1*>x&iJwzR}HfwMV{M>TIn-=CsnPF*Zx}V-rfll-xhQESabtm{#$0 zS=wz`M}T3S)mnCeR?RY^Kvenh+e=&UzSmiza!N}DZ%jMn^UCTg087)f*`^E*2V~LF z^7nXAW@o|n$Qq)wVqfG`3f~ixl$4;XdF|LbmvzU-u-W~QW}RK$QSWk=FWvt2Ov~D$Z zT5yR92r%rCU-Y)1_t3e|9)T<({{WAsYq6)%BR@t(HOSV1*#+{KIFH@%(w${@NS-te zt$FL-a-f^4b8xEFV~0m9C@#mq~N{CTikB!g+E2|dCy7D7FKq*rAl zNA;9h0#D{3A^N-syTyI!hkiBb0}H5Qpsk?J4fP1jS{m#rTDaCg$woL>9f^n=NX z%KkvpuIoZ}tyB~d5XQ)b;)r?k5c~@z=qnWpS;#^tt%_;jv-HFGQPDu?h?~E=Qgke$ zNwAR6LjoDc0`Zw=W5)&U7^4PHBb{LFz#I>gr6!|bN&p&i(SQga1ENrO)CDym#eCbo$V{?+n$5j+x zlRZL9&_nKq==ZD4a5;4g=Kzu%4Qqm`7%{)Gv)rlhpWa+CWsmOYl@j9lJ9EhUU}anZ3k zc**L&prD}I=(rT?oKao!7F*N7uD#r{R$3|+T8jHcO5|7B?@){0j~dhL;zJy_8jBKX z0?O{*$&cDrWh_*X?od`L)csmQQt-jFk4gTk-j}%<%G6A^E7V6M{wEeG zjYJoov$Q1LS>MD|s!k2My(1mu^RI8NIREAiGWzmeqI}_sY>yzZlV)GP9ZUJE_W<9E zk8y*oTJ9*NyP{~a457dERL-i=xtcm>ug+nz zm#r$ds~bNO(pb7ZmP1!B7)ufBZ_x!AEo9rM+O#*OKJ7ROmA~E5Mut15S#PflJ$r7f zv~z)lbmLKyR-26Kj0d?sSBt$K?x!P_H>E4Rbf7KC${klg@$CzP2t`HMbjJ-!wO(xCs5>xY619omz+ z6i`x6DY>BabaTq2XDhZJ8afneXilR$ltelwA3}hg5|!rxGVC8UV(vy;`6>uvq~yO# z>W4Q9ryy|^gd{8eM*pLcN0I)J&GWbK4^>#L%ZUvIt*7RirG1WDV(?$?L=7fz=bGt= z=S)||JyPcEK@N`L3aXqwu-qZn3VP8^ZlDMuXtGChPU!cVoc<2sr1PR?w){>C1 zRA<~;hL%$0Ilu1;-InNFh+Y4f_s%$iX*ad+n*O!^L6;}QYZ9x5t!Ejj`hPFfZA`1?u< zWQEyFKAt}IjurFYBX&u3LYkSc+>hX>g^k9k24cwAO|yFs3S7N)RZvSZxBuyp+H0Fi<^@qW-x4zm_qBM{Yg^%Hf;VyzYme1B$=4 zj%MZB;gehSXK|~g&+5-tZYGTXqlZopufx}5zIX?x*U3q+S64p{s6z+f`cEG}JOF)s zPHEGnEnxqA@QPvDwa}j{Sl%!fnQ1|+s&-bYb($Vthhr6T^vz8Tp77ZS(RZFFP7RVj;a)R8 zKN|b~1AjoF`Gxg@bDq7`=ymVHtGk1Pi}HC*F^uMS*cD>K8j{#o7Bheuo6lvtb}Oyz zbK2Gplh1hp*1$ibocK zkH&q`pkUY6qa{JMSgb;CXU?l{&@^;dwfKE?kw~W!4?b_1APhI$cbpjve)VQ3)NM`I z^cc{{8h{{Ga&*N1Y)Hl_!o{p+_{z)f;qI^R7=h<%*d6v@*kZ85mB2X{9&d0&FB<0X z*+2AQIs>Tp^$PDvi*_9iu3A(MG;sAASBIjO@Q8W5`tFcjxX+!0WkKw_4}w^e3+seS z(7rnKthfD^svpZ~6*R>28lJK$#75RgkR5gvY2Ajt#i~BRKQg!6Ex&IN1bqqfD!-i# z88s@gyX)9`_*^k0-QpSR*vv;<&5k7N6?DUTY@Bzn>v0v|?Ym4J)5e2JY&lUktTE&m zn#I$SFUN<#d`3p%^gz9W!4K}z)o!*fy0rax0fQ&<((+2lhp|zQq1|SFT0HPLzx;## zlX9&AtJ5sktoxbg*WKcsqN^u)cjwct(gsT(+|LDmlvr~Dsl(f7=;4N82^wk`qNjBG zw^vKyYy8TNI$WXYOQq>m)3<+p1?rKD_E2L@PFcm}7r)9zdUbBb%~$r1$97?3r?(D# z^KObYv;$-4W1cW?LlfT>R9$~j`O^G4@&6aI1NR0?VYMFZ^Q}YS+fqA~K45S{4Eq$b z*G)w1KL4*jcI`prJ^*69s-T(6`To?np_d+66jpfM{81P-(iLX9p*k=wPlJ3S${OSd zhXYK4f)5$eZP{p&_eA-IXlQ?g=&=zxvEHUvoxab{g9>FpOYKCgvf4Fzb^-5Q#O;d` z9^4uuUIe9Y?JxQk{ZxOGAIPlK9;a4BY|0bw2RT0rwPKpm&jwqQPWx#$&q!hmS~{C< z;-F5z@(id|+T)D!tQz5|n6hoO-atnlL&a!C3psscoH6RX66EDxV7qNAN0U93#pB`RefzGCF2+QOr7^@E zX7WW?@#iF754z!-hk1bKLSj?t)V7jVMnfO(@s?qAVM}jVulHVUiEwLHiSSNcz1~s( zW~|@-Ozx6$o-&y^cb!*+72 zNuI@CpFJ~luj2>fOy9k>)s7;|u|wu^{+fR$gDC%!-Nt!^6+BGRI?hj2S-kivzh?9` z!>P#YLL1^VvwbX4rdpmVJ=xPydA==AvN5kfa+d2Pq;(I_%g5Ho9O=oTovWW)pE1gY zA>_QV5*MEoP97SmA=HsQt7Qs8eUJa>`KPh0p0{&*fZNMK!!2LmQwwitbydgM{qOkG zz5PhqcjxEqqw>r*Dsa40qeXc^wU;KM$-Q*98UZz>i5*8p0h)Am`hMN@-MYs+PwFm% zaZsOGWz!ezw;A=eGw-vQ7bVI!#YJhkBXF5%ngA=|(fNudByazk{hIxqE6pVKoi;{FSiG$Z#qMeaa!4grLOK}HTAyAVqYdT-&sv$W8&^_cIGSSJax%5 z0VyAbXXkF#+7z#3U3`}X*6)pHzq*WXw=be6k-nL%(|rkP>p!|C z_;0ZQNq0a3qnH+}A$7x;3ecffxto$G+VS#K@@&iQ_ETPQ*Jf-4_!?b8zG@zpOONcd zSXIbh9e38wVc-j=QcBBDYGDiZb8B}x+^vYVLHv=#-gV1x6r%QLp*}IBcxcOEH-Os`WWy}Q5GbbpiC<3QBzZ^wYM zVG*G#+z|ZX+pabgj5byIpMG*GaXKjFeb+nTQNEpq-t-Sd%es;B6!~{%_fz9hSywlg z0C{>i$?>7_Zb~@2Kem7!>*NeV*ikP&aIalwgP9-Uk>Opp?hc#^Fi#fLiW)Wt=%DjdRaf)Z0?Drm#I0Gz)^bk0?E>~xXNEPUbXSHb-~Fu z(6zr@EyQ+rveUrY*h1hAk^NbdCv`o9f-BB0DyYsL#Hk9JQhu&7J}SfBX*{V^=nE{; z7NRk?486=lj^?MbN~+1DVVrGNGQqN=W@5e$Itz@;ccxI8zRQQ> zO?`z^P${BYvoS1oqG4~QUB*}>Y|6--%BbS_Rg%Y-1C%wp^`$c0#NCWZOgswBvBZE- zd@18hETu!_M$E;a4tgt$BXA_sSewl^VC*ZJgv!dAhy=|UxG6;Sjun*A4@tNhkyx2~ z-zh_%n7msrsp((}LWN=xZfB0?i7q74)R!GHr9@UrmPQkhBzqK>O+T3!Ag-uA=Z`PH z9#PikqYZ{f3S1>q_zcAos)H7Jt1IXPj}XSy{HOT;GeuMYOA3STJvGtD{3z5T6s10D{b$?6M+d)zI=f-{KI3TNTA3 z8?}5Ms>b1awHQyi*qMvAbXa`PSC-SRbfUrT;RC7>N{9Dn0{YOnwVkeIlF#xWd~{XL za!PkSty!}zxy7QJwS65JZlV8;Vgl_-cYbb8m(~ArHHmS(E{F1$p?tKyPT%#iWp7WW z2R*@%lW&E)!sdZ^u8~n~oa_9Kd-Kl-qlrijD$9{D)567Zo&D>QK(+= z*O_@k6Pr3)Sgk(V9}>H6^A|AFVLzmp|9e^VNY~-BufYA~8z}SyJ+gAPk4Tv5v)fP| z%L_uUvrXn~y&`##cH;(9uX3I?)t}KjiYOGFvz%KK%Tf!1+TB?3R;FHB z$QYNAc@uu4sO(k+x$gE0Y-{=T(7It2n}QbGAQh?2zU7jH8Z*iur%mk%wHC2+O(fBNnxT5;pON}p|q$Sq%c}CzxK8Tdjn-`6vSYp9_5SpPG>+h7^ zf`?IW`Fvf>ot~mqKL)ip_!ISk*@g(Y$$eFScB_-n33}ke(_|^gP2jU;(w8geaV3_- zJ{XzGDNWCAUM5$+P_!Y-YKb|`Pf2Az87E5q=z=AM3RNcUU7ISauUmW=Q)9pEUgdYP zyqLP)k@Yf#^sK6(bPGmVP|Q6KVXN{} z8R`K|%-JSJ^zk8hTGP}q=2@46B;S-H|Kik*;S)gauZi>6p_`9uDhTGWMsCMW6?a1!y`)Fn@BP!j z<`SdoD1lA76rV-LyG;~RCR*W`GUZ~AEXliGXO)mOGM$V`89LsYT($PsMWAWe$X(&+ zN!o}@T~tK%GMjI74H^BS=20es;kKm`oA98iW-OK~ZowS0KQ@j8lacjr^DQ18?boey5geY5?O{ooAHFGymy1?bv;W=Rni zH5}ZlFaiA2t(zaTP|2e?1d4 zNAP6H&33<)9EBXlN;3v7n%*;A#`{wo*XKhmF=F?v3JP?~H zIc7kU3$J;w(%hXz(%Qdwvc*p2O)ksmh^NdMKKdVR1wS_NtBEuh`*NExUtXh#dl^S- zf+=rjG4|&sK^0ZZYJT2aS4%G+J2{roidObWiFct<-_|0dogOCN@g3j1GO>fc~T zeUgG*=mkBm>aqM&(B#nnz1|x6Usx0>I}DX2{|aoc>L2YN=}VQqCvWwm+uO&wZa*K( zS49X9^GE;I(B>ekrpE5kL5GJ zfDyH#RU)pzTYEg#PuVERC~h}EQGB*=-vx_dgMn5;s=fbWWO30nIiu8{I~!-W>2r;Z zN?)9UsrNlQlV4skU zqIMYYIkj>X*>coAjk96ZhtTk9T;8a@pnJH}@TiI2SHZ+l8;Bw#MwLNSgAhVa#Gn1azhI(0P3-%3Zgh?bUa0=B;q9S(d4 zjiSyPy?D7hp1Qy!OjZTO>eHyPgv2xo$4ocLC}mkjedlR^LiPgmnopN^PO{uuB4VK^ z5(~>Jq~vjN3Cyl|cBZYysInEb0J(DVJT(YIrtPJPB8QiKC8_)R+N*$3hGvY|xhOf3 z-)MG1ThvOd)LXgVu(Cfpq>Hz)q}M2NM;>zxYs>|95<9^MSJg-_4M0|806}7Wx$M0p zQRL9FpA`8SLMNjWtd!rMzn^&Uec^l;x0w^S`Y1L69}oYj=gp-eHR%mx{o9nuZoJ)je z_9S^x=Q3xIQipuS<-ey>@&h~F8 zKf+?WvE+Iye?9HcpTa@oh8t@Z#`8l{tl^dr}C<-yn+L@Cz8I-NyF#n~q80{71Cvm=z5cgaYvYVkL?f#`<_oK3Gk4kXOwU$>}h|Isq4(kSuxcvjk zY5HP=o}|je{7pKc7czGAZ-}+!i}jj^W3Bhsesqqu!>BuzYt+LtcsdiP3y z4s*Te`oM3vfZZ93*T&*0ooRw2e*&s6D8eCv>uOc#@58wSfT$O zUYax0cZn^Or$`XrW4my!Yt0=54mf`h7~?>pwy^S59{9X=&S0)|?UQ@O0~(hQvUBpiji;c56$6Xj zks_dRqrHQ66bXRS#y75h=~kI8a4FE-$#Du2^EKHjyYHYDV#>iNU>pWR=F6&FtjTIk zD99rkU@RQxJx$$v*N)b;CaHsYdmLT5f4mg0MykAd1JL?@!;p0u8BE{77v3Ng7rycU z$E)R2GHbB3BB790(uvQk1>WDP43_~-x(Xl&P#uVRd{6(|8Z&iqliFJuGJuRgOyhap zFaPPjN;O+e7u|%Di*&(}Pr3!_iy-R7&aoI|7jbbthPapTSH|PQS)?I;8`E9Nd!5nH z6eN%)_j%m>qQ%Zsip#J}5{~}{I#a-(?F_4|Q2psjWkMNUc9q!&;bi|){#9lBH zXB~Nm72`*&L3ZC8GU==C9@6We_O#Y%}`MFc|pRF@DQpEwj zTl(=Y&ue6F;th@{EE_Qk1j)|lBA*DyX>=w{)YAT_W6@i3q0cuWj=KTEcjuZeegWAM zx13rHx)gAZ_FuE^tA zUP(#?moLP&f0RkQ(!eg>euU|e3<+CjxXiM?rq)P+*iOB&d`0ZAs+?uZ+`lOIz+2O% zO~3M**;#Z1Ku8O(j(foHQ=cm`oTDCiP5iN!{1D7J#_T1^;oI77rEM$KugERYRfj%b zxu7E=-dfv~Q$GOakMC)w)9f(Q(dRfCd~WJYjvjr+e%b%_7KWBX2PZcS-Z?4D2@_q+ zDEiUV*da6&3HlnvuOL?Y&d)vR1rTJlO7L&%2eW6XUrIr`AeJi5fKEuaf?rzNQ*z!S zC2cZ@-Okwhilm|)V*wr~$$<)p^dKqpR?W4|6OJXU`$yOHjcJgkOf$y$l@pir96t>5 z&@E}+JL{0XA?@z8~}^8+X98_FI;q*5+w zT|Z$;G7B*ZCO7e;-EZ>gmHNgnDLj?x9o^K`^#Fm-d}eJrH?q0UU0?cMD1v@b>HLB% zG7NzPuThH|`hsx*koL_2+*EaC!IFnF6hKN(#W@~&>`><}ru5ay+LFt=`CXyjGv|iv zzN5XBF~MaW#I!>|=}Qd4%h+^YEKQsN8If*;i#&M5bKc~Tv5oTkmB@+~teLV#%nf*+ zw2_6=g}W|MsLMNFfV=D6Tb;e`?fAMSY#!rnRt02OS0RRCl&b8a-O8jC+SaS;&UqHv zH~7xzLd4SIya1R#!}FvT(IC{L7rOnjF%$|#`#NE|yO-`+uZSa5_mGP^kF`wf7UtY% zGIOr(5+?smYub63+w=C9VLi;IcUVOu>lF~RVAI}a{0L|~FPIYm+ytxEd$ zr%12aIYARG(MhUiMZlaBC;Xs&HrtWba6p;r@F50fjC#ZenfHJh^~%>wE(qe@&!lLa zKJyjTazp-PsN=o4JpT99t?)M|nYC5-*d)l1wnBW9MGL3LTLmnqFYT5R#)=d?f5;n|77JE~yNCE-uV7;j_0@k@+M;>8%B-pWMu=^)>0yk}WnV(lBVs>f2MEy@ zQo71|N*qtBVUsukw1bhe7uvPI$ED@-(LOMvs3pq)lBbC9iQd`zv5NRv*i-x|$SaSo zL`uZvdqR~BZ$7><660T4iTPwVNzE?SpP5;KE3aRPMWHj>|ZUd?P@Kpor{QYp)&1ETSs|uc~4E=d8TJ^ z4GDZvpcPrzcm1aT)<# zN!Hx{S3=0{MFOhAU%mwKO$&z$sGGH3o9pg0x~&2HT)pe- z<@M)-NHyTIXYqRvBa?86xx1tV#!l~9nHZgZCq?ps*9SC8F5~wl_kGI2o8QS1A=e`l zLK9g7PvN-JSRc1wQ<5H~G+~6}0~A zE3<7`c^cI%imx@7mIS4uvV(8JHVw>^oJKm3=FR zmrQK%TS17uwQ~n>ObfXY>Axp2?@P(YOUQt>vY7s)LkgMuxr_VyoE1fSM{(^LSB)pa zC!|sBUbgrPnI8*iww{XCPkBF2o#0T*CYuHli@dDa(_2Yp&1F{WAT)cgtgSIV+xhrg zY1fT>2evz?9iY4F+H|SV0jI6)QFWs$_FR zdC+qO`(5cN#2&ED?p$APK~mu*RC7_lYH4rH&+c}xs2e7D_iY5**WZ`d#X3T!?xTq! zhAVxgfTpUA9G~Hau$0>CoSa{(txmc99orhq^0g77(*fb{JI8W$Aful9G&!(&3|D*l zWO_}&=*5(bS37V{r?U~PggI?u@`r==nwgI0^&}CCwAnegW12UEt)CwdOTE&A$FBHX zX5;eX0h>3&{RnOpOnvPj%Z;hbs|-bN4W<&-O9pf?w^~)!RbHu)`(8dhR|~{d|AB~K zf04OSbH{QK`qQztpieia2p5j}l&o-`){m7gIWT$0=f0l$I)@m|!&jVNY7T!{JbCSc zP5u(+Z^1bF`H8|3Lme%savv{zuZUMsM+Vu;H64}30bg}JG`Q(Lr%V42>7vvohy6hp z{TDd|SMOFCV)!Mm_N%wyKB9P^ySED9RQ~(ADYVsOK^!6CA(Hnf`lj>%;gIE31itZ6 zY039fQ|14)wX{7`aZh&@|3}AS^MsQDv>e`W=xha%z1SU(WE7_eb%`B`OG}S{ zly`pdc0W74$Z7D#Z}stIm&&B_miZ8zan2O07f_{ot<*FeVseV;)IzQ)7^r^ZKmdF2u)%HxaVeJR3-qJVkHLc=rHmquUBdD;<*K3CdT;kR4viot&f{^TqzQS<6NVwVt&AqmDRGe_8L!ZVr~ zDOFk?%c_xz>qJ7*lN%Dy&8Gv=jyX|$ha{t-La}EnS*ebb4YT!LiUdIm#yj;RqL<+F zaUn*EI}UohtxvJFFQ2b!-^4%9zR`6P48Jj@;ikUTm4wV0u-WI9OY*!^`vdnC2?)h> zNmkuVAZqXlLDTA&uT-lp-lTrI7>SZ|5)b+|EAc$(O%49Ahh{XV|^opZsOiQ z9N#pB5EZBQU4d{M{`Y!g;e4Y)oA{F3Pf}SqKpv>$WBBnOT2VvJZlVTY93J#nHZBDQ z%;vAp)|Lc}fN+J7p?Nds<(J|t6<|x)f*Q@6+CS$~M8>lzq9KQNr^bWRI>cZ{pUd}Z zK`!-5AI@;vvC(n#1@9+1l|lK03Hmu`8(bg}j2+ZDn<-ld_mjjY5VPb_Uq<>wT{Yh{ z+K^(UfxfZvI^_CQgw1n3HzmhUpJrfncLf6~?3I_7E@#f0Ngc%~`>7aN@IrH;Zsp#~ zuuty!xiNgcXK9)lJf;=^o=XJA*(BIu3sN(8CYABtR&palja4M8$u$tbhRr3hKz@Is zAK2oJs0Pd^MzI&^%i%fdvir|5H%l&`&C3;A2n&S8Clf7gH)a=)wFw&YPZyDbxKF3| zk%JJm>MQDknVpCQ->eQxhOy5Yez~SXaG@WW&dNa=pFy&XCX|LsXPDSouXAMxW?WrT)n{KC)IPI zhjL-)+5%5L-$4kc7N%K^yVpv{&s`}(-KEtNcCV3H-jZo8JyS5JzT~Nnflphio;Dwj z>rAQ|$U^Odjk69k_F~erJLiMb?-xncB%|8o0qg?Ed84BQta@_O*?JHG$UXLDBTStZ zm8>&6k+|v0$xn)#GD2#TlvMdxX$lMPc6v%ulL9?<40wK@a=uiie;|&hPIm2 z7}jBWL?GKoR0zcP0L%VMf0#L5JN~~u1keZpi_m81E1Uz+SJqyFL-crRHI(W^Ap9B* zKR(!03eMzVPTJB`QScVV--eQ z!A8~zg)1Ku@^Z)$XJ`Y(+lWdQw^~b+aK~(#${EeJ;Zv;^L%>|rW%TmPQT}X~uJJY5 z85>0eZ4_H4{d}QgA$N9lg=22qHD}Iy#?+K5RN6ccQuG+m$f^|Zd}jEJwDGdpVn@Bw zDrXb*g`t8)WzPFz-rdKyjx27eEN;0S2afiN-%`I@nN-gzKA%w8M9<<4Ds0VU7dwgQ zyQQVJdv^&Ft%6UB!0Xwjcl}w%UPpZdu^$2N_d9UHzvo=W5ob8Q{01u@GP<5GHp(pZ zbcE@Vb%DoNe)s*cYx~b1i+%s$Sggv0xq|b-fN-Y<$+%R_7W7nF4BB2&%!o91g%2 zqB`LJu#~W1TiQZ(n&WT|&9b2-TL4cI|DDErjUf$*SRRNQun&twSsx= zJpJb=eB>ec<4M*lXl`sn6mSnb=i&4~SFNvD^hRUA_7<<~Qd*zcQEHx1@P@B!XcRr( zQugWh!tMA~jhM&Ps}K9G$*Rfz%_K(m3=Co))nBR)J{8{hpfU9GS;YejVz)D+vlF4@ zU2>YQgUp1bTiF9`>!BH=POtQG?lKIMu1FD-=qEfK<{Tb#KOMOK603Bi{tW>lZG*F`GT%%v7m|?3 z@poUnEPUXqnA;S~Z1N5KfUWHVVyhm?h4!{d`B@L_)woo!Kv~0{#b&9*tO0i)+{Cs@ z^R`mwwpz2y=8@BM{+p*})hjPx+k3)(Nx}uz{~9_+5-uvfNFc{9PC8POhX;jn=5E2H zOV8b5cz?I-cTWSz-$z0VnaavEW&6>VV#<-q(wItyCC`>u&q!J*8DJGUWchshzJBXS zNEQTB0|9j1$zjM}UkL9n6X}i#32_|vR*mg&?z;=Dnu1P6_OTduQ8WBt89q0UBGi=| zP;+Uv1?bD=g$6@~1F;wZ4F7?s7GMCaUWt%Ydr7XIyIYZCQ{kOdT7A~Bb@=>68TnaB zC1{$Z-)SV?PL`!?LJKq_(xlb)yNi!wz4h5~P)B(;klq3(pFKS@SHD`H9T+K0bg8n&W-+R^7!)chi$IgM>6IlMh*$>VR3Z?ks$4j0K3;vBB~~hSQYQs`?y)@FikaDDS}eSo zD~tYVv|RGl!qCv$g|e9ckCyg^bNr#)wyrR#D{EmgfzO3^cbN3m)i9|LFum0l{Ijcw z02034BK2VJN6 zCVHI`fKsLYJp^M#w0HyJ8*Oe+muPzNQNk7D339Dy~u{~^ki`SR9eZtYVN=^2+(@%$;PU04Z@ zC$&u`$v`4i6Ba;{Z>WE zvwC#Wx5d-^PokxI9VDAWW|!{$HWRF~elSynk4OA`ckDdEh{`mk7OBR8-^1!ML! zMgXIOpOd;<&X36{y?v{UJsU&kVH#=(ivg}qC1?=Z^s;nG%>PHrBwsRb7D`bZ_!7iA zL#3~*D+>@%kgl!H_frkni|^nm|Du~St&4Y>b2|izBJSS^rnXdTbbp>{I*>v1H$$`@}N&!NW1mUM6QcbeF3{w0{k9&?s!cwb8 z=q(C_Vt76#&oLv7>s9!q7RmKvaUUjmGhGx0CBF(QRLs$-EkWz06NBEX(%kz;=-l^1 zz}LPdlmx%U3zC@te4`y~jtNmW#x8zyn9I#j8iC+|j3C%}v_^`yqNsN}hpr_qrv)4| zzxf2D4e2V!K}0M$mJp*Tmr?P?ybLlrH<@Pf6s~QmBAmLBOsKz_fm$gn$yw65{2EFR zw^cCu^AyKEB;$t|paI4&Ra}X7& zI>$kGl^9jKE0oS~kYPE^rZB3_k%mX-B{Qv_l10yQ{HbfnMRk`^S!)h@-MJJuK8(uR zofWjXR%LQrWzu7E-umhIfPu1oGmZP?W{P%q2#x6|!*E)d7dX>0Pd>*}ytt{n=;T!x zq4vs%7QiSYl!5Pxz`Yv-#Ec>)v-}kMm?H|!>~C`1oo_8!IYqilI|Wiy zqGEJyJawf^~9Yt zowG}6IybhoOc(j>K_!uQ%{zALjpo%S{nOl>^a%nH-Ar(j35&$o&gkTnnf!EcRt>q+ zCQsRM__#~`P_p`=BYVdqcFUIRK(G36qUtc%)2e(YrT=)+GbU5b`bX}-%PZc64;)L5 z)#T>`qqDwrrt~za@Clhp2B*u3CAZ4)PpD*Lu|?AVN&bD=oy#R**5##Ogi|Iok zW;hNzaIZ`gaNg${OEg~tG6T&n*eA|dV9V&B`jzW{=T2X#x)TCn+*jeshB25>RJ(5Y zka;;eDNxbXp9TRt60wGUB^niI2icmn;I|8%K058htK{B-v)Fq*NmteTWC2{>H@pTi zO;`(kH*$3CeQz#qC4xO6zmk8rZgz|ZB~}MMwVijJ&{F|9gd<)ZIpZ|YyWI=?W+%r- z#(@XV;m~lnsJM-Dfz{%1MFB1FhK-5QkcJUvwTSvkE@inqq@=(4&+xY4!=Gm!SC+EFmTq z#pZ3$4VMdhhzCmVP5;@ca4Plhw1eAmLs2DWM*i-{SjZj78p=Pf@ zW*L@G<@|!dQ57b(p*-qP(=}su1)fdRd}@qh2>=`%nsMDd-s>|7_@V=Av-B2*W``cy zlND{-hr#+QXt8P$LAQ|Y%-L-tZDzP53ts%TrR&bO^~&d8)q2Naxl{(TT zBTpz&d!;ekzno;gf)LpPV}H;k8HE2GZ|%P5(sebAUG()H@jYKW{)HBZ%eZu345@T~ zewQoQjZohc&WTKCqR{*9~uIp2*P{}bC@`Z%%oaN-V;=utqk zOe|Z63EL@jo#kfimfN*g~K~_)4Lc9Z8W`vN-JT| zX>Map7rQjOnA2W5%;-XVcF8S#rICQ$tKxoggP?M_R}oPv?snFVrcRXIP8yg?x?NUA zh^X61?Tm8fyLA;sX!hdgGDzS2zL2h2$o@n1#FYS z?82K7g)OG$K7Io2|A^sZs zHhPrpTdvj*qj#e@pR4O{y^02qmu2c1;&+d&M6SGjq5B}fHHo>-$fgFtaMk~WqwTUeD9 z?6PdPep^=BEa4!U$x^D6933~6kn|))MvTb@`@OO15k=C73Z@(#Nrb6}vl2&JC2M3j z)g6L~Qc^@PcqfWdNff7g5e_3`aSE_-8Ziz@Koc41Pkc-A#*v0PdT(`=OUicnyXk%z zz6Hb8FAXOo##dqDL=moly~P@r##&NenN_e9U2D-WIKjEcRy0 z2PM5?(H&6BOVi(v>*C`)36fxL16}}$r9>s9Fz6_6|Jkd7hba8`yp6O22!ou3_rSv3 zj67?{aUVp{T2|&!*R-WPU(YLL^U|R-YDz@FbY?Ib%Z)T(J>j-2VUOptO@%a-s%tg^ z<(StbE>a$uMUvieI552tri0-pc+g_AG&@E<$Al3@K=-T65kNAtkuThPc6hV$;)nV4 z62}=k=8U7je(p>TmfM(^OFQP?5)=J_9Le=>NaQ-t=jNPqVsp=)#}xPP#^&T;@swg* z%6HPiI}U`<4~ZUhd=3U{5x;QXg|%2Pnd}n6w}ih|L!~}k?Z(kEEm*7-m>~W;n^G!v zwPCiqQHkv4K%(!Sxse*m_URndN^VZh%4rlv*n_dzFXROIubFp2Dq&<$Gp{>Gp+eiKfJ6qIlu|t z^om#(w;du=;E&EEi(e~r3Ue?G}!lDOo&<)B$$ zKqxm!gjoi$Fv|qAa3y(Dyx1O1inMzwELU2eDpcIqnfB8oz^^lN0V2W!7Mnq1-Wc_EKiz~O^a-Z zN-zn<3WG=@GYbTAGtjzGjGbY{b{kTp-4kJG?I<;|!)%dj2;lqEv!r4KKPG_!q409V zattqO=dk`k^0*vGiD(_!%p7;C#m#Tp(3|@Y^`S{`lECDGI9jBbaG={{m&}kZ z(jqYBd*h8bwV(;ZYp~_=XSl zZz*vxULt^8BGYh+5O@>0dpYm0Fq1;dA^>p_%|2$2xg1>+9$ph2^YR=5;*t;J)q&p9 z(g?wUI;FIfP{~Io1)w(yU+vz|+43#hJ)>7EC2`3z_*M-Mo=fse>kExL>0* zISkAe(Wq7`Ho>;BXFN)ri=wIO`hL~saG={uN2DJsna$zPdj(DjRTd_5=S@cI*1S9$ zK||<(`ereCPNgcI2)%mNb6-5386M~#TvoN;w$C6VDd&ysibkO(V<`9>sUk9+9UA2O zzYZrA%O0bEfD%ACivHN7@@)t1R6wykhwSLIW+R z4YZq;ofl?vAi5cnZYU`VZiw-V zXYbF>o_VmnwH>szDaei{7P2|1Q_S5>3}j98FKq65XcsHV{vhIX((*)y#QVS6uap5L zTzy582i;umozNk~sp8fQSOTwsRuSd(%dhA7bTy7bljG7C3Kv`J+vXYB`rgDemPsi< zkK~D;;Q;LcV)|jE#=3zr?U$3v;AUf?DK@N%UBTuUFv&?01euzh9=Hggi8&)^jlLJd zvk=IpEaCReXc=c3hoZ92Vt}$q%$P!GtRB=EdR7X9ug}bYvxghLDx=^i6wia;+MN@2!w_W{!@> z;hBXPtVIYkzb{|w#A-?vgtSubzsrx?hUYiqC#xSp3GdW6XQ#%r!y{a?Dcm{ zb^V{T9{}-T(M#iBflB1r%5EFi6IL<{SVNKB33WiyWZUUgLlD2jf%-Vexva9~eMRbT^~wEeA>fpvhhtrgrpdsc9_9ai_woZ;VTrv`=e zwbz6+vCPjm20N;Q8zuu>)lEPPk(f9$D0w}Y*Iy1qj~0NoZu&{}qM2^trFKhjPtNXW zI!X+IGGWlvU+l(ndn$3|hZFt(&H6K%|(($ybH#YhS>xrU_%z*TZ+@ghmh!gPdM5>gzG zIXDhABjF2YuS+Mhqiz2cR|$639AOj?sRBzU5^OK8BU$BK_dW-ce(3sd`CL4AI58k` zW745t`*%EY{m1>bMbO|s;Ja!LF`c4L7`G$GC!l#OEKVXO?O89V4MCQ@%7$v5760&n zS`^z&A(wHu?D8fWs8d4n4;uKH6V%p(jnH$K6L&7skML1}SyMFP%jkFo=)5jzf)|cVk6Mc9x zl1Rf6h!iA>Ou=Q6>3|eFwxgSMxHG;NP?f4`6x&Qpi4jqe=Ow4SGBBAmePxD#UxlFQ zaafX}dfPD6IbM`XAxTaN znq%Eg;63$lfTyxE5(-4y$ysO|wU|sP<%+oV?EwiMwP+0^hd9yLo^YxuDP|y%5FbMd zCyRAtyikh6awI@iB%ND@XOxO@kQR>|rP0MCvh&hWHcs()e-UM7fG1LsXc85hNiykF z2C~!})UEeup)_D=xc=II62_St^P0GSsdjZhd_IrG|9&oa=pwW`W(^h{dCI3BiXRf^ z@_z$mzARJlm^l=kl{O{lUc1 z-aOb-y7?*SozZLEMuP&QyCCG?{uBx&MFH7Co*SiX{n{1XN8mQ3o=hSogIU!ByKCq5 zqi!ILz`Ndu6V6u?fK=lrj?LXuR%P)&QeS{$ke>fEV)Gi_=!1tH5Ihm&U)-oO?*^t! z{cqG*kx*YqBbDaK82lN%W{C%__@{PYc&bZ?y$!hmpev!pKO_|1;R+UU#2ya!`1%1_KGo`U ztvym!Q-aZh512ilK{VxZwK>TQgHRaZUric7PgDZ*1-p)Hl9=VILNN>Sy$B z6=W3>6cTbsdwwYV8fffv9p23&n;$~a!d){WL?VEU;Dim4$wGrr#mK$Z5-I2FnLPM_ zd8e0+(;C-jQw!`J+Hs;fIsm}~AXcQbL*(Pzrw*7av_zc-_)s?E3al6u6vcY_VAPGR zei!W7;!D-=tBN++_a*78@yjwMVdT%TlD(#bhEZdfn`O#aqTD0>y`pCn9 zoE$y&^N9Qy+TVPk6GRP+@f*#A48_Agyt@w_Jd;TV|BLqPjXOY*4GB(o;D0@rJRM2U zj47$u*~`R)q@!{LvS}hO3#SusykVW7ZHae*^6DQeL>_nudDkEVVR0}Thq*`H{jLe% z>Ys79r`E~z-b;tkn>z;ibyiyTKUmpIDNS>q1?Bh~X%hd+|3vzD>E?dGe87cR{s6Y# zLN)yJNDi8TICN&FDzLnZ`iNc7_~5+60xCpf$gV4(g}ZSDN0SsA*`ocGuktuSke0p$ zcKl|5W~Kz~={U=HU;7D7NtNiHE4ZSl9z}v0_e3YK7ueO$D75>PY=go)s-M)rJku16 zEAX6RIJ@#d!F+Z?kC@*PMkPEqIh&7U_~Odq6vMt!H{z#cAU5z95fM@S2ye;sS=c~$ zSA)$dJ9r*&$xH?o7b?W)AYgg;MT5g^7n%p_M;I-J@SaDw<=Fl0raq)tI0;je?O5P@ zfR7mnB)2LRYY1p`d3rrZe1(XYB4U^em+q)y!ViBIXVAOuH7 zx)=NuDQf85>qL9W&6DlFCNL64j@{@PiV?yBwu z-SvWf@4cG{}6!{gB$Bx#wSaF!Jtk50;dFxKjfEt zptme+x}WDA)xEuVg4gLaeb}!5*}G{eCJQ4zhEsk-Jw%-w()S3Xc2vT(V|tDMeLL7PN-hWK4FxJ#D`Weej9Wfv~acDu%8ahEUR zWDAmt6W;9U*-0RlzM0THX;2{Y_+IvUBET9Y66gaR>udA#U`JZxxFia219eH3EP(2r zl1hkhbSsY%dgAakBA+WNMr1OJQHPo~K6>@+P3*2E%V2te&0@Qglp7@T9lwpgz*_U4 z=q-VZ8+hB~`P~<<9PQHb9KT-0{9lDUAC!ZiLHl`v543chgLxicj7RM96DWY?8axet z`#s4>oO(s%kKJ0(^4|fN4zOCk?Zf{r0`J@N@^oQB`rWUAot15aZGN9sK0`q<5Q}{W zeQAmWg0|VV9)NZfw64#=0qrieNO1dLw`U56b6SWhb$JB$1;A8M29YDhVoPo2g|B!3S+iJlr zj#5&|wdK`6o~TI${*U~D-;EJ>!vj7fegH|k0l<^m-+hMl>4$bP0C~Q@FsDj~Pwi~T zb-u-EKehB@ES%>i3QzyhJbLS&QrI){TK0}+a=xEGI3oKP=l==hDV>f3;P2CBrM(BZm|ia+jL z%tr~p2Cf$9SrYxj07*c$zrNb^_BA+e)CWSKTof{_>-Af4XKt+yTs%fmzPuSZ9gaPe z*Ei^NTaAIbyjrLDyKJpK5(VW#0>99_1p`~;wuCC*syTD|AVF*r(fl)uKP-+>oqhdhG58LqQYtJ(L)-7pX{z5UY)%|>YOOsxlt26J` zteGB#V5V5+$FrwdemE#TAU>Xw2HQ=(e&a)CdXWtHK_A!7*c^11e%|kil(Xa8UXI}M zHn#tHHtt$I9e4Iget*HB1KGjzuce`9hTE}VEMR&!AAg5V!=HMB>65mv%&=vV;#ktr z#ul_6va*V02E|JKFY@TuO5xN)&1vvmXf^tcQzW$g%xn3ahPw#*iU|I=hkvxGF`X@N zV+eUn4%69yla(DQ%NwJFmkT(e;yye~(w;6cZ<5^*EvBzLMS%Z_o1R6FOu-*>2Dp5= zYDVGKS#(9)V-`W|TROVkU^7DLaXEZ;p)Y+j$_(`qlyLo*&hL6-4KIK+uUEtj`NOB9 zv#ZwK>G#RpitG0axvs8ne#o>enRZC&>>5P;`(d5z6f@>oH~)STYX(9QKLd#rl6bcC zxoAya^Zlgmg_vjTDX!eV(twy8E4IUY$;j*NkFEml2ZiNta>?oU7YJpx)8f40%?R-9 z^T&?cZ!MT>N*=N>2J5Pmm?(yY{4OY@ufImL^zEfnuU4_yZ+@(W*A|Id5+n!9^YP2& zGoAnbo11`tU^+f_U>EE}d)k@mGM|1B8nr<yU}BDL>qlu+3}w?(S?s&^;K zYn83=M;l+4PJZ}PU&ldN*X81rFUN6raD!6eD%A&gn{6#^Fs-Tqi057dOwUC8e25!_ z-+C<2SbT#Sn0cICc&(mZc=mCAALPeu{&z=*sh#IvL$}Qgw+(HdxK0uqjkDsyV|McEd<`B)n{#@lv3JKc9tGgwRU#-vS4czU?JEU7 zLpy-Yy$je}u^8sWX&*ROYXTGh$^L&YmwE5lWv!Z-V4 z#n6{df`FOn9YA;eH?(8O6Jx)KrNfUw?Or7nwf~sg{O-U(!BapZR{)KGpt&OT$zR5X z+s&N~7k*`Q`%nBbHs0-Mu0Qh|IX|mDy2NR0SYmXjk1jKs>X$jkXHArcf)%=Kt;(d_ zKaUmdxQaSb-8wY!mQ5D(hUWJjxoFZQVZlrQnTE8 zfru%{tE}}zUJ!BRUAT#%E#7a<`A%zY$oclHPhh(+cj5j8kf-VM8SMRn{*fKyt@P+?43)6?WHsNqB-PiX6G4a|Bk(HohS7}mWXQ0S-BIi zhqW|P&~j-!Nmxik*C-2EBc+Mp`EkuP-bYhpil4%V^p5sGiv^`F^vMT`-!8v@Oqo2P zOHqwaz*4PahL}lNs(O4p8GZpL=W_*eK8_&g3*fO-N(9(rZLgz2)sQ_WX5n*d@>WD; z4K=s(DW^oZRY4Tls1@)~wGO|l#YNPMojI6agZXs>V0J~#!H%SORtekIX3Iu1uCw^U0~n;p0%CCfBST*Ml%H`YB0s0*g=|X^sqdH)@pJ zSeZpg>GiFC+YOt0#m*@UX35*K07*&L?u2M=32TY-H<3jPZ#QRuqrYuLeXs8uq@Bvn zUG>0jLDZzy+`Li;WI5?=qSZ1nA0Qftm-?2ceLVS$?PaVB`$m)(3jb~B=;*v(i~C2V zTyDF!&8Sq|FoWaYj>r-S1+kmF4Sbx))v_$3zTAvjEmSVfJxQ^Qb{(+P>y915p!SE#wgjS$tMWCn;Tb7+ocLs`t)74Ec7tKOV`Jpde6IW@#v;{&1%MAT^9% z)kBAOjT9XBst~xV`ya|^#XcV`$jA0ZCo)g=)?JAKmF(eHSo&>pS;?)d1gfXZNAEf1 zf-)Jm<0B=kBawkE{6ukSA66-CgGK3!jS80&Gw1GPH`c92WcTYlQ_M#G2*%_i-m8lqwL)kz?SdT@$12V-ko8jEbhDY! z4_A8YeasnAcz{z3qnTJOK@|s2hbEWErN+K<@#T$|k`QB>>p+`dV9e8K5b?j+7z_5i z8hd_(k%}6)8cEIGGkt7MRkEh+sqdvPg(K|+LJ0ha@Ao&G8cD78ulAu%Qmr*41h-}_ zLYyR8!w8If73Y^1Bp*+-arT!=@*JAh0txL+Q5|vqMn>V{JI$!CG@HIJ^m>-bFS~5gFD3p-08_4rvF_S9+epw_Uu`g8EK})f*rqAg_CE%G(rImg(}#ozUfY zn2HTb^EbZ!R{aFv2X+f~Ivf5@vr7ZU_L=-lq zwz70un_$rD2>PTn5$a(sYVvw4Dvzju|JIm7FJ_sL3Fv8 zY|m47vBSHwQ~9l3{_vg=>~XI$#r}7?8t|uZsB85N)Vt4#t;HLD-WV{U!J6dv(t53f zg7IsN;ZHto!F;EhPNRgj+lPW_(Ked??p2|IiU9RwySos24sBXYT&zlfzI*@})Wi4h z|3?V%Er?@u?!v9TQut$ftkG-|iYF`|Tsz5K`FKKT`oEn@*hu`D#1h^oM|qe=OvE(?8JsRJi4mER@`SLQ^`#?m!AyQM&#cr)H3D;G}5aXXTi z5}mSPtHgqwVt3yb?51cX`r362bM{Uf`d_Y7Z|?WnzAn-h61NE2oc)iK{CVElb!?YQ z{G(DoLj2R-c?`cSpbz<6x!R;Ij_eJ{$wxC7d1!_`vNuM(0KZm`PG{s}8M%=?(qD%H zmVO1WbZ}xffJE#726g|lkddYq4RL-Z&aqX&6}Xb_)Kd$U6#y#gGJ2t7O>{>g28s)! zY_(54vQVT#Ho3W}y$CGi0pJ9)n=7Mw_{|{lm)*hxqHMkPzVD^Bna$-Zr^$YUasUndWo ziPLUMJW7;Tk#csb=As)l)hH5~l9ictwaGiX80dwHXgfO7w!8iB1V|m!STAu4%`iTh z1b?-T^wNCi{Uua2=bcif%oo9-NcLScojB0lRhVk;zZ6+qc$kz~=Fgvtkz0)gh9;#i zUd7PvUo;xjAFLrDjeS=`*;vu-j+tlez+?`qKJiOmf2*mp;o?ukG5_&jhK73`Ee%^g z)91$3$5yxv4J-AX>LVxXo9b6t|?N6kL8QBJw&b7wYJWF=QPIj{ou5$ zw$f1Z>#4aL3vM0I7TJx;yy18S$SZ{V%%lJ0c2%V`(@`Dx^U`(ByT_h?u~F%jr)skQ zRK}D+_QBO+l(DAmm`ddb+GP#LXow;UY~}K!Ki7ap*7tOkm$Ux__?7f|YSx!|&pU6Y zy56;M2K`f8Z`Gszln0da;VVU1#=3?Pt%?tn%G!>TGfHyOR)IsoG=OZ_^JsbB8-OOG zysz^(CK5qG<{#yNbD|ovel4To%KLY&U_(!>Aes<$)jOd%m zn>|nKWG$onCSYb)z9P<&rkJ;_@1YxOP8ZWO*EY8l)1t4b)`8#UXx-NU`wU@#-YbHc z)2rjUq%EJJhYTVPNHqrSbJwwiPT={%e)e{MdGC&j;1uG!h6D5ts$<&roh;|Wz8Zah z?_O&U@*B=Mc(k?njQbz$dyL}*bmpSUXAGAn1D!$m^GAu1YoUI|Jzk#i?lL}C03oyU z$#e0B3hafHZWU}oo1z(?gr(4`(lna1N?KjU_F1Kd$FH=~_5`pMuKG;t)bFfCndL}A zmS1)}CEAr@kG3KX?&agTZE0mk{QiDNNsm^;jM24;fslSY@qvHOBy{*PXn#OrOl?jd zqfUqZ^{@Jol1dGpx0iPiadK+{vq!rq@qPxPq6!HzZR2IDYRtXCp({hU!&N#cW@+4A z2TiXksvuuX3{+(JPyHzgL}$yD{$0Tp^Y#mCFrZGa0}#j~0Qq;#{A^mtj!Ms)xmDwS zS?!mrQ-7tbn#kF`#4{*9o4VnMz<6_G+fAOW1$Ck{J6l@BA=QAxryKZ4e*6RYyrzV= z?O=UQ;bb;q*`e2vd;Dl>l6%S$w(3mJ8(WoRM`X(t+PZ4JTz7?PtOK=p}k>RA6Ru5;2o`r|TI=VKAYkG%BOCg8|Z*S5TILLRuL}v~^%kW+SM+ zh66E!YTgI%RAgWd!+j~obeFHs;s8*N^=Vyh3edEXQTPLqeodndL(2XSRX03&<9@0H zA#u0&^#6OJA=^)cLr}3{7U0ReQlcFwV3eqtW`aUcd<-uZ@XMiHK-K6GfILb>#t=a1 zs;{v$MReBzi6gW3CAJF-CC<#=465@HqrNPpkgn)>Zb{rKln%Y>{0M{*vK~tQ84qrnv!3<&CW+k#u-)G_(2F+75Kaw_{Zapa{S4Bz2cqVn z7i>~~zt?9w4{EDD7Wa>hR{=bK^her)N6<66-hOT;yZx(<>)Dvs>hsndmHiFuSCtJ7 zmDs5rm!*&7=rRy~(=Rd|ygt}E2)@0{dCj@%e$B4>e=SUxwee^8 zn5rF@r5AF#n>^F@4mh}wo!`|rVtZb^qE=-Vw_Zj&PwtMFl@T&a1ogA*POjKnqKUD| zv1XZF3OPIWc!4v=SaQw&_Lu6?9jcezOK!skSK)u+>jxYH7Z!8Y(IYtIk$twS`xK6{ zX`Hh9E&=};Ur_Y?d{Kq!FwRlBPsUO)W|~Up$cJ0Ssf#r5Q^`D`i94GAFEW>Ecc|1h zhwiU8`U)vkIUvsG{a`o-&&(UsM;-$xr<~!G$J0T8SM6+mf8s~*dCg(Cn^^=TU9O{e zT}A{eE6&Ye>jz8`=c~#5EB#prV;7+v8sYTm=FS}?frQzn#=du~^V*N3 z;Q^?qFAbe3Mb^V@-u{@4-^t$Wz1KfCW-6eh?grrV8sGB^a9-HeMSv!l0sa8*f~#Q2 z)^-B6pAuiENRZYYhUPNug+ex`kJ$f?m%ITd2Z7x>Z7n&;NNG{S&92jM$G5hBV^8BV zYqZ%69VdfImD-1E z;E(g#I-pJ4O%)G6r`+B7&!C1=J~(#!OeVI+|1WZ+T z&&g~*B^kruX7A6_ET74s`Fr~!!L2@;c{L9V9y$*tGA;)!Bv8`NWTsHnj>T!|Zb{J9 z^-&+L$aT6r^JjU2$b9I5%fbm)qNHV|7)u4?7UZt|0h+a1O4PIbzvyIEDJdpr{X}alx1aS!veK}Rgs|v z%MyXfp#6d~y;G0+7LFZ3{oPnI6tBI!GIR1#goz%qOhkWtZ%}_8cz%eA()Qk{f6iRArN1X&3DR!zk=Z#%OsQ{OwtlJihfvuQx|o5v`N6n44P4PZKR=Mv zWcMUY5J1$`9o50c;qHcEPQaU*;g0B+lT=nWcS>nsN3M?Y@M%T=73rfjcCQa#sj=I- zc#{8&MtT6C1q4%dBECU80=JyqyvVV8IC`0DyZzWA|5-WtLs{-+mA<-}9@Zo^>Q(z_ zjsKe=4kw{9O~Zd{%<4@mKzbu-Evct_3&vm@Hv`4QH+U$+NIUZe=WI_$uz~m9=j7L( zBpISGoGEZch>$1}Z!v^mC~49RrR%d7(wgCjQsDP@%ToZUeQ=LXRon|azI%6rD=(dO z$kO2UCNJ;q0j)wrHo~u{z26sM+lYkVybcs ze<15oM`leaUsIs5AmS07Y5P*Uo-F}8ko;1!fo=OlJJZQeK@(9}6fp&Y!efzdOjp~W z2`Chn0EJ?SC={AF8bdt9Lqe2rveqsTsgeSHPSNM!&4ceRm@7KyAFOYEJ4U=btIfPi z4UEX=8AveQ|0Pgb4pGCyupiRQxWK`9&uw55_JDSTqvn_iM8l86wpt@4kHZq(p=vO_ z&V8la*ut8E`L%xYNf_QBx+{#0CJ7y>R%X+e-BHlkm|^Jbs{#!WRhz3AB$=N4pBS2_nOR7jmZRD z6gM0m=ygeX$pq|=nmQu;K+`+4W@|*~ylm3mn)z(>^LfWz5ueOQ?k>1{0(QT{wp

    z`k&T*KVQE;l!C%kA;LuWg#704g=j@&GCwOGltp46W*u1aBX{d+!Tr>3t=$kbS$U>OvjgMuU<(3?t}I@Rhsl^VT1Svgwr$;ZZ%0=4eDKkX)o&i z3q|sk3r2W$x2Hh&FQ1+dp~_Wfz>^_Y=I3*K6ZDmHd%4=56m}JYqk50-mJ=gW*8J_( z@P?HS?gG2vGxcY{TEJ*m2bJDi-wTdXn+6&OrlmZUtAMf;u;YugwO6&hXSuUN!T%a#cDZmiTnK(s3 zN1-?{Br`4(2wJi;l}vy_KvHpbL7)@@nA*gWX1U~NW3VvN0djj>oF=Ii!g6*RGjZ|w z1A}i(;wFLUt?%??d9&5kIRA4i&+3!z1APQ zmp{Z`?LzVwnc3WWG(-33r_T=7&oSgSv60BOb_BxM-B^dnYU&2!(HqC|eY^xJ`F@D) z0eyOtCq1OY8FCUVF9^gl`tstqOck#{t)}N1BSuXPfY%;2UR`-BUvD6c)mUUr_`7pG1AoeB`Zb3tm;PRqBe_c+h&FTbg zu@9z=@zEzQfw=U6%56OEjmp+)+?8!y`GsdoGM84R-8h?-;Sk_Ev&uqK0E5ph&-CNso`GCnhQiGbVl<8N?F^bTZ}9&Y2LG5c^{yf3@hc zfWSkmwgOtY&ZH|hWxv$hOzdqz33mP%?SNg=4eqr_5~l_16n?%yW=f0-Hm^>G0|%66 zV+a&n8QVuqs&*16<)stFHb-Mjx4_{<915k7`mkt1V%q=nDU^DXAJ92--?Z6uN25-g zab!+#ur)fW6c{Ma7B!5LWNIJYMaA9YB$>)Ggj_OK#qr{aWKyH>yf}z~{YI%r&t+-e z`Ck-e9CF4Pb1NB*437`($S5I$ty*FMC%u~CjS#!}*D({7O3>h$BO)e2E_bbjF-i1Z;neam8s6h+xu`hB% z@^nqDzayYtYj!zBO+iAUUJ8tqSB>swzy@!bl-@GwMZ?1G0;BKu<3_Y2Gb4&8RHib< zP2-c}Dn}JT-~V1nKJtQ4#p~~?+tD0OI(UE5aJF16Ff+<81PG*FM=~a-%Q3Z59E*rZ z1gtgnF4t!hbjk2jDA<0*0$K&k78cVjs}rT5xI$;tR@4c~l4}3EoHkF8rw%LAMMgSf zbPIN&sF;i^sxGE*XPc~NRMx0#{=}qA;{2ICC&^dR-9qHGv5EM=Wxre^(w|Q;&NwMa{7es; zB120(X$p9MA5H`vty1q;v=ld*o8`U}^t4k&b`n}$i9dr?Mx;6^JgUo$r=rancySVC z%Wgj>F%c^#V2C^thMpx&&0+Pqk|_V4PzHp3#T|MT7(Tn}S`Ddlm-q|AVri5N?4oQL z@t}xhDoc{S;f-P7s(D_ORg)P|H6C8JS*o>}Z&$7+$J83z}pfYs43q+cxvc#>7W)R}>g=U>)#u5=-GPFGiN-di>wK>j+4jJk)O1e*epW zdR3i5nZ_}4S}$frh?nsDRbcw+97DAcsNT}ckIE1DESe<-hTGmVuM~eeoBu;pQHZ!v ztS1t;ih+f1mSJ-y#0!rmGXA8ndcDlPzzc^_e|_fwvg2}YZ}fkjTVUZ0FImx&)*_7v z#Sv1y^+7>`nT9mS6d{8j8%j=0*_qmnl4yMd@z|FV_Z_fQ6=GO}A9>4%^Bqb)hOAIw zNh}&QiiCkhN<%~D`I8M#>!rHi2?HY|dXR{+o32pK;xQ|vYD1IRze*Shi(TFyti+lF z3g~QMs|e1?O-G87D))#&W1y#jzUV3=HQi+p?%gymZ)p_Dg~USv_f0Qy|nOE)wno@$YdgS8Y*zUTZM{Ziyrd$=}4pvRFhS4o#<{({S;J zGML7Ab)ycE#7saq(~}5ksqwsY&VAO!m=IG51SAH(7J~2st3m6u3JPLBaLjl;(zQ_J zuWt?VxXVb~mfw$myo+;Ztq>IY|R2w<+IO_U$=>|omW9zKO25^K}h zVw=dh<%;H-q0g^)(T!vyiO8|_-Uj%^Ssh0_w9Zhd6vn-u6kQpzat3pCdZHbhul8F! z9+(-LuargTDBA9iQV_ey9R%o2K_ayCtDussUsxoqe%~PU%T$51?)Zm?I=4|hqwl67 zcK+3StRy>&)F~M2^K&QMHZy2}tVu7t`t*+mxu<|LbShp@6Oo&g6M-(MvF1g$DWsjY zPS8XYB()pJ6G4Qja^zh3x<}WMLZFI#Hgqn+8(p}uYXc}Tdw8rV2W|u>NgqLnNFf<} zT8*;@i)g&O2myo^4y;9xJC~>So@uk`w}*;*he^sJLWP^mNw>rjg_)(uHM68)O;(yW z4<{p~|KGe6#8V43e@Op+2Z)Pb$@(>WO@V&qg1FESXB#uBdxjs2f9T8cBKVMrYm^`j8t2&-vGKv%% zY>>x7W(sA2FMqyz!-fjl|hEV zRZLBpHz!am@>r4l8a?(-N^-7t5-1XU54>67qW?Nw&kU*MG05>!3Wco6jlAHRdKOSp zKH^_YqB^O*cHL@746U*24AZ}5i*vIqLvN` zgFZ{KmFhQU1qPi=r>`==Y4Qev(~3O7%B0JTYBLXfWR9x67Ow|FlAVl2@IrZEX|YwmxuY}CqR13sq|o%3D~62_1WKBnV)*d;Mw1p-0RE^MTa4e; zzF&Xrd$4`508d)5ch+oQotn?(Uc9xrMLehXHK1%{h zF)~?skyLZ~uyx#&B_NBs=3R+mCYvBZ%@c=4YwG$thJ~S6Ji_g_NJ5h^2rL1xbehzn z!d}(6ou&4md8I&f(-27kW~^2V8{o^`w1x?*0OKMEv;?NC1G*=6 zVW|X4B2=l=lnW^^T_jbY*Y6IPg8o%j&2{Lrqt1_)?gRj_NURwRQPLsoXL zm#SwYKYBcG_6+Ip1>>5_ZAU5(X50xpja!wB?fYS$iASN3#AL{o!>E6P`cj`A#-IsM z$a5FObFd$>)i(10OE5VByQjNMyukAn-0w=UQ_TZ2;xqdecUyBj_)$}usgX*4-zanj zJGO1xoANj6fnTk>mK67uo|osqH+R@V4!(2#j+Deb;cZpF@8vhwU^hR)K@Xl$|uUpIL{lLcOoy(># z5_*lj2YXHDNfS*I;E>NdwWh(k!pCoJ=X= zKY-|i^Hu0}TKj*8Go>7MDlQO&A#a=Es|`n}UB==6c?XWg*JS_lIH(#B{e1mENcs}?05(d^%FN7A zMTw%~A>5ESWdcy7R>wxnpP$mU3l(H1z2h{hqZB)&AVn3O^a>GkqHYVj0H+!S0X$0v(aQ$zxq zw|8?x^1iZaOHFqh#Ctw%ma@!dONVWT0@e6Qp*)t96tUo|z5kI~uO*t19C+@mrq2sX zg)Ev1g*`j@E5nIhXmu%>tcIh~q zaJZwDrWUXNauA1{@uu8LMl-`DmYANV+eZi1-^$Hu8{s9=o0cBKSNq?MX^)9fv((Mc zRg&>yvem(Ny)#L)m%v7lqI2v(^q#!>lSyzH?!U6&PVys%L=9O?94P3p>=e5=?E$wz)Wymu zV(6=Gup%ltCpgp`9VJD29c2}m9Y8qq30N)|Sw46PbEMbtsIEe>5Yp?Ahxu->rZRU? z{J^%V@&lVQ(Up0?!7jKFf@w)n>kY7}smJ-c4-zleNFpqu&+`8wgf`DgJZuZ#Rk} zJKiVU$3=F(Nrn72sPNnMl1T2mxVwepN%bZf@(Y*{?fKLQCDySk!u6}$a+f~)=a*eA z^jE67vvoab?VLtsSc-SdTJA$b6-w>D}+_`++9*mit2C^88c4|HB?Hs zitA2|r-7T$fmGy=-QKp}2$G{4uP`lxN`jHFq>O9;b+nr>jLd8-!mcD|dt$wqO3SHw zN{0Q@CZ1`+!t6lP7b^8xPplwF}e z_lJ6mfsW+P?NOxxJNm@UbjyCGR+bIK-+taZ+3WnK@Yp->32)7-Zpgt@HRhslBrFC? z#91&o0A1c>N2eq^HpimLSn!Fc*|~m~lnrDEOqD;#$Y^Xy%hb1+{WCLbTFx_iS}wn_ z!4fjiXi^gNnRCXoGmhqZ1lx&z5+e8O-G;fv6LSIfePXnKxYcZ-co@|6)JfbT^~{i2 zne@G`^lQr&{&{}%i<&DnphSCt%a6_Ld{hJE-ab*Y`CYMCq7CGYLueEs5rV>_k>P}a zdh~M_q2^eD`iuO0Nu;xqwm?(c5C|z~au#=iy6DHJD9oU0ZY?FwvQJ_yH$uq*xVE}I zi!0(kcU-Dj%Q3YFp^q~PTG9Y1e32Atu zy7*<(R#9T($Epo`K#{&HbnuAXWoe-lZ+(kEQosMa?nna=f7!C^WzikMaNyZVWcYB> z2_R1SAX+vy&n@fA3t!Jg-duROWH6#HVhi?H5=~}Wmpz2cI4*m>T)qtKjlLUf^9&Z) zXkBI`(YKN<5QL2wo;m(<6N%xUnaM5fKhB?qpQtQn+U)VovCXGV2LDPq>i?e?bIjBix95&^735EovQRS$a=jv= zye6j{3?Ls=_hSkw1Z)9)9z}|hgPvcX`|wgRMih2Q9|ZQFSOQAUt-1Z>DxUi%c!N@@ zxokj=KW*suPE2dQeIhx}T=DY&s%1e=SfcaMHoVMFQ3llvD{7GXM|^p!6MkEuux zs3Xkfe?8u(IYV~_?-ME#Dwg)~mB;lsbQ{WlIym*KU!)AyZQiRY_xJE95GnygS2|$) z_GhPexC$a{!C$mbnL^K1ra*GKvb_1PQ$MTY>(vZw6J61=)arN4x;Z-V=DzKVhAuF-i2G%I`RuWX`yD|;)DL)mdm)F9 zCs3(SXE%^SeNkjGRHe*bTD^#v%Q$2SY7oxVmo$iIJu3|tvc?p`+cqQIfm2Ckc1tTedM(>WDAw5k00eR|nxfV>6|2BJ&Nqwx7=ml=p|OfkivMAs%&RM4iK8MTCh zG#X))RSx<0kCYVYJi>Rm#uBBCei1_4$wQz>|(Z+ z*LKzdJN=u?c4o~Cx`vX4kEJ}E5fr1+DJ17Z)Q~uYH1$-t6H-!cm3^EoDoA{a5(OL^v)-a_{QYFr z@W+TAH?1Uy*e8vg)THYE)v7EhD*^1lx&)8UZc-C~$qF#wrB|H<)UHJOBk z=-wBg{I3#UcW|#=0A$d=Q+FX#cC5);150PgtepA3z6;`K!N~xd0zD5$DZqzpi}i3z z8Lv^@fm2s>Y7OOT>s!V`9>kaUk8OmfWccELB}(F>?N>QSSl#cxeW8ed_kntx*G! zYvsD>dS^5nb&ILgg1D;4bEd1E1xg7B_#L1(pG5s|j_vF51IMIGu*Yq$VC7s&$Rk{2 z6K*=n*{oW%^HtvSJg=J03$uiuF?Wl{bm4&8wl1X43jeQ@jl>Lrt3Ql>UL0aWX>8XU zj&`8JMLj_q98RZ~7qva$PNaDQK?dkg-J)XKS82gKTs` z1->Y|hxdLAkUh#a)!!pv_@}HGgUcqgD1v1dBYv7V70{c4=1?3Fm^_vrpX6aP$odVF z&{d?T@J&o@&m^n=glxSP@XfC$GkT|Twc(3Z5pZeiWSodZaXjtPSh^5Pk*blS^329a z;a1QofJ$go6OgMqdp85}2Hu%=!VrH~Z(=%T(7q_4wWZyiOKjU6lzO2z2&nH&4NoH1 z%5~HA&e&i=poOeNv_+DWH6g>1eJLi(+_7}fEX>ZXEJaRXY5jZIHrZs8O*Yw> z{~Q7x%xNEq2A(})5yK#dzyCNJBt>!mmHcM&;j?2cCBR(=g@m;5@v|Yj?6S))d(TZd z(f>CMP(fo;ZX}1h#&DZ&ckrc|tsed~E-pUY20G^?GD?WIem!blB2|VPY zC&YlF4NUv+L-#u%_|2=+76?2iM%k5-8MW`{+2+NF8Ac8$!{%pFtTiWXO;q zV+w5nARH~)lusGwVWy5`cCg%#2B&J4=?evEf-{3okQO;JOay6IkoGut*@;_0xdAcK zlZlcTpha{jR3D{pMpVuq*j<%RNu}yl`mz+g3m4rAH@yn>PzDA->u4IZcZuYqQWlJx z=CW4NzD>S1kW#;AFr`7TN7*)}-R>%?1tYR3S!os~n7$UkKhoH~E3MDuO}tc5B~xHt zS&*BGRDu08=u=&ouL0NO6usIiiG=tXM!+OT{+w(-)IV`+vnoF%Qew1UEU`c{^o_;J zEx8jYubM&TjWTG=H2mYqDa-vP`9M)sqklxv6@ZPDnOGDQmraT3L0MqwKK7*vqUX7U zBA%{5({o;-mRB{Aj3H@Gksd5795nrH3z2clup(v^sac@FiYzIJ3iXHC1zluag&r8I zCer>xdtTK4~TrHC#kRi>xgS0+A2Ub|645M35MoeH9~ zi=oeVzTW)*5Kw$ZD5ge7;@T?LP1id^JgXbG1|7FfA#SydTc@|Z)tz&eP;wwTkL2cmf<*Z;KA_M3B9ezPOyV5gT*a&}Z@lMFq~K*%jj*mWDzsxcDW*VBV{dGWx=g>*-?30m*pUufCH9qOA*Z9Cg6t$ zz#FfjHyly`35wO<5wrG{{gg64rwF|aq|tSoit0Z_pt9~p{CG{U7_VU-+m z#fnsor4aS#m|`M6AD=-sG-?zQYg>Y_@Oh~Gz2zHLH9XyuBPnvHg~_2bs3K^a9O3`!;=MnomF^{~Zh!VH4_cx9nyFxpBv8RAjEhXOnq`>Etw z%UktC0Bv4nt2{@lE<$jV@P2A&YHOQ`T1ykxmFfDQpu~mWD8NgG@|8x{j@)8WAS%Iq z#YxkN3aLp~C&6+@JIPIVnQ0~64^>zdFAK9V)$(n<-9{B#^yxlKtuw>8Yu9bqoj%BQ z)s0O0%7?7w2BYG-VW;n2un^se2im)GC`*OBz zo7GU=$&fzJtXpyek!$6;>3U~8uwbCSB31tLaeUE$R z6S{lgG>ge9TA%upXd(b)au-5oU-mP77xp~#k8Yb5kP4eSa3;cfTl5=gmo^LpxdY#*5K^K6oNAa5*SmBCSql97~a?Z-t{siBc0(-5@h9t^S^W8I@|%VTHy zvV83qrs-k%{_~UXN;bHWu96L5&|YE4dxb%2!dMP3WZJt9=EfDolRJT`zwus0-e!LA z0%ut^qrtpWZThQ#R)vZ62TU&X*3el&NEMPD(>K3Qv; zjt1T^uEs!glsftditu!*B8!ZG>}Q2dxSo!F%d%!K28N^FugVKvNZ;4ut^?AI%3VHl z`L$ui<7)dy2Yd&evHMIv_Q_DcWkV3TR<7&STf}7DxGL7TD)P9>a(k7ZdXqxufo7B< zRMB0#J`>fUWYtNZ>Vg%!d6~^Q<=Oc9I~pcGU0nX6-5NU7$)Ke&xn}9uK^Za-lfJTb z=I!I4MN9j1XxROOM-C}Qpd<#R5qfLX(Tgo*o+Jhh3(I&4lErzJ%&j%q-Vi10-z zLUTv~CE!t?q$J}sFfL_ErwF}F8jdyYlj1`3#(Px5kv9RQFF9oY4t3XsBzKuXcFZ6h zMey+6DeV90Y5YdyCj7*3e|og=r!pwZhY)`96n?rS{KRvwVNaAQR6dd1SOicH=J+7g=?es<*MoR+40k{HZq5<69$-dhlv|HnO{m_F@}H89(Y zz0^L2m)>jAlQ^KB`lN~I^*~A?EWi^X)cAF^SU?~|2ci-PUkW>t0*1Gz0%S*O#0ms# zj6DJ1vUSPr{n2D04i6BAd*tw!3?q_Q$c6@HsDXTi!emn69a&|3IkIK|T}-wS5*_mKVt_$X@^jtE8y7Y8BOLcLIf)dLys#8pY~}zbv4>bmx%b=*8W3ns7;h zXa^G^8B2Tq?Vzz&n0gAXoLE;3s$phAJe<}?_~6v0GA5e+6%KQe+T${l+|($LR{;oS zB9XiM&zG`N-p7T(M|MLUWbDw4+v0YRN^s^FqMTy}Ssx1#{Tb}(%l+UEl3Q@1vIwxk z;Q(l?y&49zU$|(!aMO0qp6Gj00;uo+4DJIgL}JIznwQ!x)POd&OEp4UDE9pliiQrX z+jAS!#`eetHMS-;s&R1~HVo*-nJq=NWI< z)iL3?xNYmD*P%nl-k{oSxZ}APV&RHm5vD+O(yzK;$8KI`)lNyO9!cNelwXB@v78-% zffE3Ze85=?&UeecEDOg#oAVnyUDc-8{!ytv8n53*_)xeLeI>5VNg>myMNqnS*+X^6 zKy^}}x(JBfyv&X`K^ti3l8UvWN&zWLK#~YZLJs{Zvuda0RgV&&(@ZyheZhrw{XsLgmm+5Gxaq^*aL=+k_Jd32*kI<7 z4Mg6R4e`9o=cm~P1eezt&y_pee&R7d6jfj5oK@Z5p-_FqTO4v6OaG|`m!-gUu# z;r74dYFeNy;l}e%d;W3@)PaVYxT+>#Drpj0UFQbl4zc;#u z(7Bb7Wf~OhCZE^5nnKM|%SyGaRr_GT`o$aEHPFWNWU-FwU-sN+; z`tWx9BzVheP1PG3YGPV$Kk2v&dh{8v2ZL7$Zop^CyW1d-|DCPc!CUQmXux~CvDFOC z>RO1}DDV#N@*ei_OE&-p@A#HzZj#_Ce+9qxSA?lR_}{U=dm>5Z{Z)kA>A%DS|B74G z;s1yKU-;j0*8U#=MHn030Z`+ca5eoF{BPrT2Ye4Gyk=%*HZ5#{_FnhG=Dr#&t&;aAx<{@cip>k|Ln_WQ%@6~0L0<5Qw&R>Qk@@9 zFBW&P+rP*!@{8#eT)L7v4pNL2&?{N1GP9;tnfr!U(aZ)?9|8c~?bwyIe*2Ud0nn<1 zxMJ56E#&~X3kz*%LojG^mvVAquqQTr8mYBjCiLDWbPeMXQK}AEyz{JAF!NtSBx2J*?-c|`U zVOOCF?^37Kay!@KWo6RyX_Z*1pj`?T#4pbuG0LZp!XPHkAH(xm(eCB>yvpC&f_I7Ez{>eNaK2X!t+-z(g4p!l zwqHo@hZ(k07nWDX>oX(0gE8#rEa2XHb~(MHa5S}S8I9ymyl*|aICj`+=Fd=40iGm2 zITg|*1XG-errIn#A@UNT01MU?9j9LIpzpObZm3QkRg*XymK_YV;Pa0j-PpsRyo1v- z*|KEIktj0l&i3fK8GUal`xW@AC%(PE*mYjqM*E%Az6C?-17i!K$5?|pEj+2 zkAC>4-a{a$jx_qp6xim$Yweuupaj)clr=_^Z)%R^4pmx<{Vs4=bGAR*GZ%6J81TCx z|M4?t8}WjFs#wt*O5Vl@c0J}sf%Br@`9W<{z}f62TLQzjz_mTpTp0L$k;}z_>5`at zseif5TP_dbz5>2d?0h3q`ZvdppQ4ASk@N-?|BXO{Y4_#Z-m#Utwr#7grz5$a{XHcz zGbBvR%lIJ1xPX~2i?f*B16?!zzwqGuLzzW@fNB9q0$2sF$!MEn-rXLfc-00Z9VJ1!N1D7qBb<5x@v&6TA|m-Q+(T*c5;u z09Amj0Q3ZB*8zY8ObB=qa4mpS0G9wW0Z{?~0WAW~5}efm7zK0*=uB|74`5t^b2bzi4TS1NrlxMlo(z*uEAF7cT-ra5m> z5DcJT8S8>?@kG0TGh3R2suKY$Gi|P!2}F!NQ3tvFH6hyIv9=v7<1n_bjH}793U%Ugjka1!YrF-R~=JtxzQFkkZIo}GqnhVHqgOfV&^u|Ts2ZTmB$ z+Teuta7jCqa*Gn}+5pU&Zmcl6CxLS&%s?6?XD+^A!No+Rm3?35RlDQ7v?#!Wd$ue> zTZ&Po=Ch+F#gIHn1#Hh1rx2$R-hw^{f(t%Zkml~3UKF|enf`3#Gva?<;NPl(6I~f5 zT+B4qNH!S?Y0GfJgqB8~lz=)0Ph)qU(_8g$#Yuw;SWs1UOmP`wRy_B{xOK2RJ2mtz z>p0>C{A)KUwU)AFp)D3)cLv0|t}11~Ttn50K$w+A1=j1ojA`I|9IsxaRBg>mFS4e0 zLYb)7XnxI@J&)ej2gc*nvDrl`F+7?aU*5k1iOPLG`coVhr%zVc z89tzuhgC3TeQYmT!WW14q_RPnT7x^AQ)YAazwFimTpnKVpI5bcaPPIv(ifHTb!*xa zj#U8wQJLDF@-@Qos;`@>TR2ozhubvHmWi!pE)jBf46-1co*P?c>|`_VLKw$Ps!7rS zzhzq@o8WZ?&*1KR#W|9ri=oVF>C4=rnkpAl66#Ceb0rg>qh@-Wpj_T=PR-Yad8J%X zH7oq7KW%UqOz8PAd$E!IZ+L5wapbOm?`J=p>6*r?CaP*9HhO>?eZ(xdXL023?HWY@ z4@;{P<3dU(T&)lY=n435I?ZqS-4uskheF!?!;b%>$f~SP8R>k&Kmo(1X#nY-rc;eL zlD6-a#pEnz5}lZvSXUfT4$}jmVR+Jkp6_F7)JnM{%}Y0aAZy2 zx8)oEQ@6xFB^ONhF;7f_Yr#bD5up z0bVbSh6`bjQ{4_eab?wdQi{69_e8ar%X{Mf`X=vN#`XQCkGk(CqWh1Bd&RJu)Zt}% z`oq1^*n3|4f1X?bxPFX^Uw!V}=$FQvIS=YBn_GNhxZ(FkDT5LdDc(c;|0kUAfA6r2 z`lVN*25A^KSd%gcalQXMn>1zGTio3o#=X;B{=N$`9+D}mG9hSYrxqT_@s@$YS4%H5 zzpNYY0)5`n+65LP$X3IGpjp3`vDsm$ny#EJUab!3jw%5fD`h0=o+$NN_pqJZHt1!j z9QYi2|9_*68 z!o)-w&d(J_>428lJ<7AFS5eS2m_Fm^(Qp>GebPQ2w{{^2xC7aO8I(#TE1IUs4NNGn zP_^f{Wwq{po+KZ0wr7WWUMb2d4iv6>YuxT@voay)t+_7KNRabr+0boGbq(unVezo7KKc z#$N|yT{Ycq#zhVf4Zxc zngd(sGiZ-hAm7c6%Op20j4atH%H@8G=q-bc$k368O|;@_T--JoTwU1B)rt?Kh^UCS zqTchiw9vKB-1TE7evu8G#)XZ*(#?~(cl-da^KGWO#_ZVlRB_tsI2RGRcKS=fZF^yt z44IHeYvV$pnt!BoOkL0!+W>Lm{%L>q5U zmMif5D9oe-P)E<(dm8t9-ZVffJCfhh8Mke6Q=q`D4kV z_}~jpL{qf6`CkGkME?J(^i!?$uUnb?EyY&)GDMV(YXEYty-<@c$64@eaqlX_gN0Y0 z9z3}4!I^TP{%8CVUj6io`maAbx#|DT|NQDnj~@J@Wv2J4=HNBJNB4q<KP~lli*B|hs_87E%lgY4C2_@ zd{D}f9x8G%gn*v>)Yz?)@54IbhC5TCkwhmuVE^_9VQ&=@YrU%e(i?^WZ}MOG&9cP) z;4j1W>G95e*)#m){%6Lnti;~<`VK0HsSzG7bh`WxYxeoP6YD;|c^bF7uA<#|#0hiG zK%3YCv9fLO#{q=&)jtmW=GLL-%UIp-#Sn%T5(&^6(B*p{Ctld|(ytrNc0Z~2OkLxa zI-kh27_D|Whcq%$f9W%`c`q=;^GS&1*`o&$dvZQ{y^%A{QMNVYkFV_C-fUe?@~iwT z|K#}KYqkt~lqQXT5irZ{F)I?mjMmxHxHl^?@EI;-lLVoW^fCv!LPm@e0`d-hc^Qc1 zpb_7;xWamJr?{@uS|aYJr@9=@NH`JOqUeKifeqU9UJ{Lg1ESiy##x);BPsz0G~bKB z;bpWIscFQWKLWjCrPo_|Vi${~Vm&fJEYHsJ({JKO%cSScM$MISyt`djiy!1KxsQJb zzlm8X=%1?tyv>?-7PfZbe~p{`>-Yid#9)ENdOuv+=xdEzVDA~Fi4yus?Ck+8)~A>* z`@U@kC4FXvELN+&e`gI8`Vjs*@8Dg>r(d%KdzONKwOKYhUnjG&4!I_Tf8#HilrZdM zA4`6Ilyj`7Hr0erz3|*UT=v9>bJA2}$aI?$)7va~c)eaa$C;e_;Wl#&6XI>nO&@tX z_|j%c)sxlWH9aiP`(Iak8Bsi?gux!py}fMC?W4VUxtq7r%|U;*nsXRDza0C@XH<1| z`Alp6W(+eS9X?X zL?cJ|Os__dFengCEMi%&P{X-p7nx>EZxR8$Itb3=(M%a$8lDaDu^m>RbmX$gm3PX& z1MC#U{a;%8Tb1ZN#ca{71#}-L-E=jJ#M%-LI#Z&LWH=~B9gP%T4GwHs)3b{?q*hRL zY+@YK0V#s255nPqw4PWqY6tG;m{qx{SBy$zwaH1L2IKVr*>(ZvzVK_kpQx$t6=uY@lHikl=) zO7$$=(p%3?+Uxl^G+dSeqlZ;iYZ)j;8MbJ8GFxU#_~e({qk84}%w$c>n`Mx%(q0P~ z>YDXRo{vMrWf?GPXIe*Q#VErTO;2X4dI=wKxhv0S7BmJV zg?BoV$XLEyi?zc>d`v`^da8hZKoqep6Sdx)ZRE1cf}159JCe$F);KVfY#tGcerv{M zK|$*gfQPiGR%h5Wg65CJlwFss+$0|_O2WNFpxNhSt+Y(@J@LUtOQSQ7^j4|h*WTg0 zXR|yLTpfauA+{HDB)3gYf*8JP-GE(YYz7oi76%T%==~Ed;#rYlBm4m>bLY;>sVYnu zS4GLc8pGA|g=V%|fTDrRGpPe-7k!30{Oq88hdX@KO`6*Q0qZ|Hs%0Z=7EGqvLU1IW z;aA=lobhZzgHMbz@DbS6?n~boCLXD4V{7P^6D^62#CyP(ShyP+3S)(;wj3A8=bmh(2Li8p{=G$P>+ooG^ zJ-}+-M=TVM=x|l>*Je(Q@q>7(hSp1_tAPRFOor5Ew8LP2pEA%l+dH9 zC&ewtZHetFnkEO$?`fH&D-xrsTL%F7$y(H-m4V5mIT21-CSwUu7dMGzB=oO=vdfvI z`L0(7PrhXdVma>}N7k)5*+0V=TPee0&v-n%vzca}`M+SGLX0y&cIw+bd#kb`lcFoC zYJ3O2FXnkG7TJCnN+bcUE}?zgB2j(8cBT9!SQ9I&m8EV#po@!ZPiE7q1W>&{$E~2d z({Ohb?TPnsKwcy7u4R02E$ZwjVq!dZl&Bwd2o|wa8_dKv0FhYOh`eik-i=y|y?OAp zMf8qmP@pckSB60sEwkky+SU{Ke2?&=)+oN{ChPE z2a&awiuRzVil7_DIlRFceWRp+KTr?Ifi6%i2SxYo_vQ;y+>%yv|IR+I3nSqpIK%oD zy6lY>mjR<9L8EmtNCY7<@bw(SGzh~{EV1z5ESZ(xLjPPCx1^XSWn+16-d=n^iScGZ zZ)=Dp!+w;!{7~-28yK(jQZLE8Y=_u4wlEz_z5@KW-#Z5At__2R+gP@)H29E zGNn5N@x~)jch%U)s~dxctDYLOQ=h{98n_D@VNF~AM$eNa$Ze$mW0bk_N+)+@}eHn;2?&B4de_{fc(zw!@h(O!0yzSW5JE=GA`{4rF9 zE~Bp|^=_b^%r3PVRfXBkrmwiNUH^Z-&TYG0-7cpFknYiofHA8g=f+9u>_UOd38y@` zKQuyIDqv$)T`YIfIhZwP)QNCanW%4v6y%?F@iywAPM$QtN#s`u0x@Wdpl_NvCBB)pBIlWuO`hn#^bvm>FxCx|}>dWE0Qqz6)qJ zJ?>g+ymBg0^98dJaNYz4-ZxvnV$Xi6CbDN{QG)WzT5B&=*6u}8!1yXh0Mz4X~N{WKIqZQ4S zb*PtDV=Jd7#QKg>^+8KL?E8xP4z0E-R|lJFJtur#J}obihMVHgM)0z{C5-uV!{w>+ z_wVz*I@QvlHtd9ZU?Eh2HB}41C-%j=U8yMqAM#M-H8p5H_~Q<^3e`9rXKVUI0^hAH z6tVxXib&>;yY++~&qZIo=Ri@cZ)zD=qiq+kPP1_9+M$;^+R59}nGs_j=hFE*P7Soh zYj?X|2@@!}v4x|@lKhg+E2ml|Rct=~ja@t5{o2kXnt-)N`TXr^L0MCEWye-vqrxqt z;6YC`Sh#z3r)n2XZDr~^Jv&So=K&r0W=rw39pFDL{ zJDRKDP%p8u5^DmnzN@9`gRAv$AXe<{(CV*pjj*Y{bAo=iFtIkv*nwV2!c$*BGxE*Hl`LV4pt_77~PNgyXs%mYqz?>|o$LAo?+R#*~ zA`Ng0V%$lXalCRjdwSiL_dYSiOlpZ10bwpNfyRZ^67};=B3V)4t%IlSHp8|u>1%OYd-cyW*7JBIb%^dGELVk!%lgL~!5~1+ugvxF`{TZZL(<=(thX z44qXQv#VMKYoNf}PPra*Cs7186Zk#?VqE8ZiSiERO1$a*oW}lo6ex^r_K&K+?GrzQA2cjRlU<#?PMaL7mbq!)EpL$w{o2PbKOUOsbt^9b zLe;lj!{1`Hb)|opT;??`*MCAS8;n1!zUX{!i4IoK$NfAyuVC5yKl(SmSZV3$yfpkJ zyxihX9cKmk>7iaiX`v?Iv1_5gqHCc*iA8=MqmK3ZN^Ej}?CeV{Tpv4^epCnT-pcSI zL(s#G`jHZh{Cn$eaN7E-VNdl3j9)tKGxeT+Wc{y2F<1MqNle72jh(YVxk+e5$Xsc@ z6{X-*3_>h{evmilLKAZ6gGT9O=@F|$k3_>7<)J)DWPZpV^`sv4qtIzY4x|6XqrmI1 zn@s6}BxFmq#Hd;Xg=G-Cn?WVZm?GqnWktD7Z!7I+Uv zLHH=0Pd%~YLzg2sF424BCcrRI%tWk`uCa$%(psfR%wC3O8N~D?P>Dq6B0b`&*%lwl zx@s9l#UsO(xM~0-I}*yIjUcq2m=OkzpSU%o&SyMAY)L{ahLJdjZeozWvc1d&gUr?5 z6p=$L%MgMhuf+qJZ8Lf0iM3cFhM}@#MZvNSOxE)hl@)0t*CQQCOH2ffTu%Eb!evO$ zDk>{tb!8FhgUI9ymoCJcsWypFH*_gGC~W!(X>gOe%94&EB`N|;VxqiCkq{&eyc%zs zVsxa3B0C+OY0n;LO}$Dj?v1iI(w8M&GM-~4y(7PRBOkLTyG1>(bJ3!R#F>KWg%hyR zlR!dxG+Oj=C9^n@9X%B$qF{&`iTgA^g4m3#M43kO#%lbcv7_^qMv_zz2z0cE6+N;p zVIPg8MI*8b&g-kM2IPLsFJw)oOL=8op*%6sds;TS%Vw^?M4_H(G*eN|<;?PCDy5NU z!O+8 zlpO98FFf!Fr~O#9bZ63aOkR%b#!%r`kND`f_=m5ms;a8!rIKsMLoK?qj!;!}p8U?T z{peg>sY0sYlP5JIR<4!XFyDEO>;ebB|$>lP@qA@c%9Gs=JwteL7BEsZ7;qCq6^!VJW8fW;LTR zMY566GLq4Z^el$j&1w~om{}DI6M{7DgAz91rs5?vT{v+tXkl^#I*1-DkDAg6Z+IeJ~YxB8C_IBT?$6Y_2sh#_FtOhU2nC{l4b@ zx+}89!uwrQ5g)$?_=3Kl(vXivu!$C#`%+ckvPx^hWL0y`Dl^Q z?)~R4_lWOkPB!=)m1?Of>NAM?x$;{2dG>~IZGO2F?r0Id5HwiPuRho}Ze{_sncs_v zuB52yiUzgEcjbX>zg{n)C^AGf5_7%KQCjk?bC z&tdW(Z_d34?{5Si&NFHM`u6z#{j_Tn?>^yD_2T~e{!S%^FF(SAI}7B4d@%dx$x)f* z=cFCj_Pd-XRP>RWzouAV2~}#N#G7Gi%lmO2TApJwXo-AwZuI)D0JP6~)8PH(oM6D;@uT81_vbKKlTivZ5an2Yj?vh4tCFiubwMn;OAla=t=XdysVq`mN z=c%c~hJh6=+#Iy(TeR?5Kws}BOg>Ez-Y-*`J)GzOM z>bBgzoU-oT-1lmwA^`B9fvtYN$wo9Cb^3*ys!yNbX*x#(LaVAfzTx&yCh4#Xwwmj~ zu^l)U+w%9^{Q-K}#jk@PSPim|w?O;`IKX>|;%2cG5}}3`@fMNFfsJ~Fxa%TlFKmpP2fH(In}UHn&^cCFbEuB94;Jw_(&Co>5l*zQPL5Qk&Gh~ z8DtPSM6p4ZqwGW-qc@sKbV-)cqm>)7&u0u=-SlGQ=(5Q@CXOzf++$|xu)!@Brgm$b zVx?-c#4$FiW=kCVM8y+3QXjQAAU>Ps8mG)0x40yxnZ%7yZj^hxLj9b^KbZhDp;s0O zqkT5UG!Z0<#4Hq&v|8akDY|A$9FwM+W|<6DwFxH4B9yRAp00)B6htYuIHe>s#wulo zLDs1-^fOPDqL*rFAeB^8M`~l523ZfyG(igV(M=2Dvr#5#Ba|CqoDN)xA%^Kfl^C+e zH9fK(+UdhoQO*FQ+A^ORDvUABh@{&xr;IJuxM#xALoZXPauX~wd(vQ$_sm(k>1F|_ zG|MeZnOUw`iA}K$O47w7YcSGmsG2RXoGmOns%9%(vS(n;fsZ%G1jC&0YD_TA8LrkO z^IWh#^E$U}?gaIwSmXhfi)EghyzpvGvdkN++BoC+K;}nTfOvtug20Ll&@UJ+UmJ}= zrG^k?v zpLvNyE%ZvlYNfwq$WmIR!sY9=%5G`IrT3PxE0c&}S-EnoW;racyFyw;P8(eM%+zj; zOT{$3G*`-A*txmR18{Fz*>$J|bZiY6ioaU&acl4bokHKv$12xig{ z0%;>Goi_Psl&6%wLsTO zwM8((mg3A?#;7*MVk@ZD5Gn|4{nl2%E|0#l4cg_SZI(969NOXNu*#`jibk`n+ry|d z%BX#aB7L^GcfitVjrWeDc6scCv-4{g3@v23GW;S|=Nf}A=Gb(DQ)Qe!4(&;`38taLkcC;~8kmMRJQ2PK^rl%xh*n{kK|~<=x@~Zcn4!f2 z+ek?2Ofe6FRAz{Nq-e$Z?QkD?ZxrQGu157f8g#TQZPqzPN7ZDB(-@VpSS)jhm89M@ z%h=E;#*P?gIIaelaWluu7(Zfy@d>*olAf4x5~CzxN)0ng3am)4Elx>Ov|Hmn8LBqR zJd))aW{@06zHS>_l4ohN%pnDm4r(b{EO1MSp~WJXlCiAu*D$?t21fJjGsLU)m19OEbQ#M`ahM4^Grk_%+%xBBXOIOT zR}1Y~Rc0ff9Ww`-W{d1|LLr+Ad2XP2`0{!-XphglxjLzX;(Xuow^`<}0BS)x zQ!ENbDL2fZ5RgKnj0%M))N6}dVN9)-cq^Qs)+F;HkjhN6EE0{P7`Jski)CoB$i6s& zT9eF+My4vb_BYFhTg;{3n7_ZB{9{+j`>kDi^*`SxV z4VN~y-1xOgkpcQmft47f-!usEW|YlA6&tY4qj|PAOB`E-E5x&9@K&N*eQS-V&J@em z(JG8EYy+%7k4>&^GPGD=-`0~Flg#>xP-d86+h7Fk=tyn%y*;Wg=2*9nRc@F`2QbC@ zZFAottD`2jj+xr5aO{Mv!7Q6jpVXLO+8K;N=V6As0Pk|VEBLPG-JpFo!Ms}(%I<2M zyQgE=BdjMK>AiIN#?o$;bKmJ&Eb-P4k_KOy_l8zUY(LNaVraL{WxqT9@zUMj<^H$& zAgMQVtpUKPA`wiD$6&+33+W62J;bLW4TgdX4W^D-=zvl)T*Hu>B!95AQvE0f7-(Bl7b$V#-LYLGvSH3d0O)(sW;7fzNz`^3kcfeS|HUIb8HI2sWQ%_ zV3=b4c6b!R(N1My%S9{}MJvW@n~!2SI<0Xoj-k~u$KvVQZSYY7-vGlB;VO(WD+z&e z$=jttOV4vI!$^6VA7wMKFE?Hu5AX5?D;QJ==C-2KK5r|=t1-&B5+=5l!Yh;LXSH&~ zDh;c;SIyQ=rJ5@5)lw|5U){Y18IgT~))b(-7SPkm+7s*OuS>D+r}b>tKihzBLz6QM zhQ-Ddn_z5`wQ132x|@62yk?7>Ep=Mv;ND7NtA(w7Y?HYy*0$H%^=?11!|;xSJ8kSt zvuoOJ4!bjUuj92x7QQ_@_FCvW@#efS!LncD{VwcpVE;pJA0GgL!KsE&AEItZ)FCg2 zk{Oz77}8-*!ZMp<6*fw#ep_6ILmhY(Zl6bZEG_0)g%4F~jClkg=p({K0+FS`xIGR* z5c0KJ;1DT6wK0Z~0dlliWE(kFg(17#qF`#Z#9NdEH71xu1yiKY7PlX$>CvI_N3SOr zLx91U&M~uaj5V`QjIme8k&83JIW8-caf`;Q8NWGUxdGc;5}~Lw!EB~w+vPnul4>KiIVVr@nNA8M!%R{{sj|Q^C7Cf!Dbw|^ zOogGHZmI+g6jD=}=Ph-rHX3O_6&qxkCeIw3v~Y~3ZJmxFT@;S=;F_pRpOXQN?2Pgm zce!Ok(_oTurhr_n=2>QjP^gQ3=3t6hh`7%3G$>)#$=UQ~2h5%`2lSlCa|X=?KiA;g zLGxhE(>yP0-dJ||yl_}RaUrIK6$*dlx=87w=!hk!@&#sWTqTD_wR}!#vWyzK6Rta0x%c|R}F|RhYy4&h~YY?rmu%==y z1P*I0uARS5-ntg+)~$E2{?P^v8{#&M(#oh2st(qTA{1yh&!RD?TrH*@^Vv9Eg9!)R zo1m&SYL9D^WHmNyR@#tMc zyN@T8*O7oHl>~wb+2%c1l4@i2xd%t%6g)vC#Sj41><1!+h*s&0M@VE9`b~YO#y(`M z!j~mfz8>40L!)CFx?&jPuq5n^!MQA*`zi_QO|z(qticScs?o{~GpGinz&Mj? z;Y#!~s}7^aB$Mi4iuBp$UISCBWezn`G+AKZ7ovJItZKsgY@Tb)3@sMf)q+=Lf=R7J z4QAQYhE`#MY3*<&`t9(jgQv$fpLO!}+TpVC8& z+uWOFYc|7bbN1#1I9i~hZxODLYfDs%tXoEt-paC7s3LuK`Dl%;-3rIn$r{YCY6GK+ zc$+FCOxuF<(pN5?woT;QDe~4X5#9DIJDBbevZHG!E}A>T?fljSOS=_LT?_H-ro21O z?%(#f+jDs@gMD}H$EG(MmHo>1hl6?l1aI$s2GoNM2hZYVAWKNxn}Z4<8`sbe!^{i^ zHe73XSfs;Wj!+v>meojfgM5r^7&(Z+C^w__j@C8?{uq~IE{!ELw(Z!5<8+TZJwE;f zDidBz9FYVU%Oug0rX<7B&Sr9pDLAKuoJxIa=Be+dA(>`&T7&6`rdvx-XPi~~Nc0(q zj55g(rr3}zP8mI^FwP(o3(c9GX91ZdZ&3Vf#MwD5a?GBp$r5il;8Yo>pCeSUetJ29 z6&avEXXIQKa~tN4R$+v39#BOD=B1sFe7@27^A^ZlaHg7eZB`20jU%I(W2k*;TE;qjd$cnlv&aTW{ zd6Ib*NM(lTR|&||MWd=1y;Yy8c~WbUW3^8js8$D4rjLI0D1|yKu&e>Cj&_Y8+4@-a zMQoIQO$g1VnbZu1vzFW1W}oftb==k&U3a1$G4u8I>jTNgwt>$L?iw2MXoQD*qn3?5 zH~wOtO%qtGO}V%?O(VLQ?q-e6q3AYmyg3V6D7FYzs)uSz67nqzSZ~GAYL&Ovi0E2Z z&}ajrlXaVNYHew0_7$O#PTN@Rd~9dE-TU@SJAm%6y`$t#I6Ix~EWGpOE?##z-}Uou zFuOH(2Qb;awg-Uh9xr>D?R$1_;r(Lo_v`-R_Mf-+Odm|`UmXP`!2oy%M;p9;2ycO` z?1z*cigjq8L*Il!;TEQz&afQe;1%Hfs{I)*@uLFzHVl~`vTGZOt+!edj89UlkjW58vcu5rcV zeo~^HY&;_D<7JGm5I+ua0$Ov7CU~0AW5T6GpvKrulr^#RB#@KrBqgSrG=V@ecp8(n zB}b(+`NrC5(u-sXtOiA5%7wr0|~!dT^oQ%!j8KptYf6!Mhn zwZ(g01orbD%qNyF#}|`~@`K4W$2fl|f&!e@crWk?UqLADh2W4c)VHu@5kVS@+!lpV zqK9SCEIt?WzSz^^9ZTRW5wawD$**in!79ZJI}wl1<37Ih1V)f48p-dlY+q~dvA@gN`o zp#lFT0Vx5NA_Q;%f>4LR$njhCLN7a1OsN4@BDCm<0J&h0CSnjoTPXmFA}}fhC^bXJ z-7%snqLn<R8kA~UEDV&B?f+VCN2}uPaC~f*)R06NZGsJTqLn&Y@^cTVZ72BeNpr_hX zO|%}h@fg#Qga>E(3)+dh`DMqnJ&1Em=f-NMO*cor)K%$6oPytugag?|sl2PP&*|)& z6(uAERYFQoB_K%dRXaEwLZ2_EXo$dT$hV9h03?w@4k?d%DLK}zC^pIuckvzpUy-On z7rno5`T`oxPx;OXFFQfp(3y~Hg_{+aZA_>Wyi?t(gXBquSX+wA{G!zZH z$K{cf^BkWbY$AK6<^Whoz>cz%688EW&6aSz*}YkfmN;z6HI}nI5CoQ-eVi)>K5cDYt=JkJEYu{#gdazC#(G zap9(y`#XN6MAA}EC(GQ(ca9d)jFJAS!?#X3{~Or!!!*sc_=f6e1`bmkrJ)dW0(C|L zc846dZnwbrrCdB;SHTZg+iiHrx=9jMf)53#fe*flrnqS6pJemRx(v4+ot~M8DSPI- z*n$rrP!emrl|A$o*0Qn3lFc_c>y*Y`%Wi!RP1wA}Sf90yg~AVp-R1sjsuAV!!g_(i z`+NIgw5l|lID=@Y)n4l^`9$mJKwcq(x|AqUl;SCv&kPU zyFssox>2+^yJ0OF8W&|6wd{4Qf76b>-j`c*oaI?917#f5vQohmBD=JfLdh}-POhMT zw)6EmTU1H^YY=DBe-^}6PQMC1&~rEnRX%3Wq-LnXE@t(zNp-a)7*NqeAra` zq5deOns0@zlI|`L_k~a$E7k!RU`7N6GTdY6fZz)k-TyC#Ub*PJx8>f}2XAgw3R7_d ziPb*X|BW^TQ06V)WNoZhetOWHHKs0d!>%uW;Q=^)9$tJCQ05yJMN0!x8W4!JX9RkQ zg=@IsQNN!2^bv7$*`k#}e>YJhi*Mfkn0RO5`MI(uLQipn-}4k)M?+@;;+yBBNGy`1 z-EH#=4=fCwpyLR*@h{LbgW|H1y|=y?6T-P=yaH~N@_+D&1AJpHs@|j8RJZCA z?<{CjH?#Dl7sov6o29HL&a7XwSxzxhS8gH)Za0LQ@OfR}kA!xX%J7)o87z;mLi!wBof3Ljb2CLQ0{30Kd{Hj!> zF-lO^4Ih;4a?LKZ27SJhJ2-aHZ5&eLh!JQbPJmZ7*UA|pgn^@Y{J3go8@%Tg*3Jey zRcaonS2z18>dHjHTp@$Da5q05b;QmC`wA7ZCvEwr0C{vc1xI=mDUt~Xx>ZOpN9^$t zQ#<`z z!lL_yPDzTlhm`j3wdq$VM)+DQ-fs_}@ z*v(E9G@5jz(j`9DapQcBKKR8J_y0_%Q1?K8u+UQ_4^*S@|zuZ8jZAt4CmB%@6 z6&ftF8cqNcQn3tOqv4Pfl*<|us%nPqmz;#p(+9#f)#3b{V28|ELIjdJ7!u&}wn_N5 zrYR(``Lg4(R**T^t5VoDjs5+JZq?{He63Wu12^M`SGc^M?^eR|0b?yCBwU*N+D5@y zl0DaB0H6ov6)W)U9=C1Tlvnq$-<1-k%#8kCeW&V#O%9n@2JIJV7v)8;;EYdVV1YNY z>=h94Kw(-KRglZ88V#qN zpE_i+ge)7(D}s!sv25tir3SlnXR2okj!v?>#D!oAt3e!q z*!3Y^%#4YE`p_&UakymD(;^6=Z854AjlKvDqr>c-3P2dd`0r_f$SJ4b%w8G6jj@u{ z0!aiFwPyt*iT>l95hWgv{qgtV|A_y+_thOVIuC8~uUB&S;)pgrO}rIQ3#{g=5egBt zS5tC41Kse^Qm#hz9O#0 z9{~x8t!J8V91oSS?d5{2U)?;481Jn=p+lo1bL-^3j^4RA2#Qa0F;$=xM3bw5uz@J= zEh{=!uxF>SKjlF+T^xkP*3h2af=Xy!(zL(%Qh9G8$=oRTnpwhWP_6Xf&85c}mcd*f zsm~*=IfRmpDFthw(b)O)2dT@w*4|sri3pr>4{VecKoc75>9o^|^*TWm&7n$hlJA^* zlRkB5V#%y(G8#pqyBx9a=rN-HwO3+1TV|@12u?9rP~}xHJ~Et>{<2W-!QG}R;KFir zp)K|o!ZH0)bn7m%9=-iHCp5goo6UIu_HjSkdXM>*o~N(P^5N)cDLQgZA0HEdqlV?= zX?s+{-Zf~v6_mRo!;0A?t;%<73u?OIchnrCd_K^cn}()XmK!9H!f2lYTZGsF*9=1@ zl7f~2XO?AO!E2s&i28>>Fr+b?V^oW0B>nScf$l3QX2_?(*bVI5Ta{(yyOqv*ef_2Z z38avUjIqF>>u+Y}X(74>c6>FP<*;zYX?ulsR?M-PG3!lX%bIHuYY=BKqAL5CIcoZs z3-aBB`s<9n5d#=1^!0J!*39STn=*AcyS*N>P+zHukYKP0N{o*xZ&^&u?WZARjgx$N z4FJq|*7B(7YRsB{Oj%x(&2mw*Q04^Xv*wXi_42qZ1x+BuqoizmU?#=R7e*KMG^&B zq~SIb{n7*&`5>&?AoZSk%qzQM(!c3X>icvDr(WoIBWGuNhPMFiH)oPJ)y3`QY!$n2 zMdav-;J%lhwux;cYX{$tf0H17XRR7>@s|0lbRq;0m%Fp%)V%)I{P54+E5nsSZ=L_v z)T5hi#__f*L; z5r0Z+>XG-Sr^Bt)Sjjd=!p5z}UUSffTrB^{0^a*L)DeNBK4S1)+y9dR+ExGS!n^Za zZ|gy-C7zRUS6X1_@@eTB)&T}zeb-$`4TFR->fm{ra0iZkHh>cJsn)LnZ~c)#$$`+d z#Bx*DO-+$4RVRk2Yi#s9Z|h(z1_7VR?*8i;0&$u7Uf*c&E2Oywvh;@BT6D7yYuej> zUSaY9KqML(uAL3riF8D5d~_^MIq_-k)Fl4M(@KRK2O*Nd^Gq7aoR7RaGs&O%?-wr@ zSl<(wemrvIeB?I=B41hvcOR+^;AWBn;bUIcs#11-=7CdYnisS|Y4i$B(P?5dOV7~{ zws~-ggXo?)unm%)Mfhi@Mk6Z@dPBf(Ye0o-Ddl`^~Dy~2HxB@MtB$c%+ zIKme(uROMUuf2uWdk874ex`DsKDK`0oOF)t`t+M`adhI7<_P^SQpNDDmfVSN{>$|V zaQtL%V`hfHUC;CK*%WfB0C9rZ$a&XLZ+^AuZW*anyb!?Z=1e zzgErW!simF45NI{X2GfPq5+?x;n^3m7QIwt&P;8~-DSRUE*LpW(RQLM%DbhpTmC+! z9GS7Mf5>DY)JK$zbW;r=o>jtCogoYSBAnr2%sXzP$qo>3IW&6i=lVMiK-0`W%@A+(+m& zY8`}`q)41s-{t6NLgY<~d~R98!h2NG$nGZXJ;Sf2D^{sgr*4zO9+%Dp$=ou;*r4`& zw=DfNd^%@mFyW_>faaobZ9aS`6P60-*YlY1(e|G)DFY&#Zx+pWNCS5WgLmkh1R1Cx zA;~mn#G{he&MI@ekM6$BFgY3T3ORp2wt3;(KjyoB@N)$^@opIasJkZW_4;N0p0R&}xWbuPXI*(tZI9z~5R!eXaCDUM7uF(@1Ech{qmYgI=qc`VjyrZE$ z1?*-b1xT#|;tJr-yx9{-IT?w^r_$5&#a4f@0{Tw#)^T&P{yyt%)=qVJeJ1#@qO!c+ zRqlFd_S;qeLHWmr9Xmt-P{kIZc?^%?|I!j#W-f*5L|-`eh0RM#1GonW;K9@l#+`Zi zeb+QraJi}UzQD_82pHr^TQ~L5n}m%gihT;S%S05iwF;01KGaeA(l484e|jZd#)rBw zvyE&Sj|!gAE0-H@1n*N&s}-JmR}04U^YuL<4*C`mp3o^DCt74n+`ZXHr3BpGkN*)i z{Ih8i)8% zq-?`;gRaVfO{d)>yE*PJAGOT7$_GtyPIJF$5n>A%YM^kUo!G^x=+2fIu@&3$yhzt^ zGYR22?n?E-YQoE7*EF8pSxLlBV^9{KAiuNiQpao2ZF-ygey8H!$xGd#V2{ z29M9qVo}uMd;Kfl?px8ltYJk3WrIM@)85QQ!bx;y_#oN_iwLXRQ*#nGUUPDEg9ni9 zcxKDviB0HY;b@rvhvfu=pAH9ob@c*Fvcd*EW9|Dlua9{o{%A zNTB(QB`Va-FSzkKZ)4DV>uk|bG?LmSjz@1_EKJ5{_=rdjlf)O^PmP69?=83G!0uQ# zac_zW54l*F%@6`b-}4ieAN&P@G&92R`t|7*bZLbNJah^c;J}a408RZ=){$Y6z{%YC zgzVKwuo?Vaoe3vnd%eme1!MK>@fQ6%xZ6Itco%(()MJ3>QGLQ1^}9j2N?&i>8^BnC zb?U6NNWv$XcF3nQV!N+tpUm6*kY893f-!`^>|rWJ6nO*_vMLg1;V5Bzj$1kUfE?nZ zeOs+AeoK(DvtQZd`XN=Vg2&CxFxOl^P8Ii`cB^;q zG$e01xe}}4cpQJH6XI=8?ofLs5?Mo;aGBI*`k&)**7fS1-pN&O`5Me$s)sgfa@`j? z_!W=EAl--e@jSvbjHZg2gjxIg&!hZCr!(BIQwASIty=<=mlRk&xn`?suLG1#dVl44 zgi7hKF8i))u}OavWw1=su|wLH{f1#^AN%kw{)dCuleJ_esR--oZTplTafNLgMMa=_ zLuFnivl}dX)ik%&;-k0lRMW*rb5CzKV)@Zsc&;I1qCJ;2Ge^G(u`co@V$XVybNs}D z->)tOsDov_3PhdM9;G=9{x+04+pXG5aximcPPLuo)(gDd?#A=Tqo~hK%2hg|>Gy(y z$436oy+UiOMz678HiIaalJlKUUi;zcUz~%0@Ufsf0>pbi{GAJcgg=H8g2@!4Cp)rN zJ_ivlOlKZ}J-yVtAnby87hc-C{k8hD82nlBY-*`T;N65*Ke3-rVPg_{V#mX?lLPF$ z^d}_b#;F{ZpPguX?0M;|xwHINWtYj<{0>ig+0>zJ_AsF`Y#C;?MHGcSryCiK8dp8=Cd~ zYgLK9p>|}|1LPcqa>EPHYu4#hUNKhoDwTHi8%EEJjV35a%P%g(9tXx?^DjB0vE$CD7v97TU z%<&gYS$I|$3iEIwt05bX?w?9aQ#)tAE}-NK`eE11+r?L`)^0MQykY7*pV=V61@OJ$ z9Y(HtzbgQ<<~}x!?x9o8#!9R0WVdH!L5?(4T?KP(vg|mr+`peucFIDD6*6jIRI;Ys z7L-bb1ruVfZMvPxChp]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∈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 UMQ*lvlk>j@mv~dN-b!vFvP literal 0 HcmV?d00001 diff --git a/tsunami/frontend/public/wave-logo-256.png b/tsunami/frontend/public/wave-logo-256.png new file mode 100644 index 0000000000000000000000000000000000000000..d360280f1df6044b709b13257bff6e7806b31882 GIT binary patch literal 9793 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 WfZg^0S>uBPprxj#TBmFq^}hgAr7bZ4 literal 0 HcmV?d00001 diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 09544faf2f..c87008ab0a 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -432,7 +432,7 @@ function VDomInnerView({ model }: VDomViewProps) { return ( <> {model.backendOpts?.globalstyles ? ( - + ) : null} {styleMounted ? : null} From 7bdad4b3e51838f7a262f484c17d774be989ee6b Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 21:05:14 -0700 Subject: [PATCH 025/134] add static handlers as well... --- tsunami/app/serverhandlers.go | 38 ++++++++++++++++++++++++++++++----- tsunami/app/tsunamiapp.go | 11 +++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index c77f82d1cb..922649fa8e 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -37,14 +37,19 @@ func NewHTTPHandlers(client *Client) *HTTPHandlers { } } -func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, embeddedFS *embed.FS) { +func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, assetsFS *embed.FS, staticFS *embed.FS) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/files/", h.handleAssetsUrl) - + + // Add handler for static files at /static/ path + if staticFS != nil { + mux.HandleFunc("/static/", h.handleStaticPathFiles(staticFS)) + } + // Add fallback handler for embedded static files in production mode - if embeddedFS != nil { - mux.HandleFunc("/", h.handleStaticFiles(embeddedFS)) + if assetsFS != nil { + mux.HandleFunc("/", h.handleStaticFiles(assetsFS)) } } @@ -238,7 +243,7 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc { // Create a file server from the embedded FS fileServer := http.FileServer(http.FS(embeddedFS)) - + return func(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleStaticFiles", recover()) @@ -262,3 +267,26 @@ func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc fileServer.ServeHTTP(w, r) } } + +func (h *HTTPHandlers) handleStaticPathFiles(staticFS *embed.FS) http.HandlerFunc { + // Create a file server from the embedded FS + fileServer := http.FileServer(http.FS(staticFS)) + + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleStaticPathFiles", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + // Strip /static/ prefix from the path + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static") + if r.URL.Path == "" { + r.URL.Path = "/" + } + + // Serve the file using Go's file server + fileServer.ServeHTTP(w, r) + } +} diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 58e2e67975..e3b9c0e8ed 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -30,6 +30,7 @@ const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" var assetsFS *embed.FS +var staticFS *embed.FS type SSEvent struct { Event string @@ -135,7 +136,7 @@ func (c *Client) runMainE() error { if c.SetupFn != nil { c.SetupFn() } - err := c.ListenAndServe(context.Background(), assetsFS) + err := c.ListenAndServe(context.Background()) if err != nil { return err } @@ -155,13 +156,13 @@ func (c *Client) RunMain() { } } -func (c *Client) ListenAndServe(ctx context.Context, embeddedFS *embed.FS) error { +func (c *Client) ListenAndServe(ctx context.Context) error { // Create HTTP handlers handlers := NewHTTPHandlers(c) // Create a new ServeMux and register handlers mux := http.NewServeMux() - handlers.RegisterHandlers(mux, embeddedFS) + handlers.RegisterHandlers(mux, assetsFS, staticFS) // Determine listen address from environment variable or use default listenAddr := os.Getenv(TsunamiListenAddrEnvVar) @@ -457,3 +458,7 @@ func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) { func RegisterAssetsFS(fs embed.FS) { assetsFS = &fs } + +func RegisterStaticFS(fs embed.FS) { + staticFS = &fs +} From 45f94c361210b401656d5ac3c401f4e9be20dff8 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 21:49:03 -0700 Subject: [PATCH 026/134] useState and useAtom both now return 3 values.... --- tsunami/demo/todo/main-todo.go | 6 +++--- tsunami/vdom/vdom.go | 31 +++++++++---------------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/main-todo.go index 4e456d0771..04a87464b3 100644 --- a/tsunami/demo/todo/main-todo.go +++ b/tsunami/demo/todo/main-todo.go @@ -112,12 +112,12 @@ var TodoList = app.DefineComponent(AppClient, "TodoList", var App = app.DefineComponent(AppClient, "App", func(ctx context.Context, _ any) any { // Multiple state hooks example - todos, setTodos := vdom.UseState(ctx, []Todo{ + todos, setTodos, _ := vdom.UseState(ctx, []Todo{ {Id: 1, Text: "Learn VDOM", Completed: false}, {Id: 2, Text: "Build a todo app", Completed: false}, }) - nextId, setNextId := vdom.UseState(ctx, 3) - inputText, setInputText := vdom.UseState(ctx, "") + nextId, setNextId, _ := vdom.UseState(ctx, 3) + inputText, setInputText, _ := vdom.UseState(ctx, "") // Event handlers modifying multiple pieces of state addTodo := func() { diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index 8954ce307f..c218a1f90a 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -287,26 +287,7 @@ func P(propName string, propVal any) any { return map[string]any{propName: propVal} } -func UseState[T any](ctx context.Context, initialVal T) (T, func(T)) { - vc := GetRenderContext(ctx) - hookVal := vc.GetOrderedHook() - 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.AddRenderWork(vc.GetCompWaveId()) - } - return rtnVal, setVal -} - -func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) { +func UseState[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T) T)) { vc := GetRenderContext(ctx) hookVal := vc.GetOrderedHook() if !hookVal.Init { @@ -332,7 +313,7 @@ func UseStateWithFn[T any](ctx context.Context, initialVal T) (T, func(T), func( return rtnVal, setVal, setFuncVal } -func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { +func UseSharedAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { vc := GetRenderContext(ctx) hookVal := vc.GetOrderedHook() if !hookVal.Init { @@ -355,7 +336,13 @@ func UseAtom[T any](ctx context.Context, atomName string) (T, func(T)) { vc.AddRenderWork(waveId) } } - return atomVal, setVal + setFuncVal := func(updateFunc func(T) T) { + atom.Val = updateFunc(atom.Val.(T)) + for waveId := range atom.UsedBy { + vc.AddRenderWork(waveId) + } + } + return atomVal, setVal, setFuncVal } func UseVDomRef(ctx context.Context) *VDomRef { From 4b9154e473b13b5e8842aa700bebd8e231449d3c Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 22:22:22 -0700 Subject: [PATCH 027/134] 3 types of atoms... $shared, $config, and $data... --- tsunami/frontend/src/vdom.tsx | 17 +++++++++-------- tsunami/vdom/vdom.go | 14 +++++++++++++- tsunami/vdom/vdom_html.go | 25 ++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index c87008ab0a..455f485070 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -185,19 +185,20 @@ function resolveBinding(binding: VDomBinding, model: TsunamiModel): [any, string if (bindName == null || bindName == "") { return [null, []]; } - // for now we only recognize $.[atomname] bindings - if (!bindName.startsWith("$.")) { + // validate that bindName starts with valid atom prefix and has at least one char after the dot + const isValidBinding = (bindName.startsWith("$shared.") && bindName.length > 8) || + (bindName.startsWith("$config.") && bindName.length > 8) || + (bindName.startsWith("$data.") && bindName.length > 6); + + if (!isValidBinding) { return [null, []]; } - const atomName = bindName.substring(2); - if (atomName == "") { - return [null, []]; - } - const atom = model.getAtomContainer(atomName); + + const atom = model.getAtomContainer(bindName); if (atom == null) { return [null, []]; } - return [atom.val, [atomName]]; + return [atom.val, [bindName]]; } type GenericPropsType = { [key: string]: any }; diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index c218a1f90a..a95315ebd0 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -313,7 +313,7 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T return rtnVal, setVal, setFuncVal } -func UseSharedAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { +func useAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { vc := GetRenderContext(ctx) hookVal := vc.GetOrderedHook() if !hookVal.Init { @@ -345,6 +345,18 @@ func UseSharedAtom[T any](ctx context.Context, atomName string) (T, func(T), fun return atomVal, setVal, setFuncVal } +func UseSharedAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { + return useAtom[T](ctx, "$shared."+atomName) +} + +func UseConfig[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { + return useAtom[T](ctx, "$config."+atomName) +} + +func UseData[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { + return useAtom[T](ctx, "$data."+atomName) +} + func UseVDomRef(ctx context.Context) *VDomRef { vc := GetRenderContext(ctx) hookVal := vc.GetOrderedHook() diff --git a/tsunami/vdom/vdom_html.go b/tsunami/vdom/vdom_html.go index b167d4d0f4..08a38649a2 100644 --- a/tsunami/vdom/vdom_html.go +++ b/tsunami/vdom/vdom_html.go @@ -84,6 +84,23 @@ func getAttrString(token htmltoken.Token, key string) string { return "" } +func isValidBindingKey(bindKey string) bool { + if bindKey == "" { + return false + } + // validate that bindKey starts with valid atom prefix and has at least one char after the dot + if strings.HasPrefix(bindKey, "$shared.") && len(bindKey) > 8 { + return true + } + if strings.HasPrefix(bindKey, "$config.") && len(bindKey) > 8 { + return true + } + if strings.HasPrefix(bindKey, "$data.") && len(bindKey) > 6 { + return true + } + return false +} + func attrToProp(attrVal string, isJson bool, params map[string]any) any { if isJson { var val any @@ -108,7 +125,7 @@ func attrToProp(attrVal string, isJson bool, params map[string]any) any { } if strings.HasPrefix(attrVal, Html_BindPrefix) { bindKey := attrVal[len(Html_BindPrefix):] - if bindKey == "" { + if bindKey == "" || !isValidBindingKey(bindKey) { return nil } return &VDomBinding{Type: ObjectType_Binding, Bind: bindKey} @@ -364,6 +381,12 @@ outer: } if token.Data == Html_BindTagName { keyAttr := getAttrString(token, "key") + if !isValidBindingKey(keyAttr) { + errText := fmt.Sprintf("invalid binding key: %q (must start with $shared., $config., or $data.)", keyAttr) + errTextElem := TextElem(errText) + appendChildToStack(elemStack, &errTextElem) + continue + } binding := &VDomBinding{Type: ObjectType_Binding, Bind: keyAttr} appendChildToStack(elemStack, &VDomElem{Tag: WaveTextTag, Props: map[string]any{"text": binding}}) continue From e2909248bb3b72b76c4de939e0085b5a2d1c1028 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 22:49:49 -0700 Subject: [PATCH 028/134] synchronize atoms, implement GET /api/data --- tsunami/app/serverhandlers.go | 23 ++++++++++++++ tsunami/comp/root_test.go | 4 +-- tsunami/comp/rootelem.go | 60 +++++++++++++++++++++++++++++++++-- tsunami/comp/vdomcontext.go | 16 ++++++++-- tsunami/vdom/vdom.go | 24 +++++++------- tsunami/vdom/vdom_context.go | 5 ++- 6 files changed, 111 insertions(+), 21 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 922649fa8e..330b98e4aa 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -40,6 +40,7 @@ func NewHTTPHandlers(client *Client) *HTTPHandlers { func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, assetsFS *embed.FS, staticFS *embed.FS) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) + mux.HandleFunc("/api/data", h.handleData) mux.HandleFunc("/files/", h.handleAssetsUrl) // Add handler for static files at /static/ path @@ -155,6 +156,28 @@ func (h *HTTPHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpda return update, nil } +func (h *HTTPHandlers) handleData(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleData", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + result := h.Client.Root.GetDataMap() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Printf("failed to encode data response: %v", err) + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + func (h *HTTPHandlers) handleAssetsUrl(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleAssetsUrl", recover()) diff --git a/tsunami/comp/root_test.go b/tsunami/comp/root_test.go index 397b1dc24b..346b30b58f 100644 --- a/tsunami/comp/root_test.go +++ b/tsunami/comp/root_test.go @@ -22,7 +22,7 @@ type TestContext struct { } func Page(ctx context.Context, props map[string]any) any { - clicked, setClicked := vdom.UseState(ctx, false) + clicked, setClicked, _ := vdom.UseState(ctx, false) var clickedDiv *vdom.VDomElem if clicked { clickedDiv = vdom.Bind(`

    `, nil) @@ -45,7 +45,7 @@ func Page(ctx context.Context, props map[string]any) any { func Button(ctx context.Context, props map[string]any) any { ref := vdom.UseVDomRef(ctx) - clName, setClName := vdom.UseState(ctx, "button") + clName, setClName, _ := vdom.UseState(ctx, "button") vdom.UseEffect(ctx, func() func() { fmt.Printf("Button useEffect\n") setClName("button mounted") diff --git a/tsunami/comp/rootelem.go b/tsunami/comp/rootelem.go index 6e5811f4b5..2fe8037c76 100644 --- a/tsunami/comp/rootelem.go +++ b/tsunami/comp/rootelem.go @@ -10,6 +10,7 @@ import ( "reflect" "strconv" "strings" + "sync" "github.com/google/uuid" "github.com/wavetermdev/waveterm/tsunami/rpctypes" @@ -30,6 +31,7 @@ type RootElem struct { EffectWorkQueue []*vdom.EffectWorkElem NeedsRenderMap map[string]bool Atoms map[string]*vdom.Atom + atomLock sync.Mutex RefOperations []rpctypes.VDomRefOperation } @@ -44,6 +46,20 @@ func (r *RootElem) AddEffectWork(id string, effectIndex int) { r.EffectWorkQueue = append(r.EffectWorkQueue, &vdom.EffectWorkElem{Id: id, EffectIndex: effectIndex}) } +func (r *RootElem) GetDataMap() map[string]any { + r.atomLock.Lock() + defer r.atomLock.Unlock() + + result := make(map[string]any) + for atomName, atom := range r.Atoms { + if strings.HasPrefix(atomName, "$data.") { + strippedName := strings.TrimPrefix(atomName, "$data.") + result[strippedName] = atom.Val + } + } + return result +} + func MakeRoot() *RootElem { return &RootElem{ Root: nil, @@ -53,7 +69,7 @@ func MakeRoot() *RootElem { } } -func (r *RootElem) GetAtom(name string) *vdom.Atom { +func (r *RootElem) ensureAtomNoLock(name string) *vdom.Atom { atom, ok := r.Atoms[name] if !ok { atom = &vdom.Atom{UsedBy: make(map[string]bool)} @@ -62,12 +78,47 @@ func (r *RootElem) GetAtom(name string) *vdom.Atom { return atom } + +func (r *RootElem) AtomSetUsedBy(atomName string, waveId string, used bool) { + r.atomLock.Lock() + defer r.atomLock.Unlock() + + atom := r.ensureAtomNoLock(atomName) + if used { + atom.UsedBy[waveId] = true + } else { + delete(atom.UsedBy, waveId) + } +} + +func (r *RootElem) AtomAddRenderWork(atomName string) { + r.atomLock.Lock() + defer r.atomLock.Unlock() + + atom, ok := r.Atoms[atomName] + if !ok { + return + } + for compId := range atom.UsedBy { + r.AddRenderWork(compId) + } +} + func (r *RootElem) GetAtomVal(name string) any { - atom := r.GetAtom(name) + r.atomLock.Lock() + defer r.atomLock.Unlock() + + atom, ok := r.Atoms[name] + if !ok { + return nil + } return atom.Val } func (r *RootElem) GetStateSync(full bool) []rpctypes.VDomStateSync { + r.atomLock.Lock() + defer r.atomLock.Unlock() + stateSync := make([]rpctypes.VDomStateSync, 0) for atomName, atom := range r.Atoms { if atom.Dirty || full { @@ -79,7 +130,10 @@ func (r *RootElem) GetStateSync(full bool) []rpctypes.VDomStateSync { } func (r *RootElem) SetAtomVal(name string, val any, markDirty bool) { - atom := r.GetAtom(name) + r.atomLock.Lock() + defer r.atomLock.Unlock() + + atom := r.ensureAtomNoLock(name) if !markDirty { atom.Val = val return diff --git a/tsunami/comp/vdomcontext.go b/tsunami/comp/vdomcontext.go index a9594757f9..59d09bb857 100644 --- a/tsunami/comp/vdomcontext.go +++ b/tsunami/comp/vdomcontext.go @@ -27,8 +27,20 @@ func (vc *VDomContextVal) AddEffectWork(id string, effectIndex int) { vc.Root.AddEffectWork(id, effectIndex) } -func (vc *VDomContextVal) GetAtom(atomName string) *vdom.Atom { - return vc.Root.GetAtom(atomName) +func (vc *VDomContextVal) AtomSetUsedBy(atomName string, waveId string, used bool) { + vc.Root.AtomSetUsedBy(atomName, waveId, used) +} + +func (vc *VDomContextVal) AtomAddRenderWork(atomName string) { + vc.Root.AtomAddRenderWork(atomName) +} + +func (vc *VDomContextVal) GetAtomVal(atomName string) any { + return vc.Root.GetAtomVal(atomName) +} + +func (vc *VDomContextVal) SetAtomVal(atomName string, val any, markDirty bool) { + vc.Root.SetAtomVal(atomName, val, markDirty) } func (vc *VDomContextVal) GetRenderTs() int64 { diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index a95315ebd0..ed7977ff1d 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -320,27 +320,25 @@ func useAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func hookVal.Init = true closedWaveId := vc.GetCompWaveId() hookVal.UnmountFn = func() { - atom := vc.GetAtom(atomName) - delete(atom.UsedBy, closedWaveId) + vc.AtomSetUsedBy(atomName, closedWaveId, false) } } - atom := vc.GetAtom(atomName) - atom.UsedBy[vc.GetCompWaveId()] = true - atomVal, ok := atom.Val.(T) + vc.AtomSetUsedBy(atomName, vc.GetCompWaveId(), true) + atomVal, ok := vc.GetAtomVal(atomName).(T) if !ok { - panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, atom.Val)) + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, vc.GetAtomVal(atomName))) } setVal := func(newVal T) { - atom.Val = newVal - for waveId := range atom.UsedBy { - vc.AddRenderWork(waveId) - } + vc.SetAtomVal(atomName, newVal, true) + vc.AtomAddRenderWork(atomName) } setFuncVal := func(updateFunc func(T) T) { - atom.Val = updateFunc(atom.Val.(T)) - for waveId := range atom.UsedBy { - vc.AddRenderWork(waveId) + currentVal, ok := vc.GetAtomVal(atomName).(T) + if !ok { + panic(fmt.Sprintf("UseAtom %q value type mismatch in setFuncVal", atomName)) } + vc.SetAtomVal(atomName, updateFunc(currentVal), true) + vc.AtomAddRenderWork(atomName) } return atomVal, setVal, setFuncVal } diff --git a/tsunami/vdom/vdom_context.go b/tsunami/vdom/vdom_context.go index 0dbdd4a9b5..bebecfdb0b 100644 --- a/tsunami/vdom/vdom_context.go +++ b/tsunami/vdom/vdom_context.go @@ -14,7 +14,10 @@ var vdomContextKey = vdomContextKeyType{} type VDomContext interface { AddRenderWork(id string) AddEffectWork(id string, effectIndex int) - GetAtom(atomName string) *Atom + AtomSetUsedBy(atomName string, waveId string, used bool) + AtomAddRenderWork(atomName string) + GetAtomVal(atomName string) any + SetAtomVal(atomName string, val any, markDirty bool) GetRenderTs() int64 GetCompWaveId() string GetOrderedHook() *Hook From be395861021c11474ada257d3039371ce4f89f70 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 23:16:59 -0700 Subject: [PATCH 029/134] implement /api/config and /api/data --- tsunami/app/serverhandlers.go | 50 +++++++++++++++++++++++++++++++++++ tsunami/comp/rootelem.go | 14 ++++++++++ 2 files changed, 64 insertions(+) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 330b98e4aa..2b233eba47 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -41,6 +41,7 @@ func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, assetsFS *embed.FS, mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/api/data", h.handleData) + mux.HandleFunc("/api/config", h.handleConfig) mux.HandleFunc("/files/", h.handleAssetsUrl) // Add handler for static files at /static/ path @@ -178,6 +179,55 @@ func (h *HTTPHandlers) handleData(w http.ResponseWriter, r *http.Request) { } } +func (h *HTTPHandlers) handleConfig(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleConfig", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + switch r.Method { + case http.MethodGet: + h.handleConfigGet(w, r) + case http.MethodPost: + h.handleConfigPost(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *HTTPHandlers) handleConfigGet(w http.ResponseWriter, r *http.Request) { + result := h.Client.Root.GetConfigMap() + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Printf("failed to encode config response: %v", err) + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +func (h *HTTPHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest) + return + } + + var configData map[string]any + if err := json.Unmarshal(body, &configData); err != nil { + http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest) + return + } + + for key, value := range configData { + atomName := "$config." + key + h.Client.Root.SetAtomVal(atomName, value, true) + } + + w.WriteHeader(http.StatusOK) +} + func (h *HTTPHandlers) handleAssetsUrl(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleAssetsUrl", recover()) diff --git a/tsunami/comp/rootelem.go b/tsunami/comp/rootelem.go index 2fe8037c76..0ab5197833 100644 --- a/tsunami/comp/rootelem.go +++ b/tsunami/comp/rootelem.go @@ -60,6 +60,20 @@ func (r *RootElem) GetDataMap() map[string]any { return result } +func (r *RootElem) GetConfigMap() map[string]any { + r.atomLock.Lock() + defer r.atomLock.Unlock() + + result := make(map[string]any) + for atomName, atom := range r.Atoms { + if strings.HasPrefix(atomName, "$config.") { + strippedName := strings.TrimPrefix(atomName, "$config.") + result[strippedName] = atom.Val + } + } + return result +} + func MakeRoot() *RootElem { return &RootElem{ Root: nil, From 12318a4bc89b8df46798f0d6cc9dbc7d403264e4 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 1 Sep 2025 23:41:05 -0700 Subject: [PATCH 030/134] make some methods private, and implement a SetAppOpts method --- tsunami/app/serverhandlers.go | 12 +++---- tsunami/app/tsunamiapp.go | 66 +++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 2b233eba47..e8ddbe0897 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -81,10 +81,10 @@ func (h *HTTPHandlers) handleRender(w http.ResponseWriter, r *http.Request) { } if feUpdate.ForceTakeover { - h.Client.ClientTakeover(feUpdate.ClientId) + h.Client.clientTakeover(feUpdate.ClientId) } - if err := h.Client.CheckClientId(feUpdate.ClientId); err != nil { + if err := h.Client.checkClientId(feUpdate.ClientId); err != nil { http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) return } @@ -197,7 +197,7 @@ func (h *HTTPHandlers) handleConfig(w http.ResponseWriter, r *http.Request) { } } -func (h *HTTPHandlers) handleConfigGet(w http.ResponseWriter, r *http.Request) { +func (h *HTTPHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) { result := h.Client.Root.GetConfigMap() w.Header().Set("Content-Type", "application/json") @@ -246,10 +246,6 @@ func (h *HTTPHandlers) handleAssetsUrl(w http.ResponseWriter, r *http.Request) { ServeFileOption(w, r, *h.Client.GlobalStylesOption) return } - if h.Client.OverrideUrlHandler != nil { - h.Client.OverrideUrlHandler.ServeHTTP(w, r) - return - } h.Client.UrlHandlerMux.ServeHTTP(w, r) } @@ -267,7 +263,7 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { } clientId := r.URL.Query().Get("clientId") - if err := h.Client.CheckClientId(clientId); err != nil { + if err := h.Client.checkClientId(clientId); err != nil { http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest) return } diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index e3b9c0e8ed..1113e09b68 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -59,17 +59,28 @@ type Client struct { GlobalEventHandler func(client *Client, event vdom.VDomEvent) GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router - OverrideUrlHandler http.Handler SetupFn func() } +func MakeClient(appOpts AppOpts) *Client { + client := &Client{ + Lock: &sync.Mutex{}, + Root: comp.MakeRoot(), + DoneCh: make(chan struct{}), + SSEventCh: make(chan SSEvent, 100), + UrlHandlerMux: mux.NewRouter(), + } + client.SetAppOpts(appOpts) + return client +} + func (c *Client) GetIsDone() bool { c.Lock.Lock() defer c.Lock.Unlock() return c.IsDone } -func (c *Client) CheckClientId(clientId string) error { +func (c *Client) checkClientId(clientId string) error { if clientId == "" { return fmt.Errorf("client id cannot be empty") } @@ -82,7 +93,7 @@ func (c *Client) CheckClientId(clientId string) error { return fmt.Errorf("client id mismatch: expected %s, got %s", c.CurrentClientId, clientId) } -func (c *Client) ClientTakeover(clientId string) { +func (c *Client) clientTakeover(clientId string) { c.Lock.Lock() defer c.Lock.Unlock() c.CurrentClientId = clientId @@ -103,40 +114,39 @@ func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.V c.GlobalEventHandler = handler } -func (c *Client) SetOverrideUrlHandler(handler http.Handler) { - c.OverrideUrlHandler = handler -} +func (c *Client) SetAppOpts(appOpts AppOpts) { + c.Lock.Lock() + defer c.Lock.Unlock() -func MakeClient(appOpts AppOpts) *Client { if appOpts.RootComponentName == "" { appOpts.RootComponentName = "App" } - client := &Client{ - Lock: &sync.Mutex{}, - AppOpts: appOpts, - Root: comp.MakeRoot(), - DoneCh: make(chan struct{}), - SSEventCh: make(chan SSEvent, 100), - UrlHandlerMux: mux.NewRouter(), - Opts: rpctypes.VDomBackendOpts{ - CloseOnCtrlC: appOpts.CloseOnCtrlC, - GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents, - Title: appOpts.Title, - }, - } + + c.AppOpts = appOpts + + // Update the VDomBackendOpts + c.Opts.CloseOnCtrlC = appOpts.CloseOnCtrlC + c.Opts.GlobalKeyboardEvents = appOpts.GlobalKeyboardEvents + c.Opts.Title = appOpts.Title + + // Update RootElem if component name changed + c.RootElem = vdom.E(appOpts.RootComponentName) + + // Update global styles if len(appOpts.GlobalStyles) > 0 { - client.Opts.GlobalStyles = true - client.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"} + c.Opts.GlobalStyles = true + c.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"} + } else { + c.Opts.GlobalStyles = false + c.GlobalStylesOption = nil } - client.SetRootElem(vdom.E(appOpts.RootComponentName)) - return client } func (c *Client) runMainE() error { if c.SetupFn != nil { c.SetupFn() } - err := c.ListenAndServe(context.Background()) + err := c.listenAndServe(context.Background()) if err != nil { return err } @@ -156,7 +166,7 @@ func (c *Client) RunMain() { } } -func (c *Client) ListenAndServe(ctx context.Context) error { +func (c *Client) listenAndServe(ctx context.Context) error { // Create HTTP handlers handlers := NewHTTPHandlers(c) @@ -205,10 +215,6 @@ func (c *Client) ListenAndServe(ctx context.Context) error { return nil } -func (c *Client) SetRootElem(elem *vdom.VDomElem) { - c.RootElem = elem -} - func (c *Client) SendAsyncInitiation() error { if c.GetIsDone() { return fmt.Errorf("client is done") From 0cfa0f83815d6dd83bd0f639d5eccdac72735fe3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 00:25:58 -0700 Subject: [PATCH 031/134] default client + working on better type conversions for useatom... --- tsunami/app/defaultclient.go | 63 +++++++++++++++++++++++++++++ tsunami/app/tsunamiapp.go | 6 +-- tsunami/demo/todo/main-todo.go | 24 ++++++----- tsunami/util/compare.go | 74 ++++++++++++++++++++++++++++++++++ tsunami/vdom/vdom.go | 30 ++++++++++---- 5 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 tsunami/app/defaultclient.go diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go new file mode 100644 index 0000000000..2bb516635e --- /dev/null +++ b/tsunami/app/defaultclient.go @@ -0,0 +1,63 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "net/http" + + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +var defaultClient = MakeClient(AppOpts{}) + +// Default client methods that operate on the global defaultClient + +func DefineComponent[P any](name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { + return DefineComponentEx(defaultClient, name, renderFn) +} + +func SetGlobalEventHandler(handler func(client *Client, event vdom.VDomEvent)) { + defaultClient.SetGlobalEventHandler(handler) +} + +func SetAppOpts(appOpts AppOpts) { + defaultClient.SetAppOpts(appOpts) +} + +func AddSetupFn(fn func()) { + defaultClient.AddSetupFn(fn) +} + +func RunMain() { + defaultClient.RunMain() +} + +func SendAsyncInitiation() error { + return defaultClient.SendAsyncInitiation() +} + +func SetAtomVals(m map[string]any) { + defaultClient.SetAtomVals(m) +} + +func SetAtomVal(name string, val any) { + defaultClient.SetAtomVal(name, val) +} + +func GetAtomVal(name string) any { + return defaultClient.GetAtomVal(name) +} + +func RegisterUrlPathHandler(path string, handler http.Handler) { + defaultClient.RegisterUrlPathHandler(path, handler) +} + +func RegisterFilePrefixHandler(prefix string, optionProvider func(path string) (*FileHandlerOption, error)) { + defaultClient.RegisterFilePrefixHandler(prefix, optionProvider) +} + +func RegisterFileHandler(path string, option FileHandlerOption) { + defaultClient.RegisterFileHandler(path, option) +} \ No newline at end of file diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 1113e09b68..6cd4ea921b 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -246,14 +246,14 @@ func makeNullVDom() *vdom.VDomElem { return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } -func DefineComponent[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { +func DefineComponentEx[P any](client *Client, name string, renderFn func(ctx context.Context, props P) any) vdom.Component[P] { if name == "" { panic("Component name cannot be empty") } if !unicode.IsUpper(rune(name[0])) { panic("Component name must start with an uppercase letter") } - err := client.RegisterComponent(name, renderFn) + err := client.registerComponent(name, renderFn) if err != nil { panic(err) } @@ -262,7 +262,7 @@ func DefineComponent[P any](client *Client, name string, renderFn func(ctx conte } } -func (c *Client) RegisterComponent(name string, cfunc any) error { +func (c *Client) registerComponent(name string, cfunc any) error { return c.Root.RegisterComponent(name, cfunc) } diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/main-todo.go index 04a87464b3..5fb0d56af4 100644 --- a/tsunami/demo/todo/main-todo.go +++ b/tsunami/demo/todo/main-todo.go @@ -12,12 +12,14 @@ import ( //go:embed tw.css var styleCSS []byte -// Initialize client with embedded Tailwind styles and ctrl-c handling -var AppClient = app.MakeClient(app.AppOpts{ - CloseOnCtrlC: true, - GlobalStyles: styleCSS, - Title: "Todo App (Tsunami Demo)", -}) +func init() { + // Set up the default client with embedded Tailwind styles and ctrl-c handling + app.SetAppOpts(app.AppOpts{ + CloseOnCtrlC: true, + GlobalStyles: styleCSS, + Title: "Todo App (Tsunami Demo)", + }) +} // Basic domain types with json tags for props type Todo struct { @@ -46,7 +48,7 @@ type InputFieldProps struct { } // Reusable input component showing keyboard event handling -var InputField = app.DefineComponent(AppClient, "InputField", +var InputField = app.DefineComponent("InputField", func(ctx context.Context, props InputFieldProps) any { // Example of special key handling with VDomFunc keyDown := &vdom.VDomFunc{ @@ -71,7 +73,7 @@ var InputField = app.DefineComponent(AppClient, "InputField", ) // Item component showing conditional classes and event handling -var TodoItem = app.DefineComponent(AppClient, "TodoItem", +var TodoItem = app.DefineComponent("TodoItem", func(ctx context.Context, props TodoItemProps) any { return vdom.H("div", map[string]any{ "className": vdom.Classes("flex items-center gap-2.5 p-2 border border-border rounded", vdom.If(props.Todo.Completed, "opacity-70")), @@ -94,7 +96,7 @@ var TodoItem = app.DefineComponent(AppClient, "TodoItem", ) // List component demonstrating mapping over data, using WithKey to set key on a component -var TodoList = app.DefineComponent(AppClient, "TodoList", +var TodoList = app.DefineComponent("TodoList", func(ctx context.Context, props TodoListProps) any { return vdom.H("div", map[string]any{ "className": "flex flex-col gap-2", @@ -109,7 +111,7 @@ var TodoList = app.DefineComponent(AppClient, "TodoList", ) // Root component showing state management and composition -var App = app.DefineComponent(AppClient, "App", +var App = app.DefineComponent("App", func(ctx context.Context, _ any) any { // Multiple state hooks example todos, setTodos, _ := vdom.UseState(ctx, []Todo{ @@ -187,5 +189,5 @@ var App = app.DefineComponent(AppClient, "App", ) func main() { - AppClient.RunMain() + app.RunMain() } diff --git a/tsunami/util/compare.go b/tsunami/util/compare.go index 41299af5e2..3792f2624c 100644 --- a/tsunami/util/compare.go +++ b/tsunami/util/compare.go @@ -4,6 +4,7 @@ package util import ( + "math" "reflect" "strconv" ) @@ -163,4 +164,77 @@ func NumToString[T any](value T) (string, bool) { default: return "", false } +} + +// FromFloat64 converts a float64 to the specified numeric type T +// Returns the converted value and a bool indicating if the conversion was successful +func FromFloat64[T any](val float64) (T, bool) { + var zero T + + // Check for NaN or infinity + if math.IsNaN(val) || math.IsInf(val, 0) { + return zero, false + } + + switch any(zero).(type) { + case int: + if val != float64(int64(val)) || val < math.MinInt || val > math.MaxInt { + return zero, false + } + return any(int(val)).(T), true + case int8: + if val != float64(int64(val)) || val < math.MinInt8 || val > math.MaxInt8 { + return zero, false + } + return any(int8(val)).(T), true + case int16: + if val != float64(int64(val)) || val < math.MinInt16 || val > math.MaxInt16 { + return zero, false + } + return any(int16(val)).(T), true + case int32: + if val != float64(int64(val)) || val < math.MinInt32 || val > math.MaxInt32 { + return zero, false + } + return any(int32(val)).(T), true + case int64: + if val != float64(int64(val)) || val < math.MinInt64 || val > math.MaxInt64 { + return zero, false + } + return any(int64(val)).(T), true + case uint: + if val < 0 || val != float64(uint64(val)) || val > math.MaxUint { + return zero, false + } + return any(uint(val)).(T), true + case uint8: + if val < 0 || val != float64(uint64(val)) || val > math.MaxUint8 { + return zero, false + } + return any(uint8(val)).(T), true + case uint16: + if val < 0 || val != float64(uint64(val)) || val > math.MaxUint16 { + return zero, false + } + return any(uint16(val)).(T), true + case uint32: + if val < 0 || val != float64(uint64(val)) || val > math.MaxUint32 { + return zero, false + } + return any(uint32(val)).(T), true + case uint64: + if val < 0 || val != float64(uint64(val)) || val > math.MaxUint64 { + return zero, false + } + return any(uint64(val)).(T), true + case float32: + if math.Abs(val) > math.MaxFloat32 { + return zero, false + } + return any(float32(val)).(T), true + case float64: + return any(val).(T), true + default: + return zero, false + } } \ No newline at end of file diff --git a/tsunami/vdom/vdom.go b/tsunami/vdom/vdom.go index ed7977ff1d..bb9abfb922 100644 --- a/tsunami/vdom/vdom.go +++ b/tsunami/vdom/vdom.go @@ -313,6 +313,26 @@ func UseState[T any](ctx context.Context, initialVal T) (T, func(T), func(func(T return rtnVal, setVal, setFuncVal } +func getTypedAtomValue[T any](rawVal any, atomName string) T { + var result T + if rawVal == nil { + return *new(T) + } + + var ok bool + result, ok = rawVal.(T) + if !ok { + // Try converting from float64 if rawVal is float64 + if f64Val, isFloat64 := rawVal.(float64); isFloat64 { + if converted, convOk := util.FromFloat64[T](f64Val); convOk { + return converted + } + } + panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, *new(T), rawVal)) + } + return result +} + func useAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func(T) T)) { vc := GetRenderContext(ctx) hookVal := vc.GetOrderedHook() @@ -324,19 +344,13 @@ func useAtom[T any](ctx context.Context, atomName string) (T, func(T), func(func } } vc.AtomSetUsedBy(atomName, vc.GetCompWaveId(), true) - atomVal, ok := vc.GetAtomVal(atomName).(T) - if !ok { - panic(fmt.Sprintf("UseAtom %q value type mismatch (expected %T, got %T)", atomName, atomVal, vc.GetAtomVal(atomName))) - } + atomVal := getTypedAtomValue[T](vc.GetAtomVal(atomName), atomName) setVal := func(newVal T) { vc.SetAtomVal(atomName, newVal, true) vc.AtomAddRenderWork(atomName) } setFuncVal := func(updateFunc func(T) T) { - currentVal, ok := vc.GetAtomVal(atomName).(T) - if !ok { - panic(fmt.Sprintf("UseAtom %q value type mismatch in setFuncVal", atomName)) - } + currentVal := getTypedAtomValue[T](vc.GetAtomVal(atomName), atomName) vc.SetAtomVal(atomName, updateFunc(currentVal), true) vc.AtomAddRenderWork(atomName) } From 927be3f094ec27048ac06e969987f3424aebd9c7 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 09:53:34 -0700 Subject: [PATCH 032/134] implement /api/manifest (register manifest file) --- tsunami/app/defaultclient.go | 18 +++++++++++++++- tsunami/app/serverhandlers.go | 40 ++++++++++++++++++++++++++++++----- tsunami/app/tsunamiapp.go | 18 +++++----------- 3 files changed, 57 insertions(+), 19 deletions(-) diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index 2bb516635e..c9b63b38c8 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -5,12 +5,16 @@ package app import ( "context" + "embed" "net/http" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var defaultClient = MakeClient(AppOpts{}) +var assetsFS *embed.FS +var staticFS *embed.FS +var manifestFile *FileHandlerOption // Default client methods that operate on the global defaultClient @@ -60,4 +64,16 @@ func RegisterFilePrefixHandler(prefix string, optionProvider func(path string) ( func RegisterFileHandler(path string, option FileHandlerOption) { defaultClient.RegisterFileHandler(path, option) -} \ No newline at end of file +} + +func RegisterAssetsFS(fs embed.FS) { + assetsFS = &fs +} + +func RegisterStaticFS(fs embed.FS) { + staticFS = &fs +} + +func RegisterManifestFile(option FileHandlerOption) { + manifestFile = &option +} diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index e8ddbe0897..efc52c6688 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -26,6 +26,12 @@ func init() { mime.AddExtensionType(".json", "application/json") } +type HandlerOpts struct { + AssetsFS *embed.FS + StaticFS *embed.FS + ManifestFile *FileHandlerOption +} + type HTTPHandlers struct { Client *Client renderLock sync.Mutex @@ -37,21 +43,22 @@ func NewHTTPHandlers(client *Client) *HTTPHandlers { } } -func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, assetsFS *embed.FS, staticFS *embed.FS) { +func (h *HTTPHandlers) RegisterHandlers(mux *http.ServeMux, opts HandlerOpts) { mux.HandleFunc("/api/render", h.handleRender) mux.HandleFunc("/api/updates", h.handleSSE) mux.HandleFunc("/api/data", h.handleData) mux.HandleFunc("/api/config", h.handleConfig) + mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile)) mux.HandleFunc("/files/", h.handleAssetsUrl) // Add handler for static files at /static/ path - if staticFS != nil { - mux.HandleFunc("/static/", h.handleStaticPathFiles(staticFS)) + if opts.StaticFS != nil { + mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS)) } // Add fallback handler for embedded static files in production mode - if assetsFS != nil { - mux.HandleFunc("/", h.handleStaticFiles(assetsFS)) + if opts.AssetsFS != nil { + mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS)) } } @@ -337,6 +344,29 @@ func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc } } +func (h *HTTPHandlers) handleManifest(manifestFile *FileHandlerOption) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + panicErr := util.PanicHandler("handleManifest", recover()) + if panicErr != nil { + http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError) + } + }() + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if manifestFile == nil { + http.NotFound(w, r) + return + } + + ServeFileOption(w, r, *manifestFile) + } +} + func (h *HTTPHandlers) handleStaticPathFiles(staticFS *embed.FS) http.HandlerFunc { // Create a file server from the embedded FS fileServer := http.FileServer(http.FS(staticFS)) diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 6cd4ea921b..8d6409c5a9 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -5,7 +5,6 @@ package app import ( "context" - "embed" "fmt" "io" "io/fs" @@ -29,9 +28,6 @@ import ( const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" -var assetsFS *embed.FS -var staticFS *embed.FS - type SSEvent struct { Event string Data []byte @@ -172,7 +168,11 @@ func (c *Client) listenAndServe(ctx context.Context) error { // Create a new ServeMux and register handlers mux := http.NewServeMux() - handlers.RegisterHandlers(mux, assetsFS, staticFS) + handlers.RegisterHandlers(mux, HandlerOpts{ + AssetsFS: assetsFS, + StaticFS: staticFS, + ManifestFile: manifestFile, + }) // Determine listen address from environment variable or use default listenAddr := os.Getenv(TsunamiListenAddrEnvVar) @@ -460,11 +460,3 @@ func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) { } }) } - -func RegisterAssetsFS(fs embed.FS) { - assetsFS = &fs -} - -func RegisterStaticFS(fs embed.FS) { - staticFS = &fs -} From a46558c1ed706e1099430bc29f1dc8c80b6574ee Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 10:04:51 -0700 Subject: [PATCH 033/134] tsunami bin ... build, cobra, etc --- Taskfile.yml | 14 +++++++++++ tsunami/cmd/main-tsunami.go | 40 ++++++++++++++++++++++++++++++ tsunami/go.mod | 7 +++++- tsunami/go.sum | 10 ++++++++ tsunami/tsunamibase/tsunamibase.go | 3 +++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tsunami/cmd/main-tsunami.go create mode 100644 tsunami/tsunamibase/tsunamibase.go diff --git a/Taskfile.yml b/Taskfile.yml index 124220e38c..f3d3a1815b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -450,3 +450,17 @@ tasks: desc: Run the tsunami frontend vite dev server cmd: npm run dev dir: tsunami/frontend + + tsunami:build: + desc: Build the tsunami binary. + cmds: + - cmd: "{{.RM}} bin/tsunami*" + ignore_error: true + - mkdir -p bin + - cd tsunami && go build -ldflags "-X main.BuildTime=$({{.DATE}} +'%Y%m%d%H%M') -X main.TsunamiVersion={{.VERSION}}" -o ../bin/tsunami{{exeExt}} cmd/main-tsunami.go + sources: + - "tsunami/**/*.go" + - "tsunami/go.mod" + - "tsunami/go.sum" + generates: + - "bin/tsunami{{exeExt}}" diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go new file mode 100644 index 0000000000..b5c526c8f9 --- /dev/null +++ b/tsunami/cmd/main-tsunami.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/tsunami/tsunamibase" +) + +// these are set at build time +var TsunamiVersion = "0.0.0" +var BuildTime = "0" + +var rootCmd = &cobra.Command{ + Use: "tsunami", + Short: "Tsunami - A VDOM-based UI framework", + Long: `Tsunami is a VDOM-based UI framework for building modern applications.`, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print Tsunami version", + Long: `Print Tsunami version`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("v" + tsunamibase.TsunamiVersion) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func main() { + tsunamibase.TsunamiVersion = TsunamiVersion + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/tsunami/go.mod b/tsunami/go.mod index eb3016560f..c197f7b4f1 100644 --- a/tsunami/go.mod +++ b/tsunami/go.mod @@ -5,7 +5,12 @@ go 1.24.6 require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/spf13/cobra v1.10.1 github.com/wavetermdev/htmltoken v0.2.0 ) -require golang.org/x/net v0.43.0 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/net v0.43.0 // indirect +) diff --git a/tsunami/go.sum b/tsunami/go.sum index 4fe0663af0..63173da653 100644 --- a/tsunami/go.sum +++ b/tsunami/go.sum @@ -1,8 +1,18 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tsunami/tsunamibase/tsunamibase.go b/tsunami/tsunamibase/tsunamibase.go new file mode 100644 index 0000000000..0242bad120 --- /dev/null +++ b/tsunami/tsunamibase/tsunamibase.go @@ -0,0 +1,3 @@ +package tsunamibase + +var TsunamiVersion = "0.0.0" \ No newline at end of file From e9d02586e488558dc35d514fe07589c25bb391e7 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 16:49:34 -0700 Subject: [PATCH 034/134] starting the build/run commands --- tsunami/build/build.go | 144 ++++++++++++++++++++++++++++++++++++ tsunami/build/buildutil.go | 116 +++++++++++++++++++++++++++++ tsunami/cmd/main-tsunami.go | 45 ++++++++++- 3 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tsunami/build/build.go create mode 100644 tsunami/build/buildutil.go diff --git a/tsunami/build/build.go b/tsunami/build/build.go new file mode 100644 index 0000000000..df8c899206 --- /dev/null +++ b/tsunami/build/build.go @@ -0,0 +1,144 @@ +package build + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +type BuildOpts struct { + Dir string + Verbose bool +} + +func verifyTsunamiDir(dir string) error { + if dir == "" { + return fmt.Errorf("directory path cannot be empty") + } + + // Check if directory exists + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dir) + } + return fmt.Errorf("error accessing directory %q: %w", dir, err) + } + + if !info.IsDir() { + return fmt.Errorf("%q is not a directory", dir) + } + + // Check for app.go file + appGoPath := filepath.Join(dir, "app.go") + if err := CheckFileExists(appGoPath); err != nil { + return fmt.Errorf("app.go check failed in directory %q: %w", dir, err) + } + + // Check static directory if it exists + staticPath := filepath.Join(dir, "static") + if err := IsDirOrNotFound(staticPath); err != nil { + return fmt.Errorf("static directory check failed in %q: %w", dir, err) + } + + // Check that dist doesn't exist + distPath := filepath.Join(dir, "dist") + if err := FileMustNotExist(distPath); err != nil { + return fmt.Errorf("dist check failed in %q: %w", dir, err) + } + + return nil +} + +func TsunamiBuild(opts BuildOpts) error { + if err := verifyTsunamiDir(opts.Dir); err != nil { + return err + } + + // Create temporary directory + tempDir, err := os.MkdirTemp("", "tsunami-build-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + + log.Printf("Building tsunami app from %s\n", opts.Dir) + + if opts.Verbose { + log.Printf("Temp dir: %s\n", tempDir) + } + + // Copy all *.go files from the root directory + goCount, err := copyGoFiles(opts.Dir, tempDir) + if err != nil { + return fmt.Errorf("failed to copy go files: %w", err) + } + + // Copy static directory + staticCount, err := copyStaticDir(opts.Dir, tempDir) + if err != nil { + return fmt.Errorf("failed to copy static directory: %w", err) + } + + // Create dist directory + distDir := filepath.Join(tempDir, "dist") + if err := os.MkdirAll(distDir, 0755); err != nil { + return fmt.Errorf("failed to create dist directory: %w", err) + } + + if opts.Verbose { + log.Printf("Copied %d go files, %d static files\n", goCount, staticCount) + } + return nil +} + +func copyStaticDir(srcDir, destDir string) (int, error) { + // Always create static directory in temp dir + staticDestDir := filepath.Join(destDir, "static") + if err := os.MkdirAll(staticDestDir, 0755); err != nil { + return 0, fmt.Errorf("failed to create static directory: %w", err) + } + + // Copy static/ directory contents if it exists + staticSrcDir := filepath.Join(srcDir, "static") + if _, err := os.Stat(staticSrcDir); err == nil { + return copyDirRecursive(staticSrcDir, staticDestDir) + } + + return 0, nil +} + +func copyGoFiles(srcDir, destDir string) (int, error) { + entries, err := os.ReadDir(srcDir) + if err != nil { + return 0, err + } + + fileCount := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if strings.HasSuffix(entry.Name(), ".go") { + srcPath := filepath.Join(srcDir, entry.Name()) + destPath := filepath.Join(destDir, entry.Name()) + + if err := copyFile(srcPath, destPath); err != nil { + return 0, fmt.Errorf("failed to copy %s: %w", entry.Name(), err) + } + fileCount++ + } + } + + return fileCount, nil +} + +func TsunamiRun(opts BuildOpts) error { + if err := TsunamiBuild(opts); err != nil { + return err + } + + return fmt.Errorf("TsunamiRun not implemented yet") +} diff --git a/tsunami/build/buildutil.go b/tsunami/build/buildutil.go new file mode 100644 index 0000000000..8e316b9ce5 --- /dev/null +++ b/tsunami/build/buildutil.go @@ -0,0 +1,116 @@ +package build + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +func IsDirOrNotFound(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil // Not found is OK + } + return err // Other errors are not OK + } + + if !info.IsDir() { + return fmt.Errorf("%q exists but is not a directory", path) + } + + return nil // It's a directory, which is OK +} + +func CheckFileExists(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file %q not found", path) + } + return fmt.Errorf("error accessing file %q: %w", path, err) + } + + if info.IsDir() { + return fmt.Errorf("%q is a directory, not a file", path) + } + + return nil +} + +func FileMustNotExist(path string) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("%q must not exist", path) + } else if !os.IsNotExist(err) { + return err // Other errors are not OK + } + return nil // Not found is OK +} + +func copyDirRecursive(srcDir, destDir string) (int, error) { + fileCount := 0 + err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate destination path + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + destPath := filepath.Join(destDir, relPath) + + if info.IsDir() { + // Create directory + if err := os.MkdirAll(destPath, info.Mode()); err != nil { + return err + } + } else { + // Copy file + if err := copyFile(path, destPath); err != nil { + return err + } + fileCount++ + } + + return nil + }) + + return fileCount, err +} + +func copyFile(srcPath, destPath string) error { + // Get source file info for mode + srcInfo, err := os.Stat(srcPath) + if err != nil { + return err + } + + // Create destination directory if it doesn't exist + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + srcFile, err := os.Open(srcPath) + if err != nil { + return err + } + defer srcFile.Close() + + destFile, err := os.Create(destPath) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return err + } + + // Set the same mode as source file + return os.Chmod(destPath, srcInfo.Mode()) +} diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index b5c526c8f9..cea851335b 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/tsunami/build" "github.com/wavetermdev/waveterm/tsunami/tsunamibase" ) @@ -27,8 +28,50 @@ var versionCmd = &cobra.Command{ }, } +var buildCmd = &cobra.Command{ + Use: "build [directory]", + Short: "Build a Tsunami application", + Long: `Build a Tsunami application from the specified directory.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + verbose, _ := cmd.Flags().GetBool("verbose") + opts := build.BuildOpts{ + Dir: args[0], + Verbose: verbose, + } + if err := build.TsunamiBuild(opts); err != nil { + fmt.Printf("Build failed: %v\n", err) + os.Exit(1) + } + }, +} + +var runCmd = &cobra.Command{ + Use: "run [directory]", + Short: "Build and run a Tsunami application", + Long: `Build and run a Tsunami application from the specified directory.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + verbose, _ := cmd.Flags().GetBool("verbose") + opts := build.BuildOpts{ + Dir: args[0], + Verbose: verbose, + } + if err := build.TsunamiRun(opts); err != nil { + fmt.Printf("Run failed: %v\n", err) + os.Exit(1) + } + }, +} + func init() { rootCmd.AddCommand(versionCmd) + + buildCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + rootCmd.AddCommand(buildCmd) + + runCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + rootCmd.AddCommand(runCmd) } func main() { @@ -37,4 +80,4 @@ func main() { fmt.Println(err) os.Exit(1) } -} \ No newline at end of file +} From eabda0dd07685b4f40455a6c59dc02df867bdf8a Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 21:54:20 -0700 Subject: [PATCH 035/134] update AppOpts, simplify, working on build and tw.css... --- tsunami/app/tsunamiapp.go | 59 +++++++------- tsunami/build/build.go | 82 ++++++++++++++++++++ tsunami/demo/todo/{main-todo.go => app.go} | 4 - tsunami/frontend/index.html | 3 +- tsunami/frontend/src/app.tsx | 25 +++++- tsunami/frontend/src/model/tsunami-model.tsx | 42 ++++++++++ tsunami/frontend/src/types/vdom.d.ts | 3 +- tsunami/frontend/src/vdom.tsx | 54 +++---------- tsunami/rpctypes/types.go | 5 +- 9 files changed, 192 insertions(+), 85 deletions(-) rename tsunami/demo/todo/{main-todo.go => app.go} (98%) diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index 8d6409c5a9..efba12cc5d 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -27,6 +27,7 @@ import ( const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR" const DefaultListenAddr = "localhost:0" +const DefaultComponentName = "App" type SSEvent struct { Event string @@ -37,8 +38,6 @@ type AppOpts struct { Title string // window title CloseOnCtrlC bool GlobalKeyboardEvents bool - GlobalStyles []byte - RootComponentName string // defaults to "App" } type Client struct { @@ -47,11 +46,11 @@ type Client struct { Root *comp.RootElem RootElem *vdom.VDomElem CurrentClientId string + ServerId string IsDone bool DoneReason string DoneCh chan struct{} SSEventCh chan SSEvent - Opts rpctypes.VDomBackendOpts GlobalEventHandler func(client *Client, event vdom.VDomEvent) GlobalStylesOption *FileHandlerOption UrlHandlerMux *mux.Router @@ -65,6 +64,8 @@ func MakeClient(appOpts AppOpts) *Client { DoneCh: make(chan struct{}), SSEventCh: make(chan SSEvent, 100), UrlHandlerMux: mux.NewRouter(), + ServerId: uuid.New().String(), + RootElem: vdom.E(DefaultComponentName), } client.SetAppOpts(appOpts) return client @@ -114,27 +115,27 @@ func (c *Client) SetAppOpts(appOpts AppOpts) { c.Lock.Lock() defer c.Lock.Unlock() - if appOpts.RootComponentName == "" { - appOpts.RootComponentName = "App" - } - c.AppOpts = appOpts +} + +func getFaviconPath() string { + if staticFS != nil { + faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"} + for _, name := range faviconNames { + if _, err := staticFS.Open(name); err == nil { + return "/static/" + name + } + } + } + return "/wave-logo-256.png" +} - // Update the VDomBackendOpts - c.Opts.CloseOnCtrlC = appOpts.CloseOnCtrlC - c.Opts.GlobalKeyboardEvents = appOpts.GlobalKeyboardEvents - c.Opts.Title = appOpts.Title - - // Update RootElem if component name changed - c.RootElem = vdom.E(appOpts.RootComponentName) - - // Update global styles - if len(appOpts.GlobalStyles) > 0 { - c.Opts.GlobalStyles = true - c.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"} - } else { - c.Opts.GlobalStyles = false - c.GlobalStylesOption = nil +func (c *Client) makeBackendOpts() *rpctypes.VDomBackendOpts { + return &rpctypes.VDomBackendOpts{ + Title: c.AppOpts.Title, + CloseOnCtrlC: c.AppOpts.CloseOnCtrlC, + GlobalKeyboardEvents: c.AppOpts.GlobalKeyboardEvents, + FaviconPath: getFaviconPath(), } } @@ -275,10 +276,11 @@ func (c *Client) fullRender() (*rpctypes.VDomBackendUpdate, error) { renderedVDom = makeNullVDom() } return &rpctypes.VDomBackendUpdate{ - Type: "backendupdate", - Ts: time.Now().UnixMilli(), - HasWork: len(c.Root.EffectWorkQueue) > 0, - Opts: &c.Opts, + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + ServerId: c.ServerId, + HasWork: len(c.Root.EffectWorkQueue) > 0, + Opts: c.makeBackendOpts(), RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, @@ -295,8 +297,9 @@ func (c *Client) incrementalRender() (*rpctypes.VDomBackendUpdate, error) { renderedVDom = makeNullVDom() } return &rpctypes.VDomBackendUpdate{ - Type: "backendupdate", - Ts: time.Now().UnixMilli(), + Type: "backendupdate", + Ts: time.Now().UnixMilli(), + ServerId: c.ServerId, RenderUpdates: []rpctypes.VDomRenderUpdate{ {UpdateType: "root", VDom: renderedVDom}, }, diff --git a/tsunami/build/build.go b/tsunami/build/build.go index df8c899206..8363e2de58 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -4,7 +4,10 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" + "regexp" + "strconv" "strings" ) @@ -13,6 +16,81 @@ type BuildOpts struct { Verbose bool } +func verifyEnvironment(verbose bool) error { + // Check if go is in PATH + goPath, err := exec.LookPath("go") + if err != nil { + return fmt.Errorf("go command not found in PATH: %w", err) + } + + // Run go version command + cmd := exec.Command(goPath, "version") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run 'go version': %w", err) + } + + // Parse go version output and check for 1.21+ + versionStr := strings.TrimSpace(string(output)) + if verbose { + log.Printf("Found %s", versionStr) + } + + // Extract version like "go1.21.0" from output + versionRegex := regexp.MustCompile(`go1\.(\d+)`) + matches := versionRegex.FindStringSubmatch(versionStr) + if len(matches) < 2 { + return fmt.Errorf("unable to parse go version from: %s", versionStr) + } + + minor, err := strconv.Atoi(matches[1]) + if err != nil || minor < 21 { + return fmt.Errorf("go version 1.21 or higher required, found: %s", versionStr) + } + + // Check if npx is in PATH + _, err = exec.LookPath("npx") + if err != nil { + return fmt.Errorf("npx command not found in PATH: %w", err) + } + + if verbose { + log.Printf("Found npx in PATH") + } + + // Check Tailwind CSS version + tailwindCmd := exec.Command("npx", "@tailwindcss/cli") + tailwindOutput, err := tailwindCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to run 'npx @tailwindcss/cli': %w", err) + } + + tailwindStr := strings.TrimSpace(string(tailwindOutput)) + lines := strings.Split(tailwindStr, "\n") + if len(lines) == 0 { + return fmt.Errorf("no output from tailwindcss command") + } + + firstLine := lines[0] + if verbose { + log.Printf("Found %s", firstLine) + } + + // Check for v4 (format: "≈ tailwindcss v4.1.12") + tailwindRegex := regexp.MustCompile(`tailwindcss v(\d+)`) + matches = tailwindRegex.FindStringSubmatch(firstLine) + if len(matches) < 2 { + return fmt.Errorf("unable to parse tailwindcss version from: %s", firstLine) + } + + majorVersion, err := strconv.Atoi(matches[1]) + if err != nil || majorVersion != 4 { + return fmt.Errorf("tailwindcss v4 required, found: %s", firstLine) + } + + return nil +} + func verifyTsunamiDir(dir string) error { if dir == "" { return fmt.Errorf("directory path cannot be empty") @@ -53,6 +131,10 @@ func verifyTsunamiDir(dir string) error { } func TsunamiBuild(opts BuildOpts) error { + if err := verifyEnvironment(opts.Verbose); err != nil { + return err + } + if err := verifyTsunamiDir(opts.Dir); err != nil { return err } diff --git a/tsunami/demo/todo/main-todo.go b/tsunami/demo/todo/app.go similarity index 98% rename from tsunami/demo/todo/main-todo.go rename to tsunami/demo/todo/app.go index 5fb0d56af4..e1f62c16b8 100644 --- a/tsunami/demo/todo/main-todo.go +++ b/tsunami/demo/todo/app.go @@ -9,14 +9,10 @@ import ( "github.com/wavetermdev/waveterm/tsunami/vdom" ) -//go:embed tw.css -var styleCSS []byte - func init() { // Set up the default client with embedded Tailwind styles and ctrl-c handling app.SetAppOpts(app.AppOpts{ CloseOnCtrlC: true, - GlobalStyles: styleCSS, Title: "Todo App (Tsunami Demo)", }) } diff --git a/tsunami/frontend/index.html b/tsunami/frontend/index.html index 95f7db0085..80e4e5b3ed 100644 --- a/tsunami/frontend/index.html +++ b/tsunami/frontend/index.html @@ -2,11 +2,10 @@ - Tsunami App - +
    diff --git a/tsunami/frontend/src/app.tsx b/tsunami/frontend/src/app.tsx index d6ffd04b2e..247b940ccf 100644 --- a/tsunami/frontend/src/app.tsx +++ b/tsunami/frontend/src/app.tsx @@ -1,15 +1,34 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useState, useEffect } from "react"; import { TsunamiModel } from "@/model/tsunami-model"; import { VDomView } from "./vdom"; -const globalModel = new TsunamiModel(); - function App() { + const [remountKey, setRemountKey] = useState(0); + const [model, setModel] = useState(() => { + const newModel = new TsunamiModel(); + newModel.remountCallback = () => { + setRemountKey(prev => prev + 1); + }; + return newModel; + }); + + useEffect(() => { + // Create a new model when remount key changes + if (remountKey > 0) { + const newModel = new TsunamiModel(); + newModel.remountCallback = () => { + setRemountKey(prev => prev + 1); + }; + setModel(newModel); + } + }, [remountKey]); + return (
    - +
    ); } diff --git a/tsunami/frontend/src/model/tsunami-model.tsx b/tsunami/frontend/src/model/tsunami-model.tsx index 10782eb447..5b86941370 100644 --- a/tsunami/frontend/src/model/tsunami-model.tsx +++ b/tsunami/frontend/src/model/tsunami-model.tsx @@ -83,7 +83,9 @@ function annotateEvent(event: VDomEvent, propName: string, reactEvent: React.Syn export class TsunamiModel { clientId: string; + serverId: string; viewRef: React.RefObject = { current: null }; + remountCallback: (() => void) | null = null; vdomRoot: jotai.PrimitiveAtom = jotai.atom(); atoms: Map = new Map(); // key is atomname refs: Map = new Map(); // key is refid @@ -108,6 +110,7 @@ export class TsunamiModel { globalVersion: jotai.PrimitiveAtom = jotai.atom(0); hasBackendWork: boolean = false; noPadding: jotai.PrimitiveAtom; + cachedFaviconPath: string | null = null; constructor() { this.clientId = getOrCreateClientId(); @@ -531,10 +534,46 @@ export class TsunamiModel { } } + updateFavicon(faviconPath: string | null) { + if (faviconPath === this.cachedFaviconPath) { + return; + } + + this.cachedFaviconPath = faviconPath; + + let existingFavicon = document.querySelector('link[rel="icon"]') as HTMLLinkElement; + + if (faviconPath) { + if (existingFavicon) { + existingFavicon.href = faviconPath; + } else { + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = faviconPath; + document.head.appendChild(link); + } + } else { + if (existingFavicon) { + existingFavicon.remove(); + } + } + } + handleBackendUpdate(update: VDomBackendUpdate) { if (update == null) { return; } + + // Check if serverId is changing and trigger remount if needed + if (this.serverId != null && this.serverId !== update.serverid) { + // Server ID changed - need to remount the entire app + if (this.remountCallback) { + this.remountCallback(); + } + return; + } + + this.serverId = update.serverid; getDefaultStore().set(this.contextActive, true); const idMap = new Map(); const vdomRoot = getDefaultStore().get(this.vdomRoot); @@ -543,6 +582,9 @@ export class TsunamiModel { if (update.opts.title && update.opts.title.trim() !== "") { document.title = update.opts.title; } + if (update.opts.faviconpath !== undefined) { + this.updateFavicon(update.opts.faviconpath); + } } makeVDomIdMap(vdomRoot, idMap); this.handleRenderUpdates(update, idMap); diff --git a/tsunami/frontend/src/types/vdom.d.ts b/tsunami/frontend/src/types/vdom.d.ts index b2fe97918b..bdef3b9498 100644 --- a/tsunami/frontend/src/types/vdom.d.ts +++ b/tsunami/frontend/src/types/vdom.d.ts @@ -12,14 +12,15 @@ type VDomAsyncInitiationRequest = { type VDomBackendOpts = { closeonctrlc?: boolean; globalkeyboardevents?: boolean; - globalstyles?: boolean; title?: string; + faviconpath?: string; }; // vdom.VDomBackendUpdate type VDomBackendUpdate = { type: "backendupdate"; ts: number; + serverid: string; opts?: VDomBackendOpts; haswork?: boolean; renderupdates?: VDomRenderUpdate[]; diff --git a/tsunami/frontend/src/vdom.tsx b/tsunami/frontend/src/vdom.tsx index 455f485070..ad1bf078f0 100644 --- a/tsunami/frontend/src/vdom.tsx +++ b/tsunami/frontend/src/vdom.tsx @@ -16,7 +16,6 @@ const FragmentTag = "#fragment"; const WaveTextTag = "wave:text"; const WaveNullTag = "wave:null"; const StyleTagName = "style"; -const WaveStyleTagName = "wave:style"; const VDomObjType_Ref = "ref"; const VDomObjType_Binding = "binding"; @@ -186,14 +185,15 @@ function resolveBinding(binding: VDomBinding, model: TsunamiModel): [any, string return [null, []]; } // validate that bindName starts with valid atom prefix and has at least one char after the dot - const isValidBinding = (bindName.startsWith("$shared.") && bindName.length > 8) || - (bindName.startsWith("$config.") && bindName.length > 8) || - (bindName.startsWith("$data.") && bindName.length > 6); - + const isValidBinding = + (bindName.startsWith("$shared.") && bindName.length > 8) || + (bindName.startsWith("$config.") && bindName.length > 8) || + (bindName.startsWith("$data.") && bindName.length > 6); + if (!isValidBinding) { return [null, []]; } - + const atom = model.getAtomContainer(bindName); if (atom == null) { return [null, []]; @@ -323,37 +323,6 @@ function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { return ; } -function WaveStyle({ src, model, onMount }: { src: string; model: TsunamiModel; onMount?: () => void }) { - const [styleContent, setStyleContent] = React.useState(null); - React.useEffect(() => { - async function fetchCss() { - try { - const response = await fetch(src); - if (!response.ok) { - console.error(`Failed to load CSS from ${src}`); - return; - } - const cssText = await response.text(); - setStyleContent(cssText); - } catch (error) { - console.error("Error fetching CSS:", error); - onMount?.(); - } - } - fetchCss(); - }, [src, model]); - // Trigger onMount after styleContent has been set and mounted - React.useEffect(() => { - if (styleContent) { - onMount?.(); - } - }, [styleContent, onMount]); - if (!styleContent) { - return null; - } - return ; -} - function VDomTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { const props = useVDom(model, elem); if (elem.tag == WaveNullTag) { @@ -369,9 +338,6 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) { if (elem.tag == StyleTagName) { return ; } - if (elem.tag == WaveStyleTagName) { - return ; - } if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) { return
    {"Invalid Tag <" + elem.tag + ">"}
    ; } @@ -426,15 +392,13 @@ type VDomViewProps = { }; function VDomInnerView({ model }: VDomViewProps) { - let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles); - const handleStylesMounted = () => { + let [styleMounted, setStyleMounted] = React.useState(false); + const handleStyleLoad = () => { setStyleMounted(true); }; return ( <> - {model.backendOpts?.globalstyles ? ( - - ) : null} + {styleMounted ? : null} ); diff --git a/tsunami/rpctypes/types.go b/tsunami/rpctypes/types.go index 0da48e4409..9ff99bf708 100644 --- a/tsunami/rpctypes/types.go +++ b/tsunami/rpctypes/types.go @@ -48,6 +48,7 @@ type VDomFrontendUpdate struct { type VDomBackendUpdate struct { Type string `json:"type" tstype:"\"backendupdate\""` Ts int64 `json:"ts"` + ServerId string `json:"serverid"` Opts *VDomBackendOpts `json:"opts,omitempty"` HasWork bool `json:"haswork,omitempty"` RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"` @@ -167,10 +168,10 @@ type VDomRefUpdate struct { } type VDomBackendOpts struct { + Title string `json:"title,omitempty"` CloseOnCtrlC bool `json:"closeonctrlc,omitempty"` GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"` - GlobalStyles bool `json:"globalstyles,omitempty"` - Title string `json:"title,omitempty"` + FaviconPath string `json:"faviconpath,omitempty"` } type VDomRenderUpdate struct { From 4c5c1afc6cd43bfdbe537eeac3e0dbf86813a878 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 22:14:19 -0700 Subject: [PATCH 036/134] tsunami_distpath --- tsunami/build/build.go | 36 ++++++++++++++++++++++++++++++++++-- tsunami/cmd/main-tsunami.go | 20 ++++++++++++++++---- tsunami/demo/todo/app.go | 4 ---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 8363e2de58..3043871f0d 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -12,8 +12,9 @@ import ( ) type BuildOpts struct { - Dir string - Verbose bool + Dir string + Verbose bool + DistPath string } func verifyEnvironment(verbose bool) error { @@ -130,6 +131,33 @@ func verifyTsunamiDir(dir string) error { return nil } +func verifyDistPath(distPath string) error { + if distPath == "" { + return fmt.Errorf("distPath cannot be empty") + } + + // Check if directory exists + info, err := os.Stat(distPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("distPath directory %q does not exist", distPath) + } + return fmt.Errorf("error accessing distPath directory %q: %w", distPath, err) + } + + if !info.IsDir() { + return fmt.Errorf("distPath %q is not a directory", distPath) + } + + // Check for index.html file + indexPath := filepath.Join(distPath, "index.html") + if err := CheckFileExists(indexPath); err != nil { + return fmt.Errorf("index.html check failed in distPath %q: %w", distPath, err) + } + + return nil +} + func TsunamiBuild(opts BuildOpts) error { if err := verifyEnvironment(opts.Verbose); err != nil { return err @@ -139,6 +167,10 @@ func TsunamiBuild(opts BuildOpts) error { return err } + if err := verifyDistPath(opts.DistPath); err != nil { + return err + } + // Create temporary directory tempDir, err := os.MkdirTemp("", "tsunami-build-*") if err != nil { diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index cea851335b..e13e524a6e 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -35,9 +35,15 @@ var buildCmd = &cobra.Command{ Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") + distPath := os.Getenv("TSUNAMI_DISTPATH") + if distPath == "" { + fmt.Printf("Error: TSUNAMI_DISTPATH environment variable must be set\n") + os.Exit(1) + } opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, + Dir: args[0], + Verbose: verbose, + DistPath: distPath, } if err := build.TsunamiBuild(opts); err != nil { fmt.Printf("Build failed: %v\n", err) @@ -53,9 +59,15 @@ var runCmd = &cobra.Command{ Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") + distPath := os.Getenv("TSUNAMI_DISTPATH") + if distPath == "" { + fmt.Printf("Error: TSUNAMI_DISTPATH environment variable must be set\n") + os.Exit(1) + } opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, + Dir: args[0], + Verbose: verbose, + DistPath: distPath, } if err := build.TsunamiRun(opts); err != nil { fmt.Printf("Run failed: %v\n", err) diff --git a/tsunami/demo/todo/app.go b/tsunami/demo/todo/app.go index e1f62c16b8..2962a018e7 100644 --- a/tsunami/demo/todo/app.go +++ b/tsunami/demo/todo/app.go @@ -183,7 +183,3 @@ var App = app.DefineComponent("App", ) }, ) - -func main() { - app.RunMain() -} From 7ae7cf8f2b79442279f042a67c6c242b2d8da03d Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 22:27:37 -0700 Subject: [PATCH 037/134] working on the build --- tsunami/build/build.go | 52 ++++++++ tsunami/demo/todo/tw.css | 267 --------------------------------------- 2 files changed, 52 insertions(+), 267 deletions(-) delete mode 100644 tsunami/demo/todo/tw.css diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 3043871f0d..b5dab84b75 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -155,6 +155,18 @@ func verifyDistPath(distPath string) error { return fmt.Errorf("index.html check failed in distPath %q: %w", distPath, err) } + // Check for templates/tailwind.css file + tailwindPath := filepath.Join(distPath, "templates", "tailwind.css") + if err := CheckFileExists(tailwindPath); err != nil { + return fmt.Errorf("templates/tailwind.css check failed in distPath %q: %w", distPath, err) + } + + // Check for templates/main.go.tmpl file + mainTmplPath := filepath.Join(distPath, "templates", "main.go.tmpl") + if err := CheckFileExists(mainTmplPath); err != nil { + return fmt.Errorf("templates/main.go.tmpl check failed in distPath %q: %w", distPath, err) + } + return nil } @@ -204,6 +216,46 @@ func TsunamiBuild(opts BuildOpts) error { if opts.Verbose { log.Printf("Copied %d go files, %d static files\n", goCount, staticCount) } + + // Copy main.go.tmpl from dist/templates to temp dir as main-app.go + mainTmplSrc := filepath.Join(opts.DistPath, "templates", "main.go.tmpl") + mainTmplDest := filepath.Join(tempDir, "main-app.go") + if err := copyFile(mainTmplSrc, mainTmplDest); err != nil { + return fmt.Errorf("failed to copy main.go.tmpl: %w", err) + } + + // Generate Tailwind CSS + if err := generateAppTailwindCss(opts.DistPath, tempDir, opts.Verbose); err != nil { + return fmt.Errorf("failed to generate tailwind css: %w", err) + } + + return nil +} + +func generateAppTailwindCss(distPath, tempDir string, verbose bool) error { + tailwindInput := filepath.Join(distPath, "templates", "tailwind.css") + tailwindOutput := filepath.Join(tempDir, "static", "tw.css") + contentGlob := filepath.Join(tempDir, "*.go") + + tailwindCmd := exec.Command("npx", "@tailwindcss/cli", + "-i", tailwindInput, + "-o", tailwindOutput, + "--content", contentGlob) + + if verbose { + log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) + tailwindCmd.Stdout = os.Stdout + tailwindCmd.Stderr = os.Stderr + } + + if err := tailwindCmd.Run(); err != nil { + return fmt.Errorf("failed to run tailwind command: %w", err) + } + + if verbose { + log.Printf("Tailwind CSS generated successfully") + } + return nil } diff --git a/tsunami/demo/todo/tw.css b/tsunami/demo/todo/tw.css deleted file mode 100644 index 584081e972..0000000000 --- a/tsunami/demo/todo/tw.css +++ /dev/null @@ -1,267 +0,0 @@ -/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ -@layer properties; -@layer theme, base, components, utilities; -@layer theme { - :root, :host { - --font-sans: "Inter", sans-serif; - --font-mono: "Hack", monospace; - --color-red-100: oklch(93.6% 0.032 17.717); - --color-red-500: oklch(63.7% 0.237 25.331); - --spacing: 0.25rem; - --text-2xl: 1.5rem; - --text-2xl--line-height: calc(2 / 1.5); - --font-weight-bold: 700; - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); - --radius: 8px; - --color-border: rgba(255, 255, 255, 0.16); - } -} -@layer base { - *, ::after, ::before, ::backdrop, ::file-selector-button { - box-sizing: border-box; - margin: 0; - padding: 0; - border: 0 solid; - } - html, :host { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - tab-size: 4; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); - font-feature-settings: var(--default-font-feature-settings, normal); - font-variation-settings: var(--default-font-variation-settings, normal); - -webkit-tap-highlight-color: transparent; - } - hr { - height: 0; - color: inherit; - border-top-width: 1px; - } - abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - } - h1, h2, h3, h4, h5, h6 { - font-size: inherit; - font-weight: inherit; - } - a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; - } - b, strong { - font-weight: bolder; - } - code, kbd, samp, pre { - font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); - font-feature-settings: var(--default-mono-font-feature-settings, normal); - font-variation-settings: var(--default-mono-font-variation-settings, normal); - font-size: 1em; - } - small { - font-size: 80%; - } - sub, sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; - } - sub { - bottom: -0.25em; - } - sup { - top: -0.5em; - } - table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; - } - :-moz-focusring { - outline: auto; - } - progress { - vertical-align: baseline; - } - summary { - display: list-item; - } - ol, ul, menu { - list-style: none; - } - img, svg, video, canvas, audio, iframe, embed, object { - display: block; - vertical-align: middle; - } - img, video { - max-width: 100%; - height: auto; - } - button, input, select, optgroup, textarea, ::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - border-radius: 0; - background-color: transparent; - opacity: 1; - } - :where(select:is([multiple], [size])) optgroup { - font-weight: bolder; - } - :where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; - } - ::file-selector-button { - margin-inline-end: 4px; - } - ::placeholder { - opacity: 1; - } - @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: currentcolor; - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, currentcolor 50%, transparent); - } - } - } - textarea { - resize: vertical; - } - ::-webkit-search-decoration { - -webkit-appearance: none; - } - ::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; - } - ::-webkit-datetime-edit { - display: inline-flex; - } - ::-webkit-datetime-edit-fields-wrapper { - padding: 0; - } - ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { - padding-block: 0; - } - ::-webkit-calendar-picker-indicator { - line-height: 1; - } - :-moz-ui-invalid { - box-shadow: none; - } - button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { - appearance: button; - } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { - height: auto; - } - [hidden]:where(:not([hidden="until-found"])) { - display: none !important; - } -} -@layer utilities { - .m-5 { - margin: calc(var(--spacing) * 5); - } - .mb-5 { - margin-bottom: calc(var(--spacing) * 5); - } - .flex { - display: flex; - } - .h-4 { - height: calc(var(--spacing) * 4); - } - .w-4 { - width: calc(var(--spacing) * 4); - } - .max-w-\[500px\] { - max-width: 500px; - } - .flex-1 { - flex: 1; - } - .cursor-pointer { - cursor: pointer; - } - .flex-col { - flex-direction: column; - } - .items-center { - align-items: center; - } - .gap-2 { - gap: calc(var(--spacing) * 2); - } - .gap-2\.5 { - gap: calc(var(--spacing) * 2.5); - } - .rounded { - border-radius: var(--radius); - } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } - .border-border { - border-color: var(--color-border); - } - .p-2 { - padding: calc(var(--spacing) * 2); - } - .px-2 { - padding-inline: calc(var(--spacing) * 2); - } - .px-4 { - padding-inline: calc(var(--spacing) * 4); - } - .py-1 { - padding-block: calc(var(--spacing) * 1); - } - .py-2 { - padding-block: calc(var(--spacing) * 2); - } - .font-sans { - font-family: var(--font-sans); - } - .text-2xl { - font-size: var(--text-2xl); - line-height: var(--tw-leading, var(--text-2xl--line-height)); - } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); - } - .text-red-500 { - color: var(--color-red-500); - } - .line-through { - text-decoration-line: line-through; - } - .opacity-70 { - opacity: 70%; - } -} -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-font-weight { - syntax: "*"; - inherits: false; -} -@layer properties { - @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { - *, ::before, ::after, ::backdrop { - --tw-border-style: solid; - --tw-font-weight: initial; - } - } -} From 35a98fe8e06d52ae3a7248bf241a7e77b9981472 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 2 Sep 2025 23:14:43 -0700 Subject: [PATCH 038/134] getting closer with the build... --- tsunami/build/build.go | 171 +++++++++++++++++++++++++++++++----- tsunami/build/buildutil.go | 16 ++++ tsunami/cmd/main-tsunami.go | 72 ++++++++------- tsunami/go.mod | 1 + tsunami/go.sum | 2 + 5 files changed, 210 insertions(+), 52 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index b5dab84b75..3a1ce1622f 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -9,26 +9,33 @@ import ( "regexp" "strconv" "strings" + + "golang.org/x/mod/modfile" ) type BuildOpts struct { - Dir string - Verbose bool - DistPath string + Dir string + Verbose bool + DistPath string + SdkReplacePath string +} + +type BuildEnv struct { + GoVersion string } -func verifyEnvironment(verbose bool) error { +func verifyEnvironment(verbose bool) (*BuildEnv, error) { // Check if go is in PATH goPath, err := exec.LookPath("go") if err != nil { - return fmt.Errorf("go command not found in PATH: %w", err) + return nil, fmt.Errorf("go command not found in PATH: %w", err) } // Run go version command cmd := exec.Command(goPath, "version") output, err := cmd.Output() if err != nil { - return fmt.Errorf("failed to run 'go version': %w", err) + return nil, fmt.Errorf("failed to run 'go version': %w", err) } // Parse go version output and check for 1.21+ @@ -38,21 +45,30 @@ func verifyEnvironment(verbose bool) error { } // Extract version like "go1.21.0" from output - versionRegex := regexp.MustCompile(`go1\.(\d+)`) + versionRegex := regexp.MustCompile(`go(1\.\d+)`) matches := versionRegex.FindStringSubmatch(versionStr) if len(matches) < 2 { - return fmt.Errorf("unable to parse go version from: %s", versionStr) + return nil, fmt.Errorf("unable to parse go version from: %s", versionStr) + } + + goVersion := matches[1] + + // Check if version is 1.21+ + minorRegex := regexp.MustCompile(`1\.(\d+)`) + minorMatches := minorRegex.FindStringSubmatch(goVersion) + if len(minorMatches) < 2 { + return nil, fmt.Errorf("unable to parse minor version from: %s", goVersion) } - minor, err := strconv.Atoi(matches[1]) + minor, err := strconv.Atoi(minorMatches[1]) if err != nil || minor < 21 { - return fmt.Errorf("go version 1.21 or higher required, found: %s", versionStr) + return nil, fmt.Errorf("go version 1.21 or higher required, found: %s", versionStr) } // Check if npx is in PATH _, err = exec.LookPath("npx") if err != nil { - return fmt.Errorf("npx command not found in PATH: %w", err) + return nil, fmt.Errorf("npx command not found in PATH: %w", err) } if verbose { @@ -63,13 +79,13 @@ func verifyEnvironment(verbose bool) error { tailwindCmd := exec.Command("npx", "@tailwindcss/cli") tailwindOutput, err := tailwindCmd.CombinedOutput() if err != nil { - return fmt.Errorf("failed to run 'npx @tailwindcss/cli': %w", err) + return nil, fmt.Errorf("failed to run 'npx @tailwindcss/cli': %w", err) } tailwindStr := strings.TrimSpace(string(tailwindOutput)) lines := strings.Split(tailwindStr, "\n") if len(lines) == 0 { - return fmt.Errorf("no output from tailwindcss command") + return nil, fmt.Errorf("no output from tailwindcss command") } firstLine := lines[0] @@ -79,14 +95,76 @@ func verifyEnvironment(verbose bool) error { // Check for v4 (format: "≈ tailwindcss v4.1.12") tailwindRegex := regexp.MustCompile(`tailwindcss v(\d+)`) - matches = tailwindRegex.FindStringSubmatch(firstLine) - if len(matches) < 2 { - return fmt.Errorf("unable to parse tailwindcss version from: %s", firstLine) + tailwindMatches := tailwindRegex.FindStringSubmatch(firstLine) + if len(tailwindMatches) < 2 { + return nil, fmt.Errorf("unable to parse tailwindcss version from: %s", firstLine) } - majorVersion, err := strconv.Atoi(matches[1]) + majorVersion, err := strconv.Atoi(tailwindMatches[1]) if err != nil || majorVersion != 4 { - return fmt.Errorf("tailwindcss v4 required, found: %s", firstLine) + return nil, fmt.Errorf("tailwindcss v4 required, found: %s", firstLine) + } + + return &BuildEnv{GoVersion: goVersion}, nil +} + +func createGoMod(tempDir, appDirName, goVersion string, opts BuildOpts, verbose bool) error { + modulePath := fmt.Sprintf("tsunami/app/%s", appDirName) + + // Create new modfile + modFile := &modfile.File{} + if err := modFile.AddModuleStmt(modulePath); err != nil { + return fmt.Errorf("failed to add module statement: %w", err) + } + + if err := modFile.AddGoStmt(goVersion); err != nil { + return fmt.Errorf("failed to add go version: %w", err) + } + + // Add requirement for tsunami SDK + if err := modFile.AddRequire("github.com/wavetermdev/waveterm/tsunami", "v0.0.0"); err != nil { + return fmt.Errorf("failed to add require directive: %w", err) + } + + // Add replace directive for tsunami SDK + if err := modFile.AddReplace("github.com/wavetermdev/waveterm/tsunami", "", opts.SdkReplacePath, ""); err != nil { + return fmt.Errorf("failed to add replace directive: %w", err) + } + + // Format and write the file + modFile.Cleanup() + goModContent, err := modFile.Format() + if err != nil { + return fmt.Errorf("failed to format go.mod: %w", err) + } + + goModPath := filepath.Join(tempDir, "go.mod") + if err := os.WriteFile(goModPath, goModContent, 0644); err != nil { + return fmt.Errorf("failed to write go.mod file: %w", err) + } + + if verbose { + log.Printf("Created go.mod with module path: %s", modulePath) + log.Printf("Added require: github.com/wavetermdev/waveterm/tsunami v0.0.0") + log.Printf("Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath) + } + + // Run go mod tidy to clean up dependencies + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = tempDir + + if verbose { + log.Printf("Running go mod tidy in %s", tempDir) + } + + output, err := tidyCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to run go mod tidy: %w\nOutput: %s", err, string(output)) + } + + if verbose { + log.Printf("go mod tidy output:\n%s", string(output)) + log.Printf("Successfully ran go mod tidy") } return nil @@ -171,7 +249,8 @@ func verifyDistPath(distPath string) error { } func TsunamiBuild(opts BuildOpts) error { - if err := verifyEnvironment(opts.Verbose); err != nil { + buildEnv, err := verifyEnvironment(opts.Verbose) + if err != nil { return err } @@ -224,11 +303,59 @@ func TsunamiBuild(opts BuildOpts) error { return fmt.Errorf("failed to copy main.go.tmpl: %w", err) } + // Create go.mod file + appDirName := filepath.Base(opts.Dir) + if err := createGoMod(tempDir, appDirName, buildEnv.GoVersion, opts, opts.Verbose); err != nil { + return fmt.Errorf("failed to create go.mod: %w", err) + } + // Generate Tailwind CSS if err := generateAppTailwindCss(opts.DistPath, tempDir, opts.Verbose); err != nil { return fmt.Errorf("failed to generate tailwind css: %w", err) } + // Build the Go application + if err := runGoBuild(tempDir, opts.Verbose); err != nil { + return fmt.Errorf("failed to build application: %w", err) + } + + return nil +} + +func runGoBuild(tempDir string, verbose bool) error { + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("failed to create bin directory: %w", err) + } + + goFiles, err := listGoFilesInDir(tempDir) + if err != nil { + return fmt.Errorf("failed to list go files: %w", err) + } + + if len(goFiles) == 0 { + return fmt.Errorf("no .go files found in %s", tempDir) + } + + // Build command with explicit go files + args := append([]string{"build", "-o", "bin/app"}, goFiles...) + buildCmd := exec.Command("go", args...) + buildCmd.Dir = tempDir + + if verbose { + log.Printf("Running: %s in %s", strings.Join(buildCmd.Args, " "), tempDir) + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + } + + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("failed to build application: %w", err) + } + + if verbose { + log.Printf("Application built successfully at %s", filepath.Join(binDir, "app")) + } + return nil } @@ -236,18 +363,18 @@ func generateAppTailwindCss(distPath, tempDir string, verbose bool) error { tailwindInput := filepath.Join(distPath, "templates", "tailwind.css") tailwindOutput := filepath.Join(tempDir, "static", "tw.css") contentGlob := filepath.Join(tempDir, "*.go") - + tailwindCmd := exec.Command("npx", "@tailwindcss/cli", "-i", tailwindInput, "-o", tailwindOutput, "--content", contentGlob) - + if verbose { log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) tailwindCmd.Stdout = os.Stdout tailwindCmd.Stderr = os.Stderr } - + if err := tailwindCmd.Run(); err != nil { return fmt.Errorf("failed to run tailwind command: %w", err) } diff --git a/tsunami/build/buildutil.go b/tsunami/build/buildutil.go index 8e316b9ce5..9ef8dce67b 100644 --- a/tsunami/build/buildutil.go +++ b/tsunami/build/buildutil.go @@ -114,3 +114,19 @@ func copyFile(srcPath, destPath string) error { // Set the same mode as source file return os.Chmod(destPath, srcInfo.Mode()) } + +func listGoFilesInDir(dirPath string) ([]string, error) { + entries, err := os.ReadDir(dirPath) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dirPath, err) + } + + var goFiles []string + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".go" { + goFiles = append(goFiles, entry.Name()) + } + } + + return goFiles, nil +} diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index e13e524a6e..a8ca35f64b 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -28,51 +28,63 @@ var versionCmd = &cobra.Command{ }, } +func validateEnvironmentVars(opts *build.BuildOpts) error { + distPath := os.Getenv("TSUNAMI_DISTPATH") + if distPath == "" { + return fmt.Errorf("TSUNAMI_DISTPATH environment variable must be set") + } + + sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") + if sdkReplacePath == "" { + return fmt.Errorf("TSUNAMI_SDKREPLACEPATH environment variable must be set") + } + + opts.DistPath = distPath + opts.SdkReplacePath = sdkReplacePath + return nil +} + var buildCmd = &cobra.Command{ - Use: "build [directory]", - Short: "Build a Tsunami application", - Long: `Build a Tsunami application from the specified directory.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + Use: "build [directory]", + Short: "Build a Tsunami application", + Long: `Build a Tsunami application from the specified directory.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") - distPath := os.Getenv("TSUNAMI_DISTPATH") - if distPath == "" { - fmt.Printf("Error: TSUNAMI_DISTPATH environment variable must be set\n") - os.Exit(1) - } opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, - DistPath: distPath, + Dir: args[0], + Verbose: verbose, + } + if err := validateEnvironmentVars(&opts); err != nil { + return err } if err := build.TsunamiBuild(opts); err != nil { - fmt.Printf("Build failed: %v\n", err) - os.Exit(1) + return fmt.Errorf("build failed: %w", err) } + return nil }, } var runCmd = &cobra.Command{ - Use: "run [directory]", - Short: "Build and run a Tsunami application", - Long: `Build and run a Tsunami application from the specified directory.`, - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { + Use: "run [directory]", + Short: "Build and run a Tsunami application", + Long: `Build and run a Tsunami application from the specified directory.`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") - distPath := os.Getenv("TSUNAMI_DISTPATH") - if distPath == "" { - fmt.Printf("Error: TSUNAMI_DISTPATH environment variable must be set\n") - os.Exit(1) - } opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, - DistPath: distPath, + Dir: args[0], + Verbose: verbose, + } + if err := validateEnvironmentVars(&opts); err != nil { + return err } if err := build.TsunamiRun(opts); err != nil { - fmt.Printf("Run failed: %v\n", err) - os.Exit(1) + return fmt.Errorf("run failed: %w", err) } + return nil }, } diff --git a/tsunami/go.mod b/tsunami/go.mod index c197f7b4f1..a4de9dea20 100644 --- a/tsunami/go.mod +++ b/tsunami/go.mod @@ -7,6 +7,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/spf13/cobra v1.10.1 github.com/wavetermdev/htmltoken v0.2.0 + golang.org/x/mod v0.27.0 ) require ( diff --git a/tsunami/go.sum b/tsunami/go.sum index 63173da653..4f3b18dd07 100644 --- a/tsunami/go.sum +++ b/tsunami/go.sum @@ -12,6 +12,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From bea4592dc8151ba1ac7aed583fda9e714ca8895b Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 09:15:28 -0700 Subject: [PATCH 039/134] progress on build, still fighting tailwind --- tsunami/app/defaultclient.go | 14 ++--- tsunami/app/serverhandlers.go | 98 ++++++++++++++++++++++++++++------- tsunami/app/tsunamiapp.go | 2 +- tsunami/build/build.go | 87 +++++++++++++++++++++---------- tsunami/cmd/main-tsunami.go | 2 +- tsunami/frontend/.gitignore | 1 + 6 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 tsunami/frontend/.gitignore diff --git a/tsunami/app/defaultclient.go b/tsunami/app/defaultclient.go index c9b63b38c8..6cafc4b16c 100644 --- a/tsunami/app/defaultclient.go +++ b/tsunami/app/defaultclient.go @@ -5,15 +5,15 @@ package app import ( "context" - "embed" + "io/fs" "net/http" "github.com/wavetermdev/waveterm/tsunami/vdom" ) var defaultClient = MakeClient(AppOpts{}) -var assetsFS *embed.FS -var staticFS *embed.FS +var assetsFS fs.FS +var staticFS fs.FS var manifestFile *FileHandlerOption // Default client methods that operate on the global defaultClient @@ -66,12 +66,12 @@ func RegisterFileHandler(path string, option FileHandlerOption) { defaultClient.RegisterFileHandler(path, option) } -func RegisterAssetsFS(fs embed.FS) { - assetsFS = &fs +func RegisterAssetsFS(filesystem fs.FS) { + assetsFS = filesystem } -func RegisterStaticFS(fs embed.FS) { - staticFS = &fs +func RegisterStaticFS(filesystem fs.FS) { + staticFS = filesystem } func RegisterManifestFile(option FileHandlerOption) { diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index efc52c6688..3ec489a9ba 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -4,10 +4,10 @@ package app import ( - "embed" "encoding/json" "fmt" "io" + "io/fs" "log" "mime" "net/http" @@ -27,8 +27,8 @@ func init() { } type HandlerOpts struct { - AssetsFS *embed.FS - StaticFS *embed.FS + AssetsFS fs.FS + StaticFS fs.FS ManifestFile *FileHandlerOption } @@ -316,8 +316,39 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { } } -func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc { - // Create a file server from the embedded FS +// serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops +// when serving directory paths that end with "/" +func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool { + if !strings.HasSuffix(requestPath, "/") { + return false + } + + // Try to serve the specified file from that directory + var filePath string + if requestPath == "/" { + filePath = fileName + } else { + filePath = strings.TrimPrefix(requestPath, "/") + fileName + } + + file, err := embeddedFS.Open(filePath) + if err != nil { + return false + } + defer file.Close() + + // Get file info for modification time + fileInfo, err := file.Stat() + if err != nil { + return false + } + + // Serve the file directly with proper mod time + http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker)) + return true +} + +func (h *HTTPHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc { fileServer := http.FileServer(http.FS(embeddedFS)) return func(w http.ResponseWriter, r *http.Request) { @@ -328,18 +359,26 @@ func (h *HTTPHandlers) handleStaticFiles(embeddedFS *embed.FS) http.HandlerFunc } }() - // Skip if this is an API or files request (already handled by other handlers) - if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") { + // Skip if this is an API, files, or static request (already handled by other handlers) + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") || strings.HasPrefix(r.URL.Path, "/static/") { http.NotFound(w, r) return } - // Handle root "/" => "/index.html" - if r.URL.Path == "/" { - r.URL.Path = "/index.html" + // Handle any path ending with "/" to avoid redirect loops + if serveFileDirectly(w, r, embeddedFS, r.URL.Path, "index.html") { + return + } + + // For other files, check if they exist before serving + filePath := strings.TrimPrefix(r.URL.Path, "/") + _, err := embeddedFS.Open(filePath) + if err != nil { + http.NotFound(w, r) + return } - // Serve the file using Go's file server + // Serve the file using the file server fileServer.ServeHTTP(w, r) } } @@ -367,10 +406,7 @@ func (h *HTTPHandlers) handleManifest(manifestFile *FileHandlerOption) http.Hand } } -func (h *HTTPHandlers) handleStaticPathFiles(staticFS *embed.FS) http.HandlerFunc { - // Create a file server from the embedded FS - fileServer := http.FileServer(http.FS(staticFS)) - +func (h *HTTPHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { panicErr := util.PanicHandler("handleStaticPathFiles", recover()) @@ -380,12 +416,36 @@ func (h *HTTPHandlers) handleStaticPathFiles(staticFS *embed.FS) http.HandlerFun }() // Strip /static/ prefix from the path - r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static") - if r.URL.Path == "" { - r.URL.Path = "/" + filePath := strings.TrimPrefix(r.URL.Path, "/static/") + if filePath == "" { + // Handle requests to "/static/" directly + if serveFileDirectly(w, r, staticFS, "/", "index.html") { + return + } + http.NotFound(w, r) + return + } + + // Handle directory paths ending with "/" to avoid redirect loops + strippedPath := "/" + filePath + if serveFileDirectly(w, r, staticFS, strippedPath, "index.html") { + return + } + + // Check if file exists in staticFS + _, err := staticFS.Open(filePath) + if err != nil { + http.NotFound(w, r) + return } - // Serve the file using Go's file server + // Create a file server and serve the file + fileServer := http.FileServer(http.FS(staticFS)) + + // Temporarily modify the URL path for the file server + originalPath := r.URL.Path + r.URL.Path = "/" + filePath fileServer.ServeHTTP(w, r) + r.URL.Path = originalPath } } diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index efba12cc5d..bdeb2ad182 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -195,7 +195,7 @@ func (c *Client) listenAndServe(ctx context.Context) error { // Log the port we're listening on port := listener.Addr().(*net.TCPAddr).Port - log.Printf("Wave app server listening on port %d", port) + log.Printf("[tsunami] listening on port %d", port) // Serve in a goroutine so we don't block go func() { diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 3a1ce1622f..8e7027fdab 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -22,6 +22,7 @@ type BuildOpts struct { type BuildEnv struct { GoVersion string + TempDir string } func verifyEnvironment(verbose bool) (*BuildEnv, error) { @@ -154,16 +155,16 @@ func createGoMod(tempDir, appDirName, goVersion string, opts BuildOpts, verbose tidyCmd.Dir = tempDir if verbose { - log.Printf("Running go mod tidy in %s", tempDir) + log.Printf("Running go mod tidy") + tidyCmd.Stdout = os.Stdout + tidyCmd.Stderr = os.Stderr } - output, err := tidyCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to run go mod tidy: %w\nOutput: %s", err, string(output)) + if err := tidyCmd.Run(); err != nil { + return fmt.Errorf("failed to run go mod tidy: %w", err) } if verbose { - log.Printf("go mod tidy output:\n%s", string(output)) log.Printf("Successfully ran go mod tidy") } @@ -248,26 +249,28 @@ func verifyDistPath(distPath string) error { return nil } -func TsunamiBuild(opts BuildOpts) error { +func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { buildEnv, err := verifyEnvironment(opts.Verbose) if err != nil { - return err + return nil, err } if err := verifyTsunamiDir(opts.Dir); err != nil { - return err + return nil, err } if err := verifyDistPath(opts.DistPath); err != nil { - return err + return nil, err } // Create temporary directory tempDir, err := os.MkdirTemp("", "tsunami-build-*") if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) + return nil, fmt.Errorf("failed to create temp directory: %w", err) } + buildEnv.TempDir = tempDir + log.Printf("Building tsunami app from %s\n", opts.Dir) if opts.Verbose { @@ -277,49 +280,55 @@ func TsunamiBuild(opts BuildOpts) error { // Copy all *.go files from the root directory goCount, err := copyGoFiles(opts.Dir, tempDir) if err != nil { - return fmt.Errorf("failed to copy go files: %w", err) + return nil, fmt.Errorf("failed to copy go files: %w", err) } // Copy static directory staticCount, err := copyStaticDir(opts.Dir, tempDir) if err != nil { - return fmt.Errorf("failed to copy static directory: %w", err) + return nil, fmt.Errorf("failed to copy static directory: %w", err) } // Create dist directory distDir := filepath.Join(tempDir, "dist") if err := os.MkdirAll(distDir, 0755); err != nil { - return fmt.Errorf("failed to create dist directory: %w", err) + return nil, fmt.Errorf("failed to create dist directory: %w", err) + } + + // Copy dist directory contents + distCount, err := copyDirRecursive(opts.DistPath, distDir) + if err != nil { + return nil, fmt.Errorf("failed to copy dist directory: %w", err) } if opts.Verbose { - log.Printf("Copied %d go files, %d static files\n", goCount, staticCount) + log.Printf("Copied %d go files, %d static files, %d dist files\n", goCount, staticCount, distCount) } // Copy main.go.tmpl from dist/templates to temp dir as main-app.go mainTmplSrc := filepath.Join(opts.DistPath, "templates", "main.go.tmpl") mainTmplDest := filepath.Join(tempDir, "main-app.go") if err := copyFile(mainTmplSrc, mainTmplDest); err != nil { - return fmt.Errorf("failed to copy main.go.tmpl: %w", err) + return nil, fmt.Errorf("failed to copy main.go.tmpl: %w", err) } // Create go.mod file appDirName := filepath.Base(opts.Dir) if err := createGoMod(tempDir, appDirName, buildEnv.GoVersion, opts, opts.Verbose); err != nil { - return fmt.Errorf("failed to create go.mod: %w", err) + return nil, fmt.Errorf("failed to create go.mod: %w", err) } // Generate Tailwind CSS if err := generateAppTailwindCss(opts.DistPath, tempDir, opts.Verbose); err != nil { - return fmt.Errorf("failed to generate tailwind css: %w", err) + return nil, fmt.Errorf("failed to generate tailwind css: %w", err) } // Build the Go application if err := runGoBuild(tempDir, opts.Verbose); err != nil { - return fmt.Errorf("failed to build application: %w", err) + return nil, fmt.Errorf("failed to build application: %w", err) } - return nil + return buildEnv, nil } func runGoBuild(tempDir string, verbose bool) error { @@ -343,7 +352,7 @@ func runGoBuild(tempDir string, verbose bool) error { buildCmd.Dir = tempDir if verbose { - log.Printf("Running: %s in %s", strings.Join(buildCmd.Args, " "), tempDir) + log.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) buildCmd.Stdout = os.Stdout buildCmd.Stderr = os.Stderr } @@ -360,14 +369,19 @@ func runGoBuild(tempDir string, verbose bool) error { } func generateAppTailwindCss(distPath, tempDir string, verbose bool) error { - tailwindInput := filepath.Join(distPath, "templates", "tailwind.css") + // Copy tailwind.css from dist/templates to temp dir + tailwindSrc := filepath.Join(distPath, "templates", "tailwind.css") + tailwindDest := filepath.Join(tempDir, "tailwind.css") + if err := copyFile(tailwindSrc, tailwindDest); err != nil { + return fmt.Errorf("failed to copy tailwind.css: %w", err) + } + tailwindOutput := filepath.Join(tempDir, "static", "tw.css") - contentGlob := filepath.Join(tempDir, "*.go") tailwindCmd := exec.Command("npx", "@tailwindcss/cli", - "-i", tailwindInput, - "-o", tailwindOutput, - "--content", contentGlob) + "-i", "./tailwind.css", + "-o", tailwindOutput) + tailwindCmd.Dir = tempDir if verbose { log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) @@ -429,9 +443,28 @@ func copyGoFiles(srcDir, destDir string) (int, error) { } func TsunamiRun(opts BuildOpts) error { - if err := TsunamiBuild(opts); err != nil { + buildEnv, err := TsunamiBuild(opts) + if err != nil { return err } - return fmt.Errorf("TsunamiRun not implemented yet") + // Run the built application + appPath := filepath.Join(buildEnv.TempDir, "bin", "app") + runCmd := exec.Command(appPath) + runCmd.Dir = buildEnv.TempDir + + log.Printf("Running tsunami app from %s", opts.Dir) + + runCmd.Stdin = os.Stdin + if opts.Verbose { + log.Printf("Executing: %s", appPath) + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + } + + if err := runCmd.Run(); err != nil { + return fmt.Errorf("failed to run application: %w", err) + } + + return nil } diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index a8ca35f64b..ea5029cc04 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -59,7 +59,7 @@ var buildCmd = &cobra.Command{ if err := validateEnvironmentVars(&opts); err != nil { return err } - if err := build.TsunamiBuild(opts); err != nil { + if _, err := build.TsunamiBuild(opts); err != nil { return fmt.Errorf("build failed: %w", err) } return nil diff --git a/tsunami/frontend/.gitignore b/tsunami/frontend/.gitignore new file mode 100644 index 0000000000..f1f6cef32c --- /dev/null +++ b/tsunami/frontend/.gitignore @@ -0,0 +1 @@ +scaffold/ \ No newline at end of file From b5ac1541ab1146e38ee87e75d274220f084e12fe Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 10:53:36 -0700 Subject: [PATCH 040/134] build/run working! --- Taskfile.yml | 25 ++++ tsunami/.gitignore | 1 + tsunami/app/tsunamiapp.go | 4 +- tsunami/build/build.go | 190 +++++++++++++++++++---------- tsunami/build/buildutil.go | 100 ++++++++++++++- tsunami/cmd/main-tsunami.go | 11 +- tsunami/frontend/index.html | 1 + tsunami/templates/app-main.go.tmpl | 22 ++++ tsunami/templates/gitignore.tmpl | 3 + tsunami/templates/tailwind.css | 60 +++++++++ tsunami/util/util.go | 25 ++++ 11 files changed, 367 insertions(+), 75 deletions(-) create mode 100644 tsunami/.gitignore create mode 100644 tsunami/templates/app-main.go.tmpl create mode 100644 tsunami/templates/gitignore.tmpl create mode 100644 tsunami/templates/tailwind.css diff --git a/Taskfile.yml b/Taskfile.yml index f3d3a1815b..4b3c8d7833 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -451,6 +451,31 @@ tasks: cmd: npm run dev dir: tsunami/frontend + tsunami:frontend:build: + desc: Build the tsunami frontend + cmd: yarn build + dir: tsunami/frontend + + tsunami:scaffold: + desc: Build scaffold for tsunami frontend development + dir: tsunami/frontend + deps: + - tsunami:frontend:build + cmds: + - cmd: "{{.RMRF}} scaffold" + ignore_error: true + - mkdir scaffold + - cd scaffold && npm --no-workspaces init -y --init-license Apache-2.0 + - cd scaffold && npm pkg set name=tsunami-scaffold + - cd scaffold && npm pkg delete author + - cd scaffold && npm pkg set author.name="Command Line Inc" + - cd scaffold && npm pkg set author.email="info@commandline.dev" + - cd scaffold && npm --no-workspaces install tailwindcss @tailwindcss/cli + - cp -r dist scaffold/ + - cp ../templates/app-main.go.tmpl scaffold/app-main.go + - cp ../templates/tailwind.css scaffold/ + - cp ../templates/gitignore.tmpl scaffold/.gitignore + tsunami:build: desc: Build the tsunami binary. cmds: diff --git a/tsunami/.gitignore b/tsunami/.gitignore new file mode 100644 index 0000000000..6dd29b7f8d --- /dev/null +++ b/tsunami/.gitignore @@ -0,0 +1 @@ +bin/ \ No newline at end of file diff --git a/tsunami/app/tsunamiapp.go b/tsunami/app/tsunamiapp.go index bdeb2ad182..486dce6381 100644 --- a/tsunami/app/tsunamiapp.go +++ b/tsunami/app/tsunamiapp.go @@ -193,9 +193,9 @@ func (c *Client) listenAndServe(ctx context.Context) error { return fmt.Errorf("failed to listen: %v", err) } - // Log the port we're listening on + // Log the address we're listening on port := listener.Addr().(*net.TCPAddr).Port - log.Printf("[tsunami] listening on port %d", port) + log.Printf("[tsunami] listening at http://localhost:%d", port) // Serve in a goroutine so we don't block go func() { diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 8e7027fdab..85e4cea068 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -1,7 +1,9 @@ package build import ( + "bufio" "fmt" + "io" "log" "os" "os/exec" @@ -9,14 +11,17 @@ import ( "regexp" "strconv" "strings" + "time" + "github.com/wavetermdev/waveterm/tsunami/util" "golang.org/x/mod/modfile" ) type BuildOpts struct { Dir string Verbose bool - DistPath string + Open bool + ScaffoldPath string SdkReplacePath string } @@ -210,40 +215,60 @@ func verifyTsunamiDir(dir string) error { return nil } -func verifyDistPath(distPath string) error { - if distPath == "" { - return fmt.Errorf("distPath cannot be empty") +func verifyScaffoldPath(scaffoldPath string) error { + if scaffoldPath == "" { + return fmt.Errorf("scaffoldPath cannot be empty") } // Check if directory exists - info, err := os.Stat(distPath) + info, err := os.Stat(scaffoldPath) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("distPath directory %q does not exist", distPath) + return fmt.Errorf("scaffoldPath directory %q does not exist", scaffoldPath) } - return fmt.Errorf("error accessing distPath directory %q: %w", distPath, err) + return fmt.Errorf("error accessing scaffoldPath directory %q: %w", scaffoldPath, err) } if !info.IsDir() { - return fmt.Errorf("distPath %q is not a directory", distPath) + return fmt.Errorf("scaffoldPath %q is not a directory", scaffoldPath) } - // Check for index.html file - indexPath := filepath.Join(distPath, "index.html") - if err := CheckFileExists(indexPath); err != nil { - return fmt.Errorf("index.html check failed in distPath %q: %w", distPath, err) + // Check for dist directory + distPath := filepath.Join(scaffoldPath, "dist") + if err := IsDirOrNotFound(distPath); err != nil { + return fmt.Errorf("dist directory check failed in scaffoldPath %q: %w", scaffoldPath, err) + } + info, err = os.Stat(distPath) + if err != nil || !info.IsDir() { + return fmt.Errorf("dist directory must exist in scaffoldPath %q", scaffoldPath) + } + + // Check for app-main.go file + appMainPath := filepath.Join(scaffoldPath, "app-main.go") + if err := CheckFileExists(appMainPath); err != nil { + return fmt.Errorf("app-main.go check failed in scaffoldPath %q: %w", scaffoldPath, err) } - // Check for templates/tailwind.css file - tailwindPath := filepath.Join(distPath, "templates", "tailwind.css") + // Check for tailwind.css file + tailwindPath := filepath.Join(scaffoldPath, "tailwind.css") if err := CheckFileExists(tailwindPath); err != nil { - return fmt.Errorf("templates/tailwind.css check failed in distPath %q: %w", distPath, err) + return fmt.Errorf("tailwind.css check failed in scaffoldPath %q: %w", scaffoldPath, err) } - // Check for templates/main.go.tmpl file - mainTmplPath := filepath.Join(distPath, "templates", "main.go.tmpl") - if err := CheckFileExists(mainTmplPath); err != nil { - return fmt.Errorf("templates/main.go.tmpl check failed in distPath %q: %w", distPath, err) + // Check for package.json file + packageJsonPath := filepath.Join(scaffoldPath, "package.json") + if err := CheckFileExists(packageJsonPath); err != nil { + return fmt.Errorf("package.json check failed in scaffoldPath %q: %w", scaffoldPath, err) + } + + // Check for node_modules directory + nodeModulesPath := filepath.Join(scaffoldPath, "node_modules") + if err := IsDirOrNotFound(nodeModulesPath); err != nil { + return fmt.Errorf("node_modules directory check failed in scaffoldPath %q: %w", scaffoldPath, err) + } + info, err = os.Stat(nodeModulesPath) + if err != nil || !info.IsDir() { + return fmt.Errorf("node_modules directory must exist in scaffoldPath %q", scaffoldPath) } return nil @@ -259,7 +284,7 @@ func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { return nil, err } - if err := verifyDistPath(opts.DistPath); err != nil { + if err := verifyScaffoldPath(opts.ScaffoldPath); err != nil { return nil, err } @@ -284,32 +309,28 @@ func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { } // Copy static directory - staticCount, err := copyStaticDir(opts.Dir, tempDir) + staticSrcDir := filepath.Join(opts.Dir, "static") + staticDestDir := filepath.Join(tempDir, "static") + staticCount, err := copyDirRecursive(staticSrcDir, staticDestDir, true) if err != nil { return nil, fmt.Errorf("failed to copy static directory: %w", err) } - // Create dist directory - distDir := filepath.Join(tempDir, "dist") - if err := os.MkdirAll(distDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create dist directory: %w", err) - } - - // Copy dist directory contents - distCount, err := copyDirRecursive(opts.DistPath, distDir) + // Copy scaffold directory contents selectively + scaffoldCount, err := copyScaffoldSelective(opts.ScaffoldPath, tempDir) if err != nil { - return nil, fmt.Errorf("failed to copy dist directory: %w", err) + return nil, fmt.Errorf("failed to copy scaffold directory: %w", err) } if opts.Verbose { - log.Printf("Copied %d go files, %d static files, %d dist files\n", goCount, staticCount, distCount) + log.Printf("Copied %d go files, %d static files, %d scaffold files\n", goCount, staticCount, scaffoldCount) } - // Copy main.go.tmpl from dist/templates to temp dir as main-app.go - mainTmplSrc := filepath.Join(opts.DistPath, "templates", "main.go.tmpl") - mainTmplDest := filepath.Join(tempDir, "main-app.go") - if err := copyFile(mainTmplSrc, mainTmplDest); err != nil { - return nil, fmt.Errorf("failed to copy main.go.tmpl: %w", err) + // Copy app-main.go from scaffold to main-app.go in temp dir + appMainSrc := filepath.Join(tempDir, "app-main.go") + appMainDest := filepath.Join(tempDir, "main-app.go") + if err := os.Rename(appMainSrc, appMainDest); err != nil { + return nil, fmt.Errorf("failed to rename app-main.go to main-app.go: %w", err) } // Create go.mod file @@ -319,7 +340,7 @@ func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { } // Generate Tailwind CSS - if err := generateAppTailwindCss(opts.DistPath, tempDir, opts.Verbose); err != nil { + if err := generateAppTailwindCss(tempDir, opts.Verbose); err != nil { return nil, fmt.Errorf("failed to generate tailwind css: %w", err) } @@ -368,14 +389,8 @@ func runGoBuild(tempDir string, verbose bool) error { return nil } -func generateAppTailwindCss(distPath, tempDir string, verbose bool) error { - // Copy tailwind.css from dist/templates to temp dir - tailwindSrc := filepath.Join(distPath, "templates", "tailwind.css") - tailwindDest := filepath.Join(tempDir, "tailwind.css") - if err := copyFile(tailwindSrc, tailwindDest); err != nil { - return fmt.Errorf("failed to copy tailwind.css: %w", err) - } - +func generateAppTailwindCss(tempDir string, verbose bool) error { + // tailwind.css is already in tempDir from scaffold copy tailwindOutput := filepath.Join(tempDir, "static", "tw.css") tailwindCmd := exec.Command("npx", "@tailwindcss/cli", @@ -400,22 +415,6 @@ func generateAppTailwindCss(distPath, tempDir string, verbose bool) error { return nil } -func copyStaticDir(srcDir, destDir string) (int, error) { - // Always create static directory in temp dir - staticDestDir := filepath.Join(destDir, "static") - if err := os.MkdirAll(staticDestDir, 0755); err != nil { - return 0, fmt.Errorf("failed to create static directory: %w", err) - } - - // Copy static/ directory contents if it exists - staticSrcDir := filepath.Join(srcDir, "static") - if _, err := os.Stat(staticSrcDir); err == nil { - return copyDirRecursive(staticSrcDir, staticDestDir) - } - - return 0, nil -} - func copyGoFiles(srcDir, destDir string) (int, error) { entries, err := os.ReadDir(srcDir) if err != nil { @@ -456,15 +455,72 @@ func TsunamiRun(opts BuildOpts) error { log.Printf("Running tsunami app from %s", opts.Dir) runCmd.Stdin = os.Stdin - if opts.Verbose { - log.Printf("Executing: %s", appPath) + + if opts.Open { + // If --open flag is set, we need to capture stderr to parse the listening message + stderr, err := runCmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } runCmd.Stdout = os.Stdout - runCmd.Stderr = os.Stderr - } - if err := runCmd.Run(); err != nil { - return fmt.Errorf("failed to run application: %w", err) + if err := runCmd.Start(); err != nil { + return fmt.Errorf("failed to start application: %w", err) + } + + // Monitor stderr for the listening message + go monitorAndOpenBrowser(stderr, opts.Verbose) + + if err := runCmd.Wait(); err != nil { + return fmt.Errorf("application exited with error: %w", err) + } + } else { + // Normal execution without browser opening + if opts.Verbose { + log.Printf("Executing: %s", appPath) + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + } + + if err := runCmd.Run(); err != nil { + return fmt.Errorf("failed to run application: %w", err) + } } return nil } + +func monitorAndOpenBrowser(stdout io.ReadCloser, verbose bool) { + defer stdout.Close() + + scanner := bufio.NewScanner(stdout) + urlRegex := regexp.MustCompile(`\[tsunami\] listening at (http://[^\s]+)`) + browserOpened := false + if verbose { + log.Printf("monitoring for browser open\n") + } + + for scanner.Scan() { + line := scanner.Text() + if verbose { + log.Println(line) + } + + if !browserOpened && len(urlRegex.FindStringSubmatch(line)) > 1 { + matches := urlRegex.FindStringSubmatch(line) + url := matches[1] + if verbose { + log.Printf("Opening browser to %s", url) + } + go util.OpenBrowser(url, 100*time.Millisecond) + browserOpened = true + } + } + + // Continue reading and printing output if verbose + if verbose { + for scanner.Scan() { + log.Println(scanner.Text()) + } + } +} diff --git a/tsunami/build/buildutil.go b/tsunami/build/buildutil.go index 9ef8dce67b..3ad35233e0 100644 --- a/tsunami/build/buildutil.go +++ b/tsunami/build/buildutil.go @@ -48,9 +48,29 @@ func FileMustNotExist(path string) error { return nil // Not found is OK } -func copyDirRecursive(srcDir, destDir string) (int, error) { +func copyDirRecursive(srcDir, destDir string, forceCreateDestDir bool) (int, error) { + // Check if source directory exists + srcInfo, err := os.Stat(srcDir) + if err != nil { + if os.IsNotExist(err) { + if forceCreateDestDir { + // Create destination directory even if source doesn't exist + if err := os.MkdirAll(destDir, 0755); err != nil { + return 0, fmt.Errorf("failed to create destination directory %s: %w", destDir, err) + } + } + return 0, nil // Source doesn't exist, return 0 files copied + } + return 0, fmt.Errorf("error accessing source directory %s: %w", srcDir, err) + } + + // Check if source is actually a directory + if !srcInfo.IsDir() { + return 0, fmt.Errorf("source %s is not a directory", srcDir) + } + fileCount := 0 - err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -130,3 +150,79 @@ func listGoFilesInDir(dirPath string) ([]string, error) { return goFiles, nil } + +func copyScaffoldSelective(scaffoldPath, destDir string) (int, error) { + fileCount := 0 + + // Create symlinks for node_modules directory + symlinkItems := []string{"node_modules"} + for _, item := range symlinkItems { + srcPath := filepath.Join(scaffoldPath, item) + destPath := filepath.Join(destDir, item) + + // Check if source exists + if _, err := os.Stat(srcPath); err != nil { + if os.IsNotExist(err) { + continue // Skip if doesn't exist + } + return 0, fmt.Errorf("error checking %s: %w", item, err) + } + + // Create symlink + if err := os.Symlink(srcPath, destPath); err != nil { + return 0, fmt.Errorf("failed to create symlink for %s: %w", item, err) + } + fileCount++ + } + + // Copy package files instead of symlinking + packageFiles := []string{"package.json", "package-lock.json"} + for _, fileName := range packageFiles { + srcPath := filepath.Join(scaffoldPath, fileName) + destPath := filepath.Join(destDir, fileName) + + // Check if source exists + if _, err := os.Stat(srcPath); err != nil { + if os.IsNotExist(err) { + continue // Skip if doesn't exist + } + return 0, fmt.Errorf("error checking %s: %w", fileName, err) + } + + // Copy file + if err := copyFile(srcPath, destPath); err != nil { + return 0, fmt.Errorf("failed to copy %s: %w", fileName, err) + } + fileCount++ + } + + // Copy dist directory that needs to be fully copied for go embed + distSrcPath := filepath.Join(scaffoldPath, "dist") + distDestPath := filepath.Join(destDir, "dist") + dirCount, err := copyDirRecursive(distSrcPath, distDestPath, false) + if err != nil { + return 0, fmt.Errorf("failed to copy dist directory: %w", err) + } + fileCount += dirCount + + // Copy files by pattern (*.go, *.md, *.json, tailwind.css) + patterns := []string{"*.go", "*.md", "*.json", "tailwind.css"} + for _, pattern := range patterns { + matches, err := filepath.Glob(filepath.Join(scaffoldPath, pattern)) + if err != nil { + return 0, fmt.Errorf("failed to glob pattern %s: %w", pattern, err) + } + + for _, srcPath := range matches { + fileName := filepath.Base(srcPath) + destPath := filepath.Join(destDir, fileName) + + if err := copyFile(srcPath, destPath); err != nil { + return 0, fmt.Errorf("failed to copy %s: %w", fileName, err) + } + fileCount++ + } + } + + return fileCount, nil +} diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index ea5029cc04..c960f406fe 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -29,9 +29,9 @@ var versionCmd = &cobra.Command{ } func validateEnvironmentVars(opts *build.BuildOpts) error { - distPath := os.Getenv("TSUNAMI_DISTPATH") - if distPath == "" { - return fmt.Errorf("TSUNAMI_DISTPATH environment variable must be set") + scaffoldPath := os.Getenv("TSUNAMI_SCAFFOLDPATH") + if scaffoldPath == "" { + return fmt.Errorf("TSUNAMI_SCAFFOLDPATH environment variable must be set") } sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") @@ -39,7 +39,7 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { return fmt.Errorf("TSUNAMI_SDKREPLACEPATH environment variable must be set") } - opts.DistPath = distPath + opts.ScaffoldPath = scaffoldPath opts.SdkReplacePath = sdkReplacePath return nil } @@ -74,9 +74,11 @@ var runCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") + open, _ := cmd.Flags().GetBool("open") opts := build.BuildOpts{ Dir: args[0], Verbose: verbose, + Open: open, } if err := validateEnvironmentVars(&opts); err != nil { return err @@ -95,6 +97,7 @@ func init() { rootCmd.AddCommand(buildCmd) runCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + runCmd.Flags().Bool("open", false, "Open the application in the browser after starting") rootCmd.AddCommand(runCmd) } diff --git a/tsunami/frontend/index.html b/tsunami/frontend/index.html index 80e4e5b3ed..99ed1d7f3d 100644 --- a/tsunami/frontend/index.html +++ b/tsunami/frontend/index.html @@ -4,6 +4,7 @@ Tsunami App +
    diff --git a/tsunami/templates/app-main.go.tmpl b/tsunami/templates/app-main.go.tmpl new file mode 100644 index 0000000000..5111095cbd --- /dev/null +++ b/tsunami/templates/app-main.go.tmpl @@ -0,0 +1,22 @@ +package main + +import ( + "embed" + "io/fs" + + "github.com/wavetermdev/waveterm/tsunami/app" +) + +//go:embed dist/** +var distFS embed.FS + +//go:embed static/** +var staticFS embed.FS + +func main() { + subDistFS, _ := fs.Sub(distFS, "dist") + app.RegisterAssetsFS(subDistFS) + subStaticFS, _ := fs.Sub(staticFS, "static") + app.RegisterStaticFS(subStaticFS) + app.RunMain() +} diff --git a/tsunami/templates/gitignore.tmpl b/tsunami/templates/gitignore.tmpl new file mode 100644 index 0000000000..e45080f05f --- /dev/null +++ b/tsunami/templates/gitignore.tmpl @@ -0,0 +1,3 @@ +dist/ +node_modules/ +bin/ \ No newline at end of file diff --git a/tsunami/templates/tailwind.css b/tsunami/templates/tailwind.css new file mode 100644 index 0000000000..1bc07b3ca6 --- /dev/null +++ b/tsunami/templates/tailwind.css @@ -0,0 +1,60 @@ +/* Copyright 2025, Command Line Inc. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +@import "tailwindcss"; +@source inline("bg-background text-primary"); + +@theme { + --color-background: rgb(34, 34, 34); /* default background color */ + --color-primary: rgb(247, 247, 247); /* primary text color (headers, bold text) */ + --color-secondary: rgba(215, 218, 224, 0.7); /* secondary text */ + --color-muted: rgba(215, 218, 224, 0.5); /* muted, faint, small text */ + --color-accent-50: rgb(236, 253, 232); + --color-accent-100: rgb(209, 250, 202); + --color-accent-200: rgb(167, 243, 168); + --color-accent-300: rgb(110, 231, 133); + --color-accent-400: rgb(88, 193, 66); /* main accent color */ + --color-accent-500: rgb(63, 162, 51); + --color-accent-600: rgb(47, 133, 47); + --color-accent-700: rgb(34, 104, 43); + --color-accent-800: rgb(22, 81, 35); + --color-accent-900: rgb(15, 61, 29); + --color-error: rgb(229, 77, 46); /* use as bg w/ primary text */ + --color-warning: rgb(181, 137, 0); /* use as bg w/ primary text */ + --color-success: rgb(78, 154, 6); /* use as bg w/ primary text */ + --color-panel: rgba(255, 255, 255, 0.12); /* use a bg for panels */ + --color-hoverbg: rgba(255, 255, 255, 0.16); /* on hover, can use as a bg to highlight */ + --color-border: rgba(255, 255, 255, 0.16); /* fine border color */ + --color-strongborder: rgba(255, 255, 255, 0.24); /* stronger border / divider color */ + --color-accentbg: rgba(88, 193, 66, 0.4); /* accented bg color */ + --color-accent: rgb(88, 193, 66); /* accent text color */ + --color-accenthover: rgb(118, 223, 96); /* brighter accent text color (hover effect) */ + + --font-sans: "Inter", sans-serif; /* regular text font */ + --font-mono: "Hack", monospace; /* monospace, code, terminal, command font */ + --font-markdown: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + + --text-xxs: 10px; /* small, very fine text */ + --text-title: 18px; /* font size for titles */ + --text-default: 14px; /* default font size */ + --radius: 8px; /* default border radius */ + + /* ANSI Terminal Colors (Default Dark Palette) */ + --ansi-black: #757575; + --ansi-red: #cc685c; + --ansi-green: #76c266; + --ansi-yellow: #cbca9b; + --ansi-blue: #85aacb; + --ansi-magenta: #cc72ca; + --ansi-cyan: #74a7cb; + --ansi-white: #c1c1c1; + --ansi-brightblack: #727272; + --ansi-brightred: #cc9d97; + --ansi-brightgreen: #a3dd97; + --ansi-brightyellow: #cbcaaa; + --ansi-brightblue: #9ab6cb; + --ansi-brightmagenta: #cc8ecb; + --ansi-brightcyan: #b7b8cb; + --ansi-brightwhite: #f0f0f0; +} diff --git a/tsunami/util/util.go b/tsunami/util/util.go index ee2ce5ce89..117ba7adab 100644 --- a/tsunami/util/util.go +++ b/tsunami/util/util.go @@ -4,10 +4,12 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "runtime" "runtime/debug" "strings" + "time" ) func PanicHandler(debugStr string, recoverVal any) error { @@ -65,3 +67,26 @@ func ChunkSlice[T any](slice []T, chunkSize int) [][]T { } return chunks } + +func OpenBrowser(url string, delay time.Duration) { + if delay > 0 { + time.Sleep(delay) + } + + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + args = []string{url} + } + + exec.Command(cmd, args...).Start() +} From f57ace9d627591d7d6bb6bc4aef63207f24622c1 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 11:02:13 -0700 Subject: [PATCH 041/134] implement -o for tsunami build --- tsunami/build/build.go | 41 ++++++++++++++++++++++++++++--------- tsunami/cmd/main-tsunami.go | 19 ++++++++++++----- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index 85e4cea068..db6b79a201 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -21,6 +21,8 @@ type BuildOpts struct { Dir string Verbose bool Open bool + KeepTemp bool + OutputFile string ScaffoldPath string SdkReplacePath string } @@ -275,6 +277,10 @@ func verifyScaffoldPath(scaffoldPath string) error { } func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { + return tsunamiBuildInternal(opts) +} + +func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { buildEnv, err := verifyEnvironment(opts.Verbose) if err != nil { return nil, err @@ -345,17 +351,28 @@ func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { } // Build the Go application - if err := runGoBuild(tempDir, opts.Verbose); err != nil { + if err := runGoBuild(tempDir, opts); err != nil { return nil, fmt.Errorf("failed to build application: %w", err) } return buildEnv, nil } -func runGoBuild(tempDir string, verbose bool) error { - binDir := filepath.Join(tempDir, "bin") - if err := os.MkdirAll(binDir, 0755); err != nil { - return fmt.Errorf("failed to create bin directory: %w", err) +func runGoBuild(tempDir string, opts BuildOpts) error { + var outputPath string + if opts.OutputFile != "" { + // Convert to absolute path resolved against current working directory + var err error + outputPath, err = filepath.Abs(opts.OutputFile) + if err != nil { + return fmt.Errorf("failed to resolve output path: %w", err) + } + } else { + binDir := filepath.Join(tempDir, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("failed to create bin directory: %w", err) + } + outputPath = "bin/app" } goFiles, err := listGoFilesInDir(tempDir) @@ -368,11 +385,11 @@ func runGoBuild(tempDir string, verbose bool) error { } // Build command with explicit go files - args := append([]string{"build", "-o", "bin/app"}, goFiles...) + args := append([]string{"build", "-o", outputPath}, goFiles...) buildCmd := exec.Command("go", args...) buildCmd.Dir = tempDir - if verbose { + if opts.Verbose { log.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) buildCmd.Stdout = os.Stdout buildCmd.Stderr = os.Stderr @@ -382,8 +399,12 @@ func runGoBuild(tempDir string, verbose bool) error { return fmt.Errorf("failed to build application: %w", err) } - if verbose { - log.Printf("Application built successfully at %s", filepath.Join(binDir, "app")) + if opts.Verbose { + if opts.OutputFile != "" { + log.Printf("Application built successfully at %s", outputPath) + } else { + log.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) + } } return nil @@ -442,7 +463,7 @@ func copyGoFiles(srcDir, destDir string) (int, error) { } func TsunamiRun(opts BuildOpts) error { - buildEnv, err := TsunamiBuild(opts) + buildEnv, err := tsunamiBuildInternal(opts) if err != nil { return err } diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index c960f406fe..1ea515d6d1 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -52,9 +52,13 @@ var buildCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") + keepTemp, _ := cmd.Flags().GetBool("keeptemp") + output, _ := cmd.Flags().GetString("output") opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, + Dir: args[0], + Verbose: verbose, + KeepTemp: keepTemp, + OutputFile: output, } if err := validateEnvironmentVars(&opts); err != nil { return err @@ -75,10 +79,12 @@ var runCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { verbose, _ := cmd.Flags().GetBool("verbose") open, _ := cmd.Flags().GetBool("open") + keepTemp, _ := cmd.Flags().GetBool("keeptemp") opts := build.BuildOpts{ - Dir: args[0], - Verbose: verbose, - Open: open, + Dir: args[0], + Verbose: verbose, + Open: open, + KeepTemp: keepTemp, } if err := validateEnvironmentVars(&opts); err != nil { return err @@ -94,10 +100,13 @@ func init() { rootCmd.AddCommand(versionCmd) buildCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + buildCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory") + buildCmd.Flags().StringP("output", "o", "", "Output file path for the built application") rootCmd.AddCommand(buildCmd) runCmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") runCmd.Flags().Bool("open", false, "Open the application in the browser after starting") + runCmd.Flags().Bool("keeptemp", false, "Keep temporary build directory") rootCmd.AddCommand(runCmd) } From 296ac34931ea00b1e3b25031cafe94c401f6381c Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 12:00:01 -0700 Subject: [PATCH 042/134] tempdir cleanup --- tsunami/build/build.go | 84 ++++++++++++++++++++++++++++++------- tsunami/cmd/main-tsunami.go | 24 ++++++----- 2 files changed, 81 insertions(+), 27 deletions(-) diff --git a/tsunami/build/build.go b/tsunami/build/build.go index db6b79a201..5bdbd74056 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -7,10 +7,13 @@ import ( "log" "os" "os/exec" + "os/signal" "path/filepath" "regexp" "strconv" "strings" + "sync" + "syscall" "time" "github.com/wavetermdev/waveterm/tsunami/util" @@ -28,8 +31,9 @@ type BuildOpts struct { } type BuildEnv struct { - GoVersion string - TempDir string + GoVersion string + TempDir string + cleanupOnce *sync.Once } func verifyEnvironment(verbose bool) (*BuildEnv, error) { @@ -113,7 +117,10 @@ func verifyEnvironment(verbose bool) (*BuildEnv, error) { return nil, fmt.Errorf("tailwindcss v4 required, found: %s", firstLine) } - return &BuildEnv{GoVersion: goVersion}, nil + return &BuildEnv{ + GoVersion: goVersion, + cleanupOnce: &sync.Once{}, + }, nil } func createGoMod(tempDir, appDirName, goVersion string, opts BuildOpts, verbose bool) error { @@ -276,8 +283,49 @@ func verifyScaffoldPath(scaffoldPath string) error { return nil } -func TsunamiBuild(opts BuildOpts) (*BuildEnv, error) { - return tsunamiBuildInternal(opts) +func (be *BuildEnv) cleanupTempDir(keepTemp bool, verbose bool) { + if be == nil || be.cleanupOnce == nil { + return + } + + be.cleanupOnce.Do(func() { + if keepTemp || be.TempDir == "" { + log.Printf("NOT cleaning tempdir\n") + return + } + if err := os.RemoveAll(be.TempDir); err != nil { + log.Printf("Failed to remove temp directory %s: %v", be.TempDir, err) + } else if verbose { + log.Printf("Removed temp directory: %s", be.TempDir) + } + }) +} + +func setupSignalCleanup(buildEnv *BuildEnv, keepTemp, verbose bool) { + if keepTemp { + return + } + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + defer signal.Stop(sigChan) + sig := <-sigChan + if verbose { + log.Printf("Received signal %v, cleaning up temp directory", sig) + } + buildEnv.cleanupTempDir(keepTemp, verbose) + os.Exit(1) + }() +} + +func TsunamiBuild(opts BuildOpts) error { + buildEnv, err := tsunamiBuildInternal(opts) + defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) + if err != nil { + return err + } + setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose) + return nil } func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { @@ -311,7 +359,7 @@ func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { // Copy all *.go files from the root directory goCount, err := copyGoFiles(opts.Dir, tempDir) if err != nil { - return nil, fmt.Errorf("failed to copy go files: %w", err) + return buildEnv, fmt.Errorf("failed to copy go files: %w", err) } // Copy static directory @@ -319,13 +367,13 @@ func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { staticDestDir := filepath.Join(tempDir, "static") staticCount, err := copyDirRecursive(staticSrcDir, staticDestDir, true) if err != nil { - return nil, fmt.Errorf("failed to copy static directory: %w", err) + return buildEnv, fmt.Errorf("failed to copy static directory: %w", err) } // Copy scaffold directory contents selectively scaffoldCount, err := copyScaffoldSelective(opts.ScaffoldPath, tempDir) if err != nil { - return nil, fmt.Errorf("failed to copy scaffold directory: %w", err) + return buildEnv, fmt.Errorf("failed to copy scaffold directory: %w", err) } if opts.Verbose { @@ -336,23 +384,23 @@ func tsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { appMainSrc := filepath.Join(tempDir, "app-main.go") appMainDest := filepath.Join(tempDir, "main-app.go") if err := os.Rename(appMainSrc, appMainDest); err != nil { - return nil, fmt.Errorf("failed to rename app-main.go to main-app.go: %w", err) + return buildEnv, fmt.Errorf("failed to rename app-main.go to main-app.go: %w", err) } // Create go.mod file appDirName := filepath.Base(opts.Dir) if err := createGoMod(tempDir, appDirName, buildEnv.GoVersion, opts, opts.Verbose); err != nil { - return nil, fmt.Errorf("failed to create go.mod: %w", err) + return buildEnv, fmt.Errorf("failed to create go.mod: %w", err) } // Generate Tailwind CSS if err := generateAppTailwindCss(tempDir, opts.Verbose); err != nil { - return nil, fmt.Errorf("failed to generate tailwind css: %w", err) + return buildEnv, fmt.Errorf("failed to generate tailwind css: %w", err) } // Build the Go application if err := runGoBuild(tempDir, opts); err != nil { - return nil, fmt.Errorf("failed to build application: %w", err) + return buildEnv, fmt.Errorf("failed to build application: %w", err) } return buildEnv, nil @@ -421,8 +469,6 @@ func generateAppTailwindCss(tempDir string, verbose bool) error { if verbose { log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) - tailwindCmd.Stdout = os.Stdout - tailwindCmd.Stderr = os.Stderr } if err := tailwindCmd.Run(); err != nil { @@ -464,9 +510,11 @@ func copyGoFiles(srcDir, destDir string) (int, error) { func TsunamiRun(opts BuildOpts) error { buildEnv, err := tsunamiBuildInternal(opts) + defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) if err != nil { return err } + setupSignalCleanup(buildEnv, opts.KeepTemp, opts.Verbose) // Run the built application appPath := filepath.Join(buildEnv.TempDir, "bin", "app") @@ -503,8 +551,12 @@ func TsunamiRun(opts BuildOpts) error { runCmd.Stderr = os.Stderr } - if err := runCmd.Run(); err != nil { - return fmt.Errorf("failed to run application: %w", err) + if err := runCmd.Start(); err != nil { + return fmt.Errorf("failed to start application: %w", err) + } + + if err := runCmd.Wait(); err != nil { + return fmt.Errorf("application exited with error: %w", err) } } diff --git a/tsunami/cmd/main-tsunami.go b/tsunami/cmd/main-tsunami.go index 1ea515d6d1..6eae0f1951 100644 --- a/tsunami/cmd/main-tsunami.go +++ b/tsunami/cmd/main-tsunami.go @@ -33,12 +33,12 @@ func validateEnvironmentVars(opts *build.BuildOpts) error { if scaffoldPath == "" { return fmt.Errorf("TSUNAMI_SCAFFOLDPATH environment variable must be set") } - + sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") if sdkReplacePath == "" { return fmt.Errorf("TSUNAMI_SDKREPLACEPATH environment variable must be set") } - + opts.ScaffoldPath = scaffoldPath opts.SdkReplacePath = sdkReplacePath return nil @@ -50,7 +50,7 @@ var buildCmd = &cobra.Command{ Long: `Build a Tsunami application from the specified directory.`, Args: cobra.ExactArgs(1), SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") keepTemp, _ := cmd.Flags().GetBool("keeptemp") output, _ := cmd.Flags().GetString("output") @@ -61,12 +61,13 @@ var buildCmd = &cobra.Command{ OutputFile: output, } if err := validateEnvironmentVars(&opts); err != nil { - return err + fmt.Println(err) + os.Exit(1) } - if _, err := build.TsunamiBuild(opts); err != nil { - return fmt.Errorf("build failed: %w", err) + if err := build.TsunamiBuild(opts); err != nil { + fmt.Println(err) + os.Exit(1) } - return nil }, } @@ -76,7 +77,7 @@ var runCmd = &cobra.Command{ Long: `Build and run a Tsunami application from the specified directory.`, Args: cobra.ExactArgs(1), SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") open, _ := cmd.Flags().GetBool("open") keepTemp, _ := cmd.Flags().GetBool("keeptemp") @@ -87,12 +88,13 @@ var runCmd = &cobra.Command{ KeepTemp: keepTemp, } if err := validateEnvironmentVars(&opts); err != nil { - return err + fmt.Println(err) + os.Exit(1) } if err := build.TsunamiRun(opts); err != nil { - return fmt.Errorf("run failed: %w", err) + fmt.Println(err) + os.Exit(1) } - return nil }, } From 013e4f4ec9a300caba4bccf567ce5341888822cd Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 12:30:27 -0700 Subject: [PATCH 043/134] fixed SSE asyncinitation, ported pomodoro --- .vscode/settings.json | 3 +- tsunami/app/serverhandlers.go | 26 ++-- tsunami/demo/pomodoro/app.go | 237 ++++++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 tsunami/demo/pomodoro/app.go diff --git a/.vscode/settings.json b/.vscode/settings.json index 15c0d1c61c..07a68e5252 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,6 +59,7 @@ "gopls": { "analyses": { "QF1003": false - } + }, + "directoryFilters": ["-tsunami/frontend/scaffold"] } } diff --git a/tsunami/app/serverhandlers.go b/tsunami/app/serverhandlers.go index 3ec489a9ba..5feb9e5f93 100644 --- a/tsunami/app/serverhandlers.go +++ b/tsunami/app/serverhandlers.go @@ -277,18 +277,18 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Accel-Buffering", "no") // nginx hint - // Flush headers immediately - flusher, ok := w.(http.Flusher) - if !ok { + // Use ResponseController for better flushing control + rc := http.NewResponseController(w) + if err := rc.Flush(); err != nil { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } - flusher.Flush() // Create a ticker for keepalive packets keepaliveTicker := time.NewTicker(SSEKeepAliveDuration) @@ -301,17 +301,15 @@ func (h *HTTPHandlers) handleSSE(w http.ResponseWriter, r *http.Request) { case <-keepaliveTicker.C: // Send keepalive comment fmt.Fprintf(w, ": keepalive\n\n") - flusher.Flush() + rc.Flush() case event := <-h.Client.SSEventCh: if event.Event == "" { break } fmt.Fprintf(w, "event: %s\n", event.Event) - if len(event.Data) > 0 { - fmt.Fprintf(w, "data: %s\n", string(event.Data)) - } + fmt.Fprintf(w, "data: %s\n", string(event.Data)) fmt.Fprintf(w, "\n") - flusher.Flush() + rc.Flush() } } } @@ -322,7 +320,7 @@ func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, if !strings.HasSuffix(requestPath, "/") { return false } - + // Try to serve the specified file from that directory var filePath string if requestPath == "/" { @@ -330,19 +328,19 @@ func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, } else { filePath = strings.TrimPrefix(requestPath, "/") + fileName } - + file, err := embeddedFS.Open(filePath) if err != nil { return false } defer file.Close() - + // Get file info for modification time fileInfo, err := file.Stat() if err != nil { return false } - + // Serve the file directly with proper mod time http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker)) return true diff --git a/tsunami/demo/pomodoro/app.go b/tsunami/demo/pomodoro/app.go new file mode 100644 index 0000000000..15ec2dbf42 --- /dev/null +++ b/tsunami/demo/pomodoro/app.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/wavetermdev/waveterm/tsunami/app" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +func init() { + app.SetAppOpts(app.AppOpts{ + CloseOnCtrlC: true, + Title: "Pomodoro Timer (Tsunami Demo)", + }) +} + +type Mode struct { + Name string `json:"name"` + Duration int `json:"duration"` // in minutes +} + +var ( + WorkMode = Mode{Name: "Work", Duration: 25} + BreakMode = Mode{Name: "Break", Duration: 5} +) + +type TimerDisplayProps struct { + Minutes int `json:"minutes"` + Seconds int `json:"seconds"` + Mode string `json:"mode"` +} + +type ControlButtonsProps struct { + IsRunning bool `json:"isRunning"` + OnStart func() `json:"onStart"` + OnPause func() `json:"onPause"` + OnReset func() `json:"onReset"` + OnMode func(int) `json:"onMode"` +} + +type TimerState struct { + ticker *time.Ticker + done chan bool + startTime time.Time + duration time.Duration + isActive bool // Track if the timer goroutine is running +} + +var TimerDisplay = app.DefineComponent("TimerDisplay", + func(ctx context.Context, props TimerDisplayProps) any { + return vdom.E("div", + vdom.Class("bg-slate-700 p-8 rounded-lg mb-8 text-center"), + vdom.E("div", + vdom.Class("text-xl text-blue-400 mb-2"), + props.Mode, + ), + vdom.E("div", + vdom.Class("text-6xl font-bold font-mono text-slate-100"), + fmt.Sprintf("%02d:%02d", props.Minutes, props.Seconds), + ), + ) + }, +) + +var ControlButtons = app.DefineComponent("ControlButtons", + func(ctx context.Context, props ControlButtonsProps) any { + return vdom.E("div", + vdom.Class("flex flex-col gap-4"), + vdom.IfElse(props.IsRunning, + vdom.E("button", + vdom.Class("px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200"), + vdom.P("onClick", props.OnPause), + "Pause", + ), + vdom.E("button", + vdom.Class("px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200"), + vdom.P("onClick", props.OnStart), + "Start", + ), + ), + vdom.E("button", + vdom.Class("px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200"), + vdom.P("onClick", props.OnReset), + "Reset", + ), + vdom.E("div", + vdom.Class("flex gap-4 mt-4"), + vdom.E("button", + vdom.Class("flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200"), + vdom.P("onClick", func() { props.OnMode(WorkMode.Duration) }), + "Work Mode", + ), + vdom.E("button", + vdom.Class("flex-1 px-3 py-3 text-base border-none rounded bg-green-500 text-white cursor-pointer hover:bg-green-600 transition-colors duration-200"), + vdom.P("onClick", func() { props.OnMode(BreakMode.Duration) }), + "Break Mode", + ), + ), + ) + }, +) + +var App = app.DefineComponent("App", + func(ctx context.Context, _ any) any { + isRunning, setIsRunning, _ := vdom.UseState(ctx, false) + minutes, setMinutes, _ := vdom.UseState(ctx, WorkMode.Duration) + seconds, setSeconds, _ := vdom.UseState(ctx, 0) + mode, setMode, _ := vdom.UseState(ctx, WorkMode.Name) + _, setIsComplete, _ := vdom.UseState(ctx, false) + timerRef := vdom.UseRef(ctx, &TimerState{ + done: make(chan bool), + }) + + stopTimer := func() { + if timerRef.Current.ticker != nil { + timerRef.Current.ticker.Stop() + timerRef.Current.ticker = nil + } + if timerRef.Current.isActive { + close(timerRef.Current.done) + timerRef.Current.isActive = false + } + timerRef.Current.done = make(chan bool) + } + + startTimer := func() { + if timerRef.Current.isActive { + return // Timer already running + } + + // Stop any existing timer first + stopTimer() + + setIsComplete(false) + timerRef.Current.startTime = time.Now() + timerRef.Current.duration = time.Duration(minutes) * time.Minute + timerRef.Current.isActive = true + setIsRunning(true) + timerRef.Current.ticker = time.NewTicker(1 * time.Second) + + go func() { + for { + select { + case <-timerRef.Current.done: + return + case <-timerRef.Current.ticker.C: + elapsed := time.Since(timerRef.Current.startTime) + remaining := timerRef.Current.duration - elapsed + + if remaining <= 0 { + // Timer completed + setIsRunning(false) + setMinutes(0) + setSeconds(0) + setIsComplete(true) + stopTimer() + app.SendAsyncInitiation() + return + } + + m := int(remaining.Minutes()) + s := int(remaining.Seconds()) % 60 + + // Only send update if values actually changed + if m != minutes || s != seconds { + setMinutes(m) + setSeconds(s) + app.SendAsyncInitiation() + } + } + } + }() + } + + pauseTimer := func() { + stopTimer() + setIsRunning(false) + app.SendAsyncInitiation() + } + + resetTimer := func() { + stopTimer() + setIsRunning(false) + setIsComplete(false) + if mode == WorkMode.Name { + setMinutes(WorkMode.Duration) + } else { + setMinutes(BreakMode.Duration) + } + setSeconds(0) + app.SendAsyncInitiation() + } + + changeMode := func(duration int) { + stopTimer() + setIsRunning(false) + setIsComplete(false) + setMinutes(duration) + setSeconds(0) + if duration == WorkMode.Duration { + setMode(WorkMode.Name) + } else { + setMode(BreakMode.Name) + } + app.SendAsyncInitiation() + } + + // Cleanup on unmount + vdom.UseEffect(ctx, func() func() { + return func() { + stopTimer() + } + }, []any{}) + + return vdom.E("div", + vdom.Class("max-w-sm mx-auto my-8 p-8 bg-slate-800 rounded-xl text-slate-100 font-sans"), + vdom.E("h1", + vdom.Class("text-center text-slate-100 mb-8 text-3xl"), + "Pomodoro Timer", + ), + TimerDisplay(TimerDisplayProps{ + Minutes: minutes, + Seconds: seconds, + Mode: mode, + }), + ControlButtons(ControlButtonsProps{ + IsRunning: isRunning, + OnStart: startTimer, + OnPause: pauseTimer, + OnReset: resetTimer, + OnMode: changeMode, + }), + ) + }, +) From 5eb75b240625e4fbae912214604a8a00ce13d0bf Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 3 Sep 2025 12:51:17 -0700 Subject: [PATCH 044/134] first cut at new prompt.md for tsunami (ported from waveapps) --- tsunami/prompt.md | 877 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 877 insertions(+) create mode 100644 tsunami/prompt.md diff --git a/tsunami/prompt.md b/tsunami/prompt.md new file mode 100644 index 0000000000..3636c20998 --- /dev/null +++ b/tsunami/prompt.md @@ -0,0 +1,877 @@ +# Tsunami Framework Guide + +Wave Terminal includes a powerful Tsunami framework that lets developers create rich HTML/React-based UI applications directly from Go code. The system translates Go components and elements into React components that are rendered within Wave Terminal's UI. It's particularly well-suited for administrative interfaces, monitoring dashboards, data visualization, configuration managers, and form-based applications where you want a graphical interface but don't need complex browser-side interactions. + +This guide explains how to use the Tsunami framework (and corresponding VDOM, virtual DOM, component) to create interactive applications that run in Wave Terminal. While the patterns will feel familiar to React developers (components, props, hooks), the implementation is pure Go and takes advantage of Go's strengths like goroutines for async operations. Note that complex browser-side interactions like drag-and-drop, rich text editing, or heavy JavaScript functionality are not supported - the framework is designed for straightforward, practical applications. + +You'll learn how to: + +- Create and compose components +- Manage state and handle events +- Work with styles and CSS +- Handle async operations with goroutines +- Create rich UIs that render in Wave Terminal + +The included todo-main.go provides a complete example application showing these patterns in action. + +## App Setup and Registration + +Tsunami applications use a default client configured through `app.SetAppOpts()` in an `init()` function: + +```go +func init() { + // Set up the default client with options + app.SetAppOpts(app.AppOpts{ + CloseOnCtrlC: true, + Title: "My Tsunami App", + }) +} + +// Components are defined using the default client +var MyComponent = app.DefineComponent("MyComponent", + func(ctx context.Context, props MyProps) any { + // component logic + }, +) +``` + +## Building Elements with vdom.H() + +The H function creates virtual DOM elements following a React-like pattern. It takes a tag name, a props map, and any number of children: + +```go +// Basic element with no props +vdom.H("div", nil, "Hello world") + +// Element with props +vdom.H("div", map[string]any{ + "className": "container", + "id": "main", + "onClick": func() { + fmt.Println("clicked!") + }, +}, + "child content", +) + +// Element with style +vdom.H("div", map[string]any{ + "style": map[string]any{ + "marginTop": 10, // Numbers automatically convert to px + "color": "red", + "display": "flex", + }, +}) + +// Working with classes +vdom.H("div", map[string]any{ + "className": vdom.Classes( + "base", // Static classes + vdom.If(isActive, "active"), // Conditional class: condition first, then class + vdom.If(isDisabled, "disabled"), // Another conditional + ), +}) + +// Nesting elements +vdom.H("div", map[string]any{ + "className": "container", +}, + vdom.H("h1", map[string]any{ + "className": "title", + }, "Hello"), + vdom.H("p", map[string]any{ + "className": "content", + }, "Some content"), +) + +// Handling events +vdom.H("button", map[string]any{ + "onClick": func() { + handleClick() + }, + "onKeyDown": &vdom.VDomFunc{ + Fn: handleKey, + Keys: []string{"Enter", "Space"}, + PreventDefault: true, + }, +}) + +// List rendering +vdom.H("ul", nil, + vdom.ForEachIdx(items, func(item string, idx int) any { + return vdom.H("li", map[string]any{ + "key": idx, + "className": "list-item", + }, item) + }), +) + +// Conditional rendering +vdom.H("div", nil, + vdom.If(isVisible, vdom.H("span", nil, "Visible content")), +) +``` + +Arguments to H: + +1. `tag` (string): The HTML tag name +2. `props` (map[string]any or nil): Props map including: + - className: String of space-separated classes + - style: map[string]any of CSS properties + - Event handlers (onClick, onChange, etc) + - Any other valid HTML attributes +3. `children` (...any): Any number of child elements: + - Other H() elements + - Strings (become text nodes) + - Numbers (converted to string) + - Arrays of the above + - nil values are ignored + - Anything with String() method becomes text + +Best practices: + +- Use Classes() with If() for conditional classes +- Use camelCase for style properties (matching React) +- Numbers in style are automatically converted to pixel values +- Always create new slices when updating arrays in state +- Use ForEach or ForEachIdx for list rendering +- Include key prop when rendering lists + +## Conditional Rendering and Lists + +The system provides helper functions for conditional and list rendering: + +```go +// Conditional rendering with vdom.If() +vdom.H("div", nil, + vdom.If(isVisible, + vdom.H("span", nil, "Visible content"), + ), +) + +// Branching with vdom.IfElse() +vdom.H("div", nil, + vdom.IfElse(isActive, + vdom.H("span", nil, "Active"), + vdom.H("span", nil, "Inactive"), + ), +) + +// List rendering (adding "key" prop to li element) +items := []string{"A", "B", "C"} +vdom.H("ul", nil, + vdom.ForEachIdx(items, func(item string, idx int) any { + return vdom.H("li", map[string]any{ + "key": idx, + "className": "list-item", + }, item) + }), +) +``` + +Helper functions: + +- `vdom.Fragment(...any)` - Combines elements into a group without adding a DOM node. Useful with conditional functions. +- `vdom.If(cond bool, part any) any` - Returns part if condition is true, nil otherwise +- `vdom.IfElse(cond bool, truePart any, falsePart any) any` - Returns truePart if condition is true, falsePart otherwise +- `vdom.ForEach[T any](items []T, fn func(T) any) []any` - Maps over items without index +- `vdom.ForEachIdx[T any](items []T, fn func(T, int) any) []any` - Maps over items with index +- `vdom.Filter[T any](items []T, fn func(T) bool) []T` - Filters items based on condition +- `vdom.FilterIdx[T any](items []T, fn func(T, int) bool) []T` - Filters items with index access + +- The same If/IfElse functions are used for both conditional rendering and conditional classes, always following the pattern of condition first, then value(s). +- Remember to use vdom.IfElse if you need a true ternary condition. vdom.If will return nil on false and does not allow a 3rd argument. + +## Style Handling + +Tsunami applications use Tailwind v4 CSS by default for styling. You can also define inline styles using a map[string]any in the props: + +```go +vdom.H("div", map[string]any{ + "style": map[string]any{ + "marginRight": 10, // Numbers for px values + "backgroundColor": "#fff", // Colors as strings + "display": "flex", // CSS values as strings + "fontSize": 16, // More numbers + "borderRadius": 4, // Numbers to px + }, +}) + +// Multiple style properties can be combined with dynamic values +vdom.H("div", map[string]any{ + "style": map[string]any{ + "marginTop": spacing, // Variables work too + "color": vdom.IfElse(isActive, "blue", "gray"), + "display": "flex", + "opacity": vdom.If(isVisible, 1.0), // Conditional styles + }, +}) +``` + +Properties use camelCase (must match React) and values can be: + +- Numbers (automatically converted to pixel values) +- Colors as strings +- Other CSS values as strings +- Conditional values using If/IfElse + +The style map in props mirrors React's style object pattern, making it familiar to React developers while maintaining type safety in Go. + +## Component Definition Pattern + +Create typed, reusable components using the client: + +```go +// Define prop types with json tags +type TodoItemProps struct { + Todo Todo `json:"todo"` + OnToggle func() `json:"onToggle"` + IsActive bool `json:"isActive"` +} + +// Create component with typed props +var TodoItem = app.DefineComponent("TodoItem", + func(ctx context.Context, props TodoItemProps) any { + return vdom.H("div", map[string]any{ + "className": vdom.Classes( + "todo-item", + vdom.If(props.IsActive, "active"), + ), + "onClick": props.OnToggle, + "style": map[string]any{ + "cursor": "pointer", + "opacity": vdom.If(props.IsActive, 1.0, 0.7), + }, + }, props.Todo.Text) + }, +) + +// Usage in parent component: +vdom.H("div", map[string]any{ + "className": "todo-list", +}, + TodoItem(TodoItemProps{ + Todo: todo, + OnToggle: handleToggle, + IsActive: isCurrentItem, + }), +) + +// Usage with key (when in lists) +TodoItem(TodoItemProps{ + Todo: todo, + OnToggle: handleToggle, +}).WithKey(fmt.Sprint(idx)) +``` + +Components in Tsunami: + +- Use Go structs with json tags for props +- Take a context and props as arguments +- Return elements created with vdom.H() +- Can use all hooks (useState, useRef, etc) +- Are registered with the default client and given a name +- Are called as functions with their props struct + +Special Handling for Component "key" prop: + +- Use the WithKey(key string) chaining func to set a key on a component +- Keys must be added for components rendered in lists (just like in React) +- Keys should be unique among siblings and stable across renders +- Keys are handled at the framework level and should not be declared in component props + +This pattern matches React's functional components while maintaining Go's type safety and explicit props definition. + +## Handler Functions + +For most event handling, passing a function directly in the props map works: + +```go +vdom.H("button", map[string]any{ + "onClick": func() { + fmt.Println("clicked!") + }, +}) + +// With event data +vdom.H("input", map[string]any{ + "onChange": func(e vdom.VDomEvent) { + fmt.Println("new value:", e.TargetValue) + }, +}) +``` + +For keyboard events that need special handling, preventDefault, or stopPropagation, use VDomFunc: + +```go +// Handle specific keys with onKeyDown +keyHandler := &vdom.VDomFunc{ + Type: vdom.ObjectType_Func, + Fn: func(event vdom.VDomEvent) { + // handle key press + }, + StopPropagation: true, // Stop event bubbling + PreventDefault: true, // Prevent default browser behavior + Keys: []string{ + "Enter", // Just Enter key + "Shift:Tab", // Shift+Tab + "Control:c", // Ctrl+C + "Meta:v", // Meta+V (Windows)/Command+V (Mac) + "Alt:x", // Alt+X + "Cmd:s", // Command+S (Mac)/Alt+S (Windows) + "Option:f", // Option+F (Mac)/Meta+F (Windows) + }, +} + +vdom.H("input", map[string]any{ + "className": "special-input", + "onKeyDown": keyHandler, +}) + +// Common pattern for form handling +vdom.H("form", map[string]any{ + "onSubmit": &vdom.VDomFunc{ + Fn: handleSubmit, + PreventDefault: true, // Prevent form submission + }, +}) +``` + +The Keys field on VDomFunc: + +- Only works with onKeyDown events +- Format is "[modifier]:key" or just "key" +- Modifiers: + - Shift, Control, Meta, Alt: work as expected + - Cmd: maps to Meta on Mac, Alt on Windows/Linux + - Option: maps to Alt on Mac, Meta on Windows/Linux + +Event handlers follow React patterns while providing additional type safety and explicit control over event behavior through VDomFunc. + +## State Management with Hooks + +```go +func MyComponent(ctx context.Context, props MyProps) any { + // UseState: returns current value, setter function, and functional setter + count, setCount, _ := vdom.UseState(ctx, 0) // Initial value of 0 + items, setItems, _ := vdom.UseState(ctx, []string{}) // Initial value of empty slice + + // When you need the functional setter, use all 3 return values + counter, setCounter, setCounterFn := vdom.UseState(ctx, 0) + + // Event handlers that update state (called from onClick, onChange, etc.) + incrementCount := func() { + setCount(count + 1) // Direct update when you have the value + } + + incrementCounterFn := func() { + setCounterFn(func(current int) int { + return current + 1 // Functional update based on current value + }) + } + + addItem := func(item string) { + // When updating slices/maps, create new value + setItems(append([]string{}, items..., item)) + } + + // Refs for values that persist between renders but don't trigger updates + renderCounter := vdom.UseRef(ctx, 0) + renderCounter.Current++ // Doesn't cause re-render + + // DOM refs for accessing elements directly + inputRef := vdom.UseVDomRef(ctx) + + // Side effects (can call setters here) + vdom.UseEffect(ctx, func() func() { + // Example: set counter to 10 on mount + setCounter(10) + + return func() { + // cleanup + } + }, []any{}) // Empty dependency array means run once on mount + + return vdom.H("div", nil, + vdom.H("button", map[string]any{ + "onClick": incrementCount, // State setter called in event handler + }, "Increment: ", count), + vdom.H("button", map[string]any{ + "onClick": incrementCounterFn, // Functional setter in event handler + }, "Functional Increment: ", counter), + vdom.H("input", map[string]any{ + "ref": inputRef, + "type": "text", + "placeholder": "Add item", + "onKeyDown": &vdom.VDomFunc{ + Fn: func(e vdom.VDomEvent) { + if e.TargetValue != "" { + addItem(e.TargetValue) // State setter in event handler + } + }, + Keys: []string{"Enter"}, + }, + }), + vdom.H("ul", nil, + vdom.ForEach(items, func(item string) any { + return vdom.H("li", nil, item) + }), + ), + ) +} +``` + +## Available Hooks + +The system provides three main types of hooks: + +1. `UseState` - For values that trigger re-renders when changed: + + - Returns current value, direct setter, and functional setter + - Direct setter triggers component re-render + - Functional setter ensures you're working with latest state value + - Create new values for slices/maps when updating + + ```go + count, setCount, setCountFn := vdom.UseState(ctx, 0) + // Direct update when you have the value: + setCount(42) + // Functional update when you need current value: + setCountFn(func(current int) int { + return current + 1 + }) + ``` + +2. `UseRef` - For values that persist between renders without triggering updates: + + - Holds mutable values that survive re-renders + - Changes don't cause re-renders + - Perfect for: + - Managing goroutine state + - Storing timers/channels + - Tracking subscriptions + - Holding complex state structures + + ```go + timerRef := vdom.UseRef(ctx, &TimerState{ + done: make(chan bool), + }) + ``` + +3. `UseVDomRef` - For accessing DOM elements directly: + - Creates refs for DOM interaction + - Useful for: + - Accessing DOM element properties + - Managing focus + - Measuring elements + - Direct DOM manipulation when needed + ```go + inputRef := vdom.UseVDomRef(ctx) + vdom.H("input", map[string]any{ + "ref": inputRef, + "type": "text", + }) + ``` + +Best Practices: + +- Use `UseState` for all UI state - it provides both direct and functional setters +- Use functional setter when updating state from goroutines or based on current value +- Use `UseRef` for complex state that goroutines need to access +- Always clean up timers, channels, and goroutines in UseEffect cleanup functions + +## State Management and Async Updates + +While React patterns typically avoid globals, in Go Tsunami applications it's perfectly fine and often clearer to use global variables. However, when dealing with async operations and goroutines, special care must be taken: + +```go +// Global state is fine! +var globalTodos []Todo +var globalFilter string + +// For async operations, consider using a state struct +type TimerState struct { + ticker *time.Ticker + done chan bool + isActive bool +} + +var TodoApp = app.DefineComponent("TodoApp", + func(ctx context.Context, _ struct{}) any { + // Local state for UI updates + count, setCount := vdom.UseState(ctx, 0) + + // UseState returns value, setter, and functional setter + seconds, setSeconds, setSecondsFn := vdom.UseState(ctx, 0) + + // Use refs to store complex state that goroutines need to access + stateRef := vdom.UseRef(ctx, &TimerState{ + done: make(chan bool), + }) + + // Example of safe goroutine management + startAsync := func() { + if stateRef.Current.isActive { + return // Prevent multiple goroutines + } + + stateRef.Current.isActive = true + go func() { + defer func() { + stateRef.Current.isActive = false + }() + + // Use channels for cleanup + for { + select { + case <-stateRef.Current.done: + return + case <-time.After(time.Second): + // Use functional updates for state that depends on current value + setSecondsFn(func(s int) int { + return s + 1 + }) + // Notify UI of update + app.SendAsyncInitiation() + } + } + }() + } + + // Always clean up goroutines + stopAsync := func() { + if stateRef.Current.isActive { + close(stateRef.Current.done) + stateRef.Current.done = make(chan bool) + } + } + + // Use UseEffect for cleanup on unmount + vdom.UseEffect(ctx, func() func() { + return func() { + stopAsync() + } + }, []any{}) + + return vdom.H("div", nil, + vdom.ForEach(globalTodos, func(todo Todo) any { + return TodoItem(TodoItemProps{Todo: todo}) + }), + ) + }, +) +``` + +Key points for state management: + +- Global state is fine for simple data structures +- Use functional setter when updating state based on its current value, especially in goroutines +- Store complex state in refs when it needs to be accessed by goroutines +- Use `UseEffect` cleanup function to handle component unmount +- Call `SendAsyncInitiation()` after state changes in goroutines (consider round trip performance, so don't call at very high speeds) +- Use atomic operations if globals are modified from multiple goroutines (or locks) + +## Global Keyboard Handling + +The Tsunami framework provides two approaches for handling keyboard events: + +1. Standard DOM event handling on elements: + +```go +vdom.H("div", map[string]any{ + "onKeyDown": func(e vdom.VDomEvent) { + // Handle key event + }, +}) +``` + +2. Global keyboard event handling: + +```go +func init() { + app.SetAppOpts(app.AppOpts{ + CloseOnCtrlC: true, + GlobalKeyboardEvents: true, // Enable global keyboard events + Title: "My Tsunami App", + }) +} + +// In main() or an effect: +app.SetGlobalEventHandler(func(event vdom.VDomEvent) { + if event.EventType != "onKeyDown" || event.KeyData == nil { + return + } + + switch event.KeyData.Key { + case "ArrowUp": + // Handle up arrow + case "ArrowDown": + // Handle down arrow + } +}) +``` + +The global handler approach is particularly useful when: + +- You need to handle keyboard events regardless of focus state +- Building terminal-like applications that need consistent keyboard control +- Implementing application-wide keyboard shortcuts +- Managing navigation in full-screen applications + +Key differences: + +- Standard DOM events require the element to have focus +- Global events work regardless of focus state +- Global events can be used alongside regular DOM event handlers +- Global handler receives all keyboard events for the application + +The event handler receives a VDomEvent with KeyData for keyboard events: + +```go +type VDomEvent struct { + EventType string // e.g., "onKeyDown" + KeyData *WaveKeyboardEvent `json:"keydata,omitempty"` + // ... other fields +} + +type WaveKeyboardEvent struct { + Type string // "keydown", "keyup", "keypress" + Key string // The key value (e.g., "ArrowUp") + Code string // Physical key code + Shift bool // Modifier states + Control bool + Alt bool + Meta bool + Cmd bool // Meta on Mac, Alt on Windows/Linux + Option bool // Alt on Mac, Meta on Windows/Linux +} +``` + +When using global keyboard events, remember to: + +1. Enable GlobalKeyboardEvents in AppOpts +2. Set up the handler in a place where you have access to necessary state updates + +## File Handling + +The Tsunami framework can serve files to components. Any URL starting with `vdom://` will be handled by the registered handlers: + +```go +// Register handlers for files (in the main func) +app.RegisterFileHandler("/logo.png", app.FileHandlerOption{ + FilePath: "./assets/logo.png", +}) + +// returning nil will produce a 404, path will be the full path, including the prefix +app.RegisterFilePrefixHandler("/img/", func(path string) (*app.FileHandlerOption, error) { + return &app.FileHandlerOption{Data: data, MimeType: "image/png"} +}) + +// Use in components with vdom:// prefix +vdom.H("img", map[string]any{ + "src": "vdom:///logo.png", // Note the vdom:// prefix + "alt": "Logo", + "style": map[string]any{ + "background": "url(vdom:///logo.png)", // vdom urls can be used in CSS as well + }, +}) +``` + +Files can come from: + +- Disk (FilePath) +- Memory (Data + MimeType) +- Stream (Reader) + +``` +type FileHandlerOption struct { + FilePath string // optional file path on disk + Data []byte // optional byte slice content (easy to use with go:embed) + Reader io.Reader // optional reader for content + File fs.File // optional embedded or opened file + MimeType string // optional mime type + ETag string // optional ETag (if set, resource may be cached) +} +``` + +Any URL passed to src attributes that starts with vdom:// will be handled by the registered handlers. All registered paths should be absolute (start with "/"). + +Note that the system will attempt to detect the file type using the first 512 bytes of content. This works great for images, videos, and binary files. For text files which might be ambiguous (CSS, JSON, YAML, TOML, JavaScript, Go Code, Java, other programming languages) it can make sense to specify the mimetype (but usually only required if the frontend needs it for some reason). + +By default, files will not be cached. If you'd like to enable caching, pass an ETag. If a subsequent request comes and the ETag matches the system will used the cached content. + +## Tsunami App Template + +```go +package main + +import ( + "context" + _ "embed" + "strconv" + + "github.com/wavetermdev/waveterm/tsunami/app" + "github.com/wavetermdev/waveterm/tsunami/vdom" +) + +func init() { + // Set up the default client with Tailwind styles and ctrl-c handling + app.SetAppOpts(app.AppOpts{ + CloseOnCtrlC: true, + Title: "My Tsunami App", + }) +} + +// Basic domain types with json tags for props +type Todo struct { + Id int `json:"id"` + Text string `json:"text"` + Completed bool `json:"completed"` +} + +type TodoItemProps struct { + Todo Todo `json:"todo"` + OnToggle func() `json:"onToggle"` + OnDelete func() `json:"onDelete"` +} + +// Reusable components +var TodoItem = app.DefineComponent("TodoItem", + func(ctx context.Context, props TodoItemProps) any { + return vdom.H("div", map[string]any{ + "className": vdom.Classes("flex items-center gap-2.5 p-2 border border-border rounded", vdom.If(props.Todo.Completed, "opacity-70")), + }, + vdom.H("input", map[string]any{ + "className": "w-4 h-4", + "type": "checkbox", + "checked": props.Todo.Completed, + "onChange": props.OnToggle, + }), + vdom.H("span", map[string]any{ + "className": vdom.Classes("flex-1", vdom.If(props.Todo.Completed, "line-through")), + }, props.Todo.Text), + vdom.H("button", map[string]any{ + "className": "text-red-500 cursor-pointer px-2 py-1 rounded", + "onClick": props.OnDelete, + }, "×"), + ) + }, +) + +// Root component must be named "App" +var App = app.DefineComponent("App", + func(ctx context.Context, _ any) any { + // UseState returns 3 values: value, setter, functional setter + // Use ", _" to ignore the functional setter when not needed + todos, setTodos, _ := vdom.UseState(ctx, []Todo{ + {Id: 1, Text: "Learn Tsunami", Completed: false}, + {Id: 2, Text: "Build an app", Completed: false}, + }) + nextId, setNextId, _ := vdom.UseState(ctx, 3) + inputText, setInputText, _ := vdom.UseState(ctx, "") + + // Event handlers + addTodo := func() { + if inputText == "" { + return + } + setTodos(append(todos, Todo{ + Id: nextId, + Text: inputText, + Completed: false, + })) + setNextId(nextId + 1) + setInputText("") + } + + toggleTodo := func(id int) { + newTodos := make([]Todo, len(todos)) + copy(newTodos, todos) + for i := range newTodos { + if newTodos[i].Id == id { + newTodos[i].Completed = !newTodos[i].Completed + break + } + } + setTodos(newTodos) + } + + deleteTodo := func(id int) { + newTodos := vdom.Filter(todos, func(todo Todo) bool { + return todo.Id != id + }) + setTodos(newTodos) + } + + return vdom.H("div", map[string]any{ + "className": "max-w-[500px] m-5 font-sans", + }, + vdom.H("h1", map[string]any{ + "className": "text-2xl font-bold mb-5", + }, "My Tsunami App"), + + vdom.H("div", map[string]any{ + "className": "flex gap-2.5 mb-5", + }, + vdom.H("input", map[string]any{ + "className": "flex-1 p-2 border border-border rounded", + "type": "text", + "placeholder": "Add new item...", + "value": inputText, + "onChange": func(e vdom.VDomEvent) { + setInputText(e.TargetValue) + }, + }), + vdom.H("button", map[string]any{ + "className": "px-4 py-2 border border-border rounded cursor-pointer", + "onClick": addTodo, + }, "Add"), + ), + + vdom.H("div", map[string]any{ + "className": "flex flex-col gap-2", + }, vdom.ForEach(todos, func(todo Todo) any { + return TodoItem(TodoItemProps{ + Todo: todo, + OnToggle: func() { toggleTodo(todo.Id) }, + OnDelete: func() { deleteTodo(todo.Id) }, + }).WithKey(strconv.Itoa(todo.Id)) + })), + ) + }, +) + +``` + +Key points: + +1. Root component must be named "App" +2. Use `init()` function to configure app options +3. No main() function is needed - the framework handles app lifecycle +4. File handlers can be registered in init() if needed + +``` +type AppOpts struct { + Title string // window title + CloseOnCtrlC bool + GlobalKeyboardEvents bool +} +``` + +## Important Technical Details + +- Props must be defined as Go structs with json tags +- Components take their props type directly: `func MyComponent(ctx context.Context, props MyProps) any` +- Always use app.DefineComponent for component registration +- Call app.SendAsyncInitiation() after async state updates +- Provide keys when using ForEach() with lists (using WithKey() method) +- Use Classes() with If() for combining static and conditional class names +- Consider cleanup functions in UseEffect() for async operations +-