tsunami framework (waveapps v2) (#2315)

Huge PR.  135 commits here to rebuild waveapps into the "Tsunami" framework.

* Simplified API
* Updated system.md prompt
* Basic applications building and running
* /api/config and /api/data support
* tailwind styling
* no need for async updates
* goroutine/timer primitives for async routing handling
* POC for integrating 3rd party react frameworks (recharts)
* POC for server side components (table.go)
* POC for interacting with apps via /api/config (tsunamiconfig)

Checkpoint.  Still needs to be tightly integrated with Wave (lifecycle, AI interaction, etc.) but looking very promising 🚀
This commit is contained in:
Mike Sawka 2025-09-11 14:25:07 -07:00 committed by GitHub
parent fb30d7fff3
commit e7cd584659
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 21457 additions and 17 deletions

View file

@ -59,6 +59,7 @@
"gopls": {
"analyses": {
"QF1003": false
}
},
"directoryFilters": ["-tsunami/frontend/scaffold"]
}
}

View file

@ -438,3 +438,90 @@ 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/*.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
tsunami:frontend:build:
desc: Build the tsunami frontend
cmd: yarn build
dir: tsunami/frontend
tsunami:frontend:devbuild:
desc: Build the tsunami frontend in development mode (with source maps and symbols)
cmd: yarn build:dev
dir: tsunami/frontend
tsunami:scaffold:
desc: Build scaffold for tsunami frontend development
deps:
- tsunami:frontend:build
cmds:
- task: tsunami:scaffold:internal
tsunami:devscaffold:
desc: Build scaffold for tsunami frontend development (with source maps and symbols)
deps:
- tsunami:frontend:devbuild
cmds:
- task: tsunami:scaffold:internal
tsunami:scaffold:internal:
desc: Internal task to create scaffold directory structure
dir: tsunami/frontend
internal: true
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:
- 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}}"
tsunami:clean:
desc: Clean tsunami frontend build artifacts
dir: tsunami/frontend
cmds:
- cmd: "{{.RMRF}} dist"
ignore_error: true
- cmd: "{{.RMRF}} scaffold"
ignore_error: true
godoc:
desc: Start the Go documentation server for the root module
cmd: $(go env GOPATH)/bin/pkgsite -http=:6060
tsunami:godoc:
desc: Start the Go documentation server for the tsunami module
cmd: $(go env GOPATH)/bin/pkgsite -http=:6060
dir: tsunami

View file

@ -1,8 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.view-vdom {
overflow: auto;
width: 100%;
min-height: 100%;
}

View file

@ -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 (
<div className={clsx("view-vdom", vdomClass)} ref={viewRef}>
<div className={clsx("overflow-auto w-full min-h-full", vdomClass)} ref={viewRef}>
{contextActive ? <VDomInnerView blockId={blockId} model={model} /> : null}
</div>
);

View file

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

2
go.mod
View file

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

View file

@ -172,6 +172,7 @@
},
"packageManager": "yarn@4.6.0",
"workspaces": [
"docs"
"docs",
"tsunami/frontend"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

1
tsunami/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
bin/

123
tsunami/app/atom.go Normal file
View file

@ -0,0 +1,123 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package app
import (
"log"
"reflect"
"runtime"
"github.com/wavetermdev/waveterm/tsunami/engine"
"github.com/wavetermdev/waveterm/tsunami/util"
)
// logInvalidAtomSet logs an error when an atom is being set during component render
func logInvalidAtomSet(atomName string) {
_, file, line, ok := runtime.Caller(2)
if ok {
log.Printf("invalid Set of atom '%s' in component render function at %s:%d", atomName, file, line)
} else {
log.Printf("invalid Set of atom '%s' in component render function", atomName)
}
}
// sameRef returns true if oldVal and newVal share the same underlying reference
// (pointer, map, or slice). Nil values return false.
func sameRef[T any](oldVal, newVal T) bool {
vOld := reflect.ValueOf(oldVal)
vNew := reflect.ValueOf(newVal)
if !vOld.IsValid() || !vNew.IsValid() {
return false
}
switch vNew.Kind() {
case reflect.Ptr:
// direct comparison works for *T
return any(oldVal) == any(newVal)
case reflect.Map, reflect.Slice:
if vOld.Kind() != vNew.Kind() || vOld.IsZero() || vNew.IsZero() {
return false
}
return vOld.Pointer() == vNew.Pointer()
}
// primitives, structs, etc. → not a reference type
return false
}
// logMutationWarning logs a warning when mutation is detected
func logMutationWarning(atomName string) {
_, file, line, ok := runtime.Caller(2)
if ok {
log.Printf("WARNING: atom '%s' appears to be mutated instead of copied at %s:%d - use app.DeepCopy to create a copy before mutating", atomName, file, line)
} else {
log.Printf("WARNING: atom '%s' appears to be mutated instead of copied - use app.DeepCopy to create a copy before mutating", atomName)
}
}
// Atom[T] represents a typed atom implementation
type Atom[T any] struct {
name string
client *engine.ClientImpl
}
// AtomName implements the vdom.Atom interface
func (a Atom[T]) AtomName() string {
return a.name
}
// Get returns the current value of the atom. When called during component render,
// it automatically registers the component as a dependency for this atom, ensuring
// the component re-renders when the atom value changes.
func (a Atom[T]) Get() T {
vc := engine.GetGlobalRenderContext()
if vc != nil {
vc.UsedAtoms[a.name] = true
}
val := a.client.Root.GetAtomVal(a.name)
typedVal := util.GetTypedAtomValue[T](val, a.name)
return typedVal
}
// Set updates the atom's value to the provided new value and triggers re-rendering
// of any components that depend on this atom. This method cannot be called during
// render cycles - use effects or event handlers instead.
func (a Atom[T]) Set(newVal T) {
vc := engine.GetGlobalRenderContext()
if vc != nil {
logInvalidAtomSet(a.name)
return
}
// Check for potential mutation bugs with reference types
currentVal := a.client.Root.GetAtomVal(a.name)
currentTyped := util.GetTypedAtomValue[T](currentVal, a.name)
if sameRef(currentTyped, newVal) {
logMutationWarning(a.name)
}
if err := a.client.Root.SetAtomVal(a.name, newVal); err != nil {
log.Printf("Failed to set atom value for %s: %v", a.name, err)
return
}
a.client.Root.AtomAddRenderWork(a.name)
}
// SetFn updates the atom's value by applying the provided function to the current value.
// The function receives a copy of the current atom value, which can be safely mutated
// without affecting the original data. The return value from the function becomes the
// new atom value. This method cannot be called during render cycles.
func (a Atom[T]) SetFn(fn func(T) T) {
vc := engine.GetGlobalRenderContext()
if vc != nil {
logInvalidAtomSet(a.name)
return
}
currentVal := a.Get()
copiedVal := DeepCopy(currentVal)
newVal := fn(copiedVal)
a.Set(newVal)
}

View file

@ -0,0 +1,110 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package app
import (
"encoding/json"
"io/fs"
"net/http"
"github.com/wavetermdev/waveterm/tsunami/engine"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
func DefineComponent[P any](name string, renderFn func(props P) any) vdom.Component[P] {
return engine.DefineComponentEx(engine.GetDefaultClient(), name, renderFn)
}
func SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
engine.GetDefaultClient().SetGlobalEventHandler(handler)
}
// RegisterSetupFn registers a single setup function that is called before the app starts running.
// Only one setup function is allowed, so calling this will replace any previously registered
// setup function.
func RegisterSetupFn(fn func()) {
engine.GetDefaultClient().RegisterSetupFn(fn)
}
// SendAsyncInitiation notifies the frontend that the backend has updated state
// and requires a re-render. Normally the frontend calls the backend in response
// to events, but when the backend changes state independently (e.g., from a
// background process), this function gives the frontend a "nudge" to update.
func SendAsyncInitiation() error {
return engine.GetDefaultClient().SendAsyncInitiation()
}
func ConfigAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$config." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func DataAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$data." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
func SharedAtom[T any](name string, defaultValue T) Atom[T] {
fullName := "$shared." + name
client := engine.GetDefaultClient()
atom := engine.MakeAtomImpl(defaultValue)
client.Root.RegisterAtom(fullName, atom)
return Atom[T]{name: fullName, client: client}
}
// HandleDynFunc registers a dynamic HTTP handler function with the internal http.ServeMux.
// The pattern MUST start with "/dyn/" to be valid. This allows registration of dynamic
// routes that can be handled at runtime.
func HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {
engine.GetDefaultClient().HandleDynFunc(pattern, fn)
}
// RunMain is used internally by generated code and should not be called directly.
func RunMain() {
engine.GetDefaultClient().RunMain()
}
// RegisterEmbeds is used internally by generated code and should not be called directly.
func RegisterEmbeds(assetsFilesystem fs.FS, staticFilesystem fs.FS, manifest []byte) {
client := engine.GetDefaultClient()
client.AssetsFS = assetsFilesystem
client.StaticFS = staticFilesystem
client.ManifestFileBytes = manifest
}
// DeepCopy creates a deep copy of the input value using JSON marshal/unmarshal.
// Panics on JSON errors.
func DeepCopy[T any](v T) T {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
var result T
err = json.Unmarshal(data, &result)
if err != nil {
panic(err)
}
return result
}
// QueueRefOp queues a reference operation to be executed on the DOM element.
// Operations include actions like "focus", "scrollIntoView", etc.
// If the ref is nil or not current, the operation is ignored.
// This function must be called within a component context.
func QueueRefOp(ref *vdom.VDomRef, op vdom.VDomRefOperation) {
if ref == nil || !ref.HasCurrent {
return
}
if op.RefId == "" {
op.RefId = ref.RefId
}
client := engine.GetDefaultClient()
client.Root.QueueRefOp(op)
}

198
tsunami/app/hooks.go Normal file
View file

@ -0,0 +1,198 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package app
import (
"context"
"fmt"
"time"
"github.com/wavetermdev/waveterm/tsunami/engine"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// UseVDomRef provides a reference to a DOM element in the VDOM tree.
// It returns a VDomRef that can be attached to elements for direct DOM access.
// The ref will not be current on the first render - refs are set and become
// current after client-side mounting.
// This hook must be called within a component context.
func UseVDomRef() *vdom.VDomRef {
rc := engine.GetGlobalRenderContext()
val := engine.UseVDomRef(rc)
refVal, ok := val.(*vdom.VDomRef)
if !ok {
panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)")
}
return refVal
}
// UseRef is the tsunami analog to React's useRef hook.
// It provides a mutable ref object that persists across re-renders.
// Unlike UseVDomRef, this is not tied to DOM elements but holds arbitrary values.
// This hook must be called within a component context.
func UseRef[T any](val T) *vdom.VDomSimpleRef[T] {
rc := engine.GetGlobalRenderContext()
refVal := engine.UseRef(rc, &vdom.VDomSimpleRef[T]{Current: val})
typedRef, ok := refVal.(*vdom.VDomSimpleRef[T])
if !ok {
panic("UseRef hook value is not a ref (possible out of order or conditional hooks)")
}
return typedRef
}
// UseId returns the underlying component's unique identifier (UUID).
// The ID persists across re-renders but is recreated when the component
// is recreated, following React component lifecycle.
// This hook must be called within a component context.
func UseId() string {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseId must be called within a component (no context)")
}
return engine.UseId(rc)
}
// UseRenderTs returns the timestamp of the current render.
// This hook must be called within a component context.
func UseRenderTs() int64 {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseRenderTs must be called within a component (no context)")
}
return engine.UseRenderTs(rc)
}
// UseResync returns whether the current render is a resync operation.
// Resyncs happen on initial app loads or full refreshes, as opposed to
// incremental renders which happen otherwise.
// This hook must be called within a component context.
func UseResync() bool {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseResync must be called within a component (no context)")
}
return engine.UseResync(rc)
}
// UseEffect is the tsunami analog to React's useEffect hook.
// It queues effects to run after the render cycle completes.
// The function can return a cleanup function that runs before the next effect
// or when the component unmounts. Dependencies use shallow comparison, just like React.
// This hook must be called within a component context.
func UseEffect(fn func() func(), deps []any) {
// note UseEffect never actually runs anything, it just queues the effect to run later
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseEffect must be called within a component (no context)")
}
engine.UseEffect(rc, fn, deps)
}
// UseSetAppTitle sets the application title for the current component.
// This hook must be called within a component context.
func UseSetAppTitle(title string) {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseSetAppTitle must be called within a component (no context)")
}
engine.UseSetAppTitle(rc, title)
}
// UseLocal creates a component-local atom that is automatically cleaned up when the component unmounts.
// The atom is created with a unique name based on the component's wave ID and hook index.
// This hook must be called within a component context.
func UseLocal[T any](initialVal T) Atom[T] {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseLocal must be called within a component (no context)")
}
atomName := engine.UseLocal(rc, initialVal)
return Atom[T]{
name: atomName,
client: engine.GetDefaultClient(),
}
}
// UseGoRoutine manages a goroutine lifecycle within a component.
// It spawns a new goroutine with the provided function when dependencies change,
// and automatically cancels the context on dependency changes or component unmount.
// This hook must be called within a component context.
func UseGoRoutine(fn func(ctx context.Context), deps []any) {
rc := engine.GetGlobalRenderContext()
if rc == nil {
panic("UseGoRoutine must be called within a component (no context)")
}
// Use UseRef to store the cancel function
cancelRef := UseRef[context.CancelFunc](nil)
UseEffect(func() func() {
// Cancel any existing goroutine
if cancelRef.Current != nil {
cancelRef.Current()
}
// Create new context and start goroutine
ctx, cancel := context.WithCancel(context.Background())
cancelRef.Current = cancel
componentName := "unknown"
if rc.Comp != nil && rc.Comp.Elem != nil {
componentName = rc.Comp.Elem.Tag
}
go func() {
defer func() {
util.PanicHandler(fmt.Sprintf("UseGoRoutine in component '%s'", componentName), recover())
}()
fn(ctx)
}()
// Return cleanup function that cancels the context
return func() {
if cancel != nil {
cancel()
}
}
}, deps)
}
// UseTicker manages a ticker lifecycle within a component.
// It creates a ticker that calls the provided function at regular intervals.
// The ticker is automatically stopped on dependency changes or component unmount.
// This hook must be called within a component context.
func UseTicker(interval time.Duration, tickFn func(), deps []any) {
UseGoRoutine(func(ctx context.Context) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tickFn()
}
}
}, deps)
}
// UseAfter manages a timeout lifecycle within a component.
// It creates a timer that calls the provided function after the specified duration.
// The timer is automatically canceled on dependency changes or component unmount.
// This hook must be called within a component context.
func UseAfter(duration time.Duration, timeoutFn func(), deps []any) {
UseGoRoutine(func(ctx context.Context) {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
timeoutFn()
}
}, deps)
}

775
tsunami/build/build.go Normal file
View file

@ -0,0 +1,775 @@
package build
import (
"bufio"
"fmt"
"go/parser"
"go/token"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/wavetermdev/waveterm/tsunami/util"
"golang.org/x/mod/modfile"
)
const MinSupportedGoMinorVersion = 22
const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui"
type BuildOpts struct {
Dir string
Verbose bool
Open bool
KeepTemp bool
OutputFile string
ScaffoldPath string
SdkReplacePath string
}
type BuildEnv struct {
GoVersion string
TempDir string
cleanupOnce *sync.Once
}
func findGoExecutable() (string, error) {
// First try the standard PATH lookup
if goPath, err := exec.LookPath("go"); err == nil {
return goPath, nil
}
// Define platform-specific paths to check
var pathsToCheck []string
if runtime.GOOS == "windows" {
pathsToCheck = []string{
`c:\go\bin\go.exe`,
`c:\program files\go\bin\go.exe`,
}
} else {
// Unix-like systems (macOS, Linux, etc.)
pathsToCheck = []string{
"/opt/homebrew/bin/go", // Homebrew on Apple Silicon
"/usr/local/bin/go", // Traditional Homebrew or manual install
"/usr/local/go/bin/go", // Official Go installation
"/usr/bin/go", // System package manager
}
}
// Check each path
for _, path := range pathsToCheck {
if _, err := os.Stat(path); err == nil {
// File exists, check if it's executable
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path, nil
}
}
}
return "", fmt.Errorf("go command not found in PATH or common installation locations")
}
func verifyEnvironment(verbose bool) (*BuildEnv, error) {
// Find Go executable using enhanced search
goPath, err := findGoExecutable()
if err != nil {
return nil, fmt.Errorf("go command not found: %w", err)
}
// Run go version command
cmd := exec.Command(goPath, "version")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run 'go version': %w", err)
}
// Parse go version output and check for 1.22+
versionStr := strings.TrimSpace(string(output))
if verbose {
log.Printf("Found %s", versionStr)
}
// Extract version like "go1.22.0" from output
versionRegex := regexp.MustCompile(`go(1\.\d+)`)
matches := versionRegex.FindStringSubmatch(versionStr)
if len(matches) < 2 {
return nil, fmt.Errorf("unable to parse go version from: %s", versionStr)
}
goVersion := matches[1]
// Check if version is 1.22+
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(minorMatches[1])
if err != nil || minor < MinSupportedGoMinorVersion {
return nil, fmt.Errorf("go version 1.%d or higher required, found: %s", MinSupportedGoMinorVersion, versionStr)
}
// Check if npx is in PATH
_, err = exec.LookPath("npx")
if err != nil {
return nil, 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 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 nil, 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+)`)
tailwindMatches := tailwindRegex.FindStringSubmatch(firstLine)
if len(tailwindMatches) < 2 {
return nil, fmt.Errorf("unable to parse tailwindcss version from: %s", firstLine)
}
majorVersion, err := strconv.Atoi(tailwindMatches[1])
if err != nil || majorVersion != 4 {
return nil, fmt.Errorf("tailwindcss v4 required, found: %s", firstLine)
}
return &BuildEnv{
GoVersion: goVersion,
cleanupOnce: &sync.Once{},
}, nil
}
func createGoMod(tempDir, appDirName, goVersion string, opts BuildOpts, verbose bool) error {
modulePath := fmt.Sprintf("tsunami/app/%s", appDirName)
// Check if go.mod already exists in original directory
originalGoModPath := filepath.Join(opts.Dir, "go.mod")
var modFile *modfile.File
var err error
if _, err := os.Stat(originalGoModPath); err == nil {
// go.mod exists, copy and parse it
if verbose {
log.Printf("Found existing go.mod, copying from %s", originalGoModPath)
}
// Copy existing go.mod to temp directory
tempGoModPath := filepath.Join(tempDir, "go.mod")
if err := copyFile(originalGoModPath, tempGoModPath); err != nil {
return fmt.Errorf("failed to copy existing go.mod: %w", err)
}
// Also copy go.sum if it exists
originalGoSumPath := filepath.Join(opts.Dir, "go.sum")
if _, err := os.Stat(originalGoSumPath); err == nil {
tempGoSumPath := filepath.Join(tempDir, "go.sum")
if err := copyFile(originalGoSumPath, tempGoSumPath); err != nil {
return fmt.Errorf("failed to copy existing go.sum: %w", err)
}
if verbose {
log.Printf("Found and copied existing go.sum from %s", originalGoSumPath)
}
}
// Parse the existing go.mod
goModContent, err := os.ReadFile(tempGoModPath)
if err != nil {
return fmt.Errorf("failed to read copied go.mod: %w", err)
}
modFile, err = modfile.Parse("go.mod", goModContent, nil)
if err != nil {
return fmt.Errorf("failed to parse existing go.mod: %w", err)
}
} else if os.IsNotExist(err) {
// go.mod doesn't exist, create new one
if verbose {
log.Printf("No existing go.mod found, creating new one")
}
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)
}
} else {
return fmt.Errorf("error checking for existing go.mod: %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")
tidyCmd.Stdout = os.Stdout
tidyCmd.Stderr = os.Stderr
}
if err := tidyCmd.Run(); err != nil {
return fmt.Errorf("failed to run go mod tidy: %w", err)
}
if verbose {
log.Printf("Successfully ran go mod tidy")
}
return nil
}
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 verifyScaffoldPath(scaffoldPath string) error {
if scaffoldPath == "" {
return fmt.Errorf("scaffoldPath cannot be empty")
}
// Check if directory exists
info, err := os.Stat(scaffoldPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("scaffoldPath directory %q does not exist", scaffoldPath)
}
return fmt.Errorf("error accessing scaffoldPath directory %q: %w", scaffoldPath, err)
}
if !info.IsDir() {
return fmt.Errorf("scaffoldPath %q is not a directory", scaffoldPath)
}
// 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 tailwind.css file
tailwindPath := filepath.Join(scaffoldPath, "tailwind.css")
if err := CheckFileExists(tailwindPath); err != nil {
return fmt.Errorf("tailwind.css check failed in scaffoldPath %q: %w", scaffoldPath, 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
}
func buildImportsMap(dir string) (map[string]bool, error) {
imports := make(map[string]bool)
files, err := filepath.Glob(filepath.Join(dir, "*.go"))
if err != nil {
return nil, fmt.Errorf("failed to list go files: %w", err)
}
fset := token.NewFileSet()
for _, file := range files {
node, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
continue // Skip files that can't be parsed
}
for _, imp := range node.Imports {
// Remove quotes from import path
importPath := strings.Trim(imp.Path.Value, `"`)
imports[importPath] = true
}
}
return imports, nil
}
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) {
buildEnv, err := verifyEnvironment(opts.Verbose)
if err != nil {
return nil, err
}
if err := verifyTsunamiDir(opts.Dir); err != nil {
return nil, err
}
if err := verifyScaffoldPath(opts.ScaffoldPath); err != nil {
return nil, err
}
// Create temporary directory
tempDir, err := os.MkdirTemp("", "tsunami-build-*")
if err != nil {
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 {
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 buildEnv, fmt.Errorf("failed to copy go files: %w", err)
}
// Copy static directory
staticSrcDir := filepath.Join(opts.Dir, "static")
staticDestDir := filepath.Join(tempDir, "static")
staticCount, err := copyDirRecursive(staticSrcDir, staticDestDir, true)
if err != nil {
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 buildEnv, fmt.Errorf("failed to copy scaffold directory: %w", err)
}
if opts.Verbose {
log.Printf("Copied %d go files, %d static files, %d scaffold files\n", goCount, staticCount, scaffoldCount)
}
// 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 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 buildEnv, fmt.Errorf("failed to create go.mod: %w", err)
}
// Build imports map from Go files
imports, err := buildImportsMap(tempDir)
if err != nil {
return buildEnv, fmt.Errorf("failed to build imports map: %w", err)
}
// Create symlink to SDK ui directory only if UI package is imported
if imports[TsunamiUIImportPath] {
uiLinkPath := filepath.Join(tempDir, "ui")
uiTargetPath := filepath.Join(opts.SdkReplacePath, "ui")
if err := os.Symlink(uiTargetPath, uiLinkPath); err != nil {
return buildEnv, fmt.Errorf("failed to create ui symlink: %w", err)
}
if opts.Verbose {
log.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath)
}
} else if opts.Verbose {
log.Printf("Skipping UI symlink creation - no UI package imports found")
}
// Generate Tailwind CSS
if err := generateAppTailwindCss(tempDir, opts.Verbose); err != nil {
return buildEnv, fmt.Errorf("failed to generate tailwind css: %w", err)
}
// Build the Go application
if err := runGoBuild(tempDir, opts); err != nil {
return buildEnv, fmt.Errorf("failed to build application: %w", err)
}
// Move generated files back to original directory
if err := moveFilesBack(tempDir, opts.Dir, opts.Verbose); err != nil {
return buildEnv, fmt.Errorf("failed to move files back: %w", err)
}
return buildEnv, nil
}
func moveFilesBack(tempDir, originalDir string, verbose bool) error {
// Move go.mod back to original directory
goModSrc := filepath.Join(tempDir, "go.mod")
goModDest := filepath.Join(originalDir, "go.mod")
if err := copyFile(goModSrc, goModDest); err != nil {
return fmt.Errorf("failed to copy go.mod back: %w", err)
}
if verbose {
log.Printf("Moved go.mod back to %s", goModDest)
}
// Move go.sum back to original directory (only if it exists)
goSumSrc := filepath.Join(tempDir, "go.sum")
if _, err := os.Stat(goSumSrc); err == nil {
goSumDest := filepath.Join(originalDir, "go.sum")
if err := copyFile(goSumSrc, goSumDest); err != nil {
return fmt.Errorf("failed to copy go.sum back: %w", err)
}
if verbose {
log.Printf("Moved go.sum back to %s", goSumDest)
}
}
// Ensure static directory exists in original directory
staticDir := filepath.Join(originalDir, "static")
if err := os.MkdirAll(staticDir, 0755); err != nil {
return fmt.Errorf("failed to create static directory: %w", err)
}
if verbose {
log.Printf("Ensured static directory exists at %s", staticDir)
}
// Move tw.css back to original directory
twCssSrc := filepath.Join(tempDir, "static", "tw.css")
twCssDest := filepath.Join(originalDir, "static", "tw.css")
if err := copyFile(twCssSrc, twCssDest); err != nil {
return fmt.Errorf("failed to copy tw.css back: %w", err)
}
if verbose {
log.Printf("Moved tw.css back to %s", twCssDest)
}
return nil
}
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)
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", outputPath}, goFiles...)
buildCmd := exec.Command("go", args...)
buildCmd.Dir = tempDir
if opts.Verbose {
log.Printf("Running: %s", strings.Join(buildCmd.Args, " "))
buildCmd.Stdout = os.Stdout
buildCmd.Stderr = os.Stderr
}
if err := buildCmd.Run(); err != nil {
return fmt.Errorf("failed to build application: %w", err)
}
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
}
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",
"-i", "./tailwind.css",
"-o", tailwindOutput)
tailwindCmd.Dir = tempDir
if verbose {
log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " "))
}
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
}
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 {
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")
runCmd := exec.Command(appPath)
runCmd.Dir = buildEnv.TempDir
log.Printf("Running tsunami app from %s", opts.Dir)
runCmd.Stdin = os.Stdin
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
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.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)
}
}
return nil
}
func monitorAndOpenBrowser(r io.ReadCloser, verbose bool) {
defer r.Close()
scanner := bufio.NewScanner(r)
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()
fmt.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
}
}
}

228
tsunami/build/buildutil.go Normal file
View file

@ -0,0 +1,228 @@
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, 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 {
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())
}
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
}
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
}

126
tsunami/cmd/main-tsunami.go Normal file
View file

@ -0,0 +1,126 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/tsunami/build"
"github.com/wavetermdev/waveterm/tsunami/tsunamibase"
)
const (
EnvTsunamiScaffoldPath = "TSUNAMI_SCAFFOLDPATH"
EnvTsunamiSdkReplacePath = "TSUNAMI_SDKREPLACEPATH"
)
// 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 validateEnvironmentVars(opts *build.BuildOpts) error {
scaffoldPath := os.Getenv(EnvTsunamiScaffoldPath)
if scaffoldPath == "" {
return fmt.Errorf("%s environment variable must be set", EnvTsunamiScaffoldPath)
}
sdkReplacePath := os.Getenv(EnvTsunamiSdkReplacePath)
if sdkReplacePath == "" {
return fmt.Errorf("%s environment variable must be set", EnvTsunamiSdkReplacePath)
}
opts.ScaffoldPath = scaffoldPath
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),
SilenceUsage: true,
Run: func(cmd *cobra.Command, args []string) {
verbose, _ := cmd.Flags().GetBool("verbose")
keepTemp, _ := cmd.Flags().GetBool("keeptemp")
output, _ := cmd.Flags().GetString("output")
opts := build.BuildOpts{
Dir: args[0],
Verbose: verbose,
KeepTemp: keepTemp,
OutputFile: output,
}
if err := validateEnvironmentVars(&opts); err != nil {
fmt.Println(err)
os.Exit(1)
}
if err := build.TsunamiBuild(opts); err != nil {
fmt.Println(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),
SilenceUsage: true,
Run: func(cmd *cobra.Command, args []string) {
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,
KeepTemp: keepTemp,
}
if err := validateEnvironmentVars(&opts); err != nil {
fmt.Println(err)
os.Exit(1)
}
if err := build.TsunamiRun(opts); err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
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)
}
func main() {
tsunamibase.TsunamiVersion = TsunamiVersion
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

1
tsunami/demo/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
test/

View file

@ -0,0 +1,360 @@
package main
import (
"log"
"time"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Global atoms for config and data
var (
dataPointCountAtom = app.ConfigAtom("dataPointCount", 60)
cpuDataAtom = app.DataAtom("cpuData", func() []CPUDataPoint {
// Initialize with empty data points to maintain consistent chart size
dataPointCount := 60 // Default value for initialization
initialData := make([]CPUDataPoint, dataPointCount)
for i := range initialData {
initialData[i] = CPUDataPoint{
Time: 0,
CPUUsage: nil, // Use nil to represent empty slots
Timestamp: "",
}
}
return initialData
}())
)
type CPUDataPoint struct {
Time int64 `json:"time"` // Unix timestamp in seconds
CPUUsage *float64 `json:"cpuUsage"` // CPU usage percentage (nil for empty slots)
Timestamp string `json:"timestamp"` // Human readable timestamp
}
type StatsPanelProps struct {
Data []CPUDataPoint `json:"data"`
}
func collectCPUUsage() (float64, error) {
percentages, err := cpu.Percent(time.Second, false)
if err != nil {
return 0, err
}
if len(percentages) == 0 {
return 0, nil
}
return percentages[0], nil
}
func generateCPUDataPoint() CPUDataPoint {
now := time.Now()
cpuUsage, err := collectCPUUsage()
if err != nil {
log.Printf("Error collecting CPU usage: %v", err)
cpuUsage = 0
}
dataPoint := CPUDataPoint{
Time: now.Unix(),
CPUUsage: &cpuUsage, // Convert to pointer
Timestamp: now.Format("15:04:05"),
}
log.Printf("CPU Usage: %.2f%% at %s", cpuUsage, dataPoint.Timestamp)
return dataPoint
}
var StatsPanel = app.DefineComponent("StatsPanel", func(props StatsPanelProps) any {
var currentUsage float64
var avgUsage float64
var maxUsage float64
var validCount int
if len(props.Data) > 0 {
lastPoint := props.Data[len(props.Data)-1]
if lastPoint.CPUUsage != nil {
currentUsage = *lastPoint.CPUUsage
}
// Calculate average and max from non-nil values
total := 0.0
for _, point := range props.Data {
if point.CPUUsage != nil {
total += *point.CPUUsage
validCount++
if *point.CPUUsage > maxUsage {
maxUsage = *point.CPUUsage
}
}
}
if validCount > 0 {
avgUsage = total / float64(validCount)
}
}
return vdom.H("div", map[string]any{
"className": "bg-gray-800 rounded-lg p-4 mb-6",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-semibold text-white mb-3",
}, "CPU Statistics"),
vdom.H("div", map[string]any{
"className": "grid grid-cols-3 gap-4",
},
// Current Usage
vdom.H("div", map[string]any{
"className": "bg-gray-700 rounded p-3",
},
vdom.H("div", map[string]any{
"className": "text-sm text-gray-400 mb-1",
}, "Current"),
vdom.H("div", map[string]any{
"className": "text-2xl font-bold text-blue-400",
}, vdom.H("span", nil, int(currentUsage+0.5), "%")),
),
// Average Usage
vdom.H("div", map[string]any{
"className": "bg-gray-700 rounded p-3",
},
vdom.H("div", map[string]any{
"className": "text-sm text-gray-400 mb-1",
}, "Average"),
vdom.H("div", map[string]any{
"className": "text-2xl font-bold text-green-400",
}, vdom.H("span", nil, int(avgUsage+0.5), "%")),
),
// Max Usage
vdom.H("div", map[string]any{
"className": "bg-gray-700 rounded p-3",
},
vdom.H("div", map[string]any{
"className": "text-sm text-gray-400 mb-1",
}, "Peak"),
vdom.H("div", map[string]any{
"className": "text-2xl font-bold text-red-400",
}, vdom.H("span", nil, int(maxUsage+0.5), "%")),
),
),
)
},
)
var App = app.DefineComponent("App", func(_ struct{}) any {
app.UseSetAppTitle("CPU Usage Monitor")
// Use UseTicker for continuous CPU data collection - automatically cleaned up on unmount
app.UseTicker(time.Second, func() {
// Collect new CPU data point and shift the data window
newPoint := generateCPUDataPoint()
cpuDataAtom.SetFn(func(data []CPUDataPoint) []CPUDataPoint {
currentDataPointCount := dataPointCountAtom.Get()
// Ensure we have the right size array
if len(data) != currentDataPointCount {
// Resize array if config changed
resized := make([]CPUDataPoint, currentDataPointCount)
copyCount := currentDataPointCount
if len(data) < copyCount {
copyCount = len(data)
}
if copyCount > 0 {
copy(resized[currentDataPointCount-copyCount:], data[len(data)-copyCount:])
}
data = resized
}
// Append new point and keep only the last currentDataPointCount elements
data = append(data, newPoint)
if len(data) > currentDataPointCount {
data = data[len(data)-currentDataPointCount:]
}
return data
})
}, []any{})
handleClear := func() {
// Reset with empty data points based on current config
currentDataPointCount := dataPointCountAtom.Get()
initialData := make([]CPUDataPoint, currentDataPointCount)
for i := range initialData {
initialData[i] = CPUDataPoint{
Time: 0,
CPUUsage: nil,
Timestamp: "",
}
}
cpuDataAtom.Set(initialData)
}
// Read atom values once for rendering
cpuData := cpuDataAtom.Get()
dataPointCount := dataPointCountAtom.Get()
return vdom.H("div", map[string]any{
"className": "min-h-screen bg-gray-900 text-white p-6",
},
vdom.H("div", map[string]any{
"className": "max-w-6xl mx-auto",
},
// Header
vdom.H("div", map[string]any{
"className": "mb-8",
},
vdom.H("h1", map[string]any{
"className": "text-3xl font-bold text-white mb-2",
}, "Real-Time CPU Usage Monitor"),
vdom.H("p", map[string]any{
"className": "text-gray-400",
}, "Live CPU usage data collected using gopsutil, displaying 60 seconds of history"),
),
// Controls
vdom.H("div", map[string]any{
"className": "bg-gray-800 rounded-lg p-4 mb-6",
},
vdom.H("div", map[string]any{
"className": "flex items-center gap-4 flex-wrap",
},
// Clear button
vdom.H("button", map[string]any{
"className": "px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md text-sm font-medium transition-colors cursor-pointer",
"onClick": handleClear,
}, "Clear Data"),
// Status indicator
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("div", map[string]any{
"className": "w-2 h-2 rounded-full bg-green-500",
}),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, "Live Monitoring"),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-500 ml-2",
}, "(", len(cpuData), "/", dataPointCount, " data points)"),
),
),
),
// Statistics Panel
StatsPanel(StatsPanelProps{
Data: cpuData,
}),
// Main chart
vdom.H("div", map[string]any{
"className": "bg-gray-800 rounded-lg p-6 mb-6",
},
vdom.H("h2", map[string]any{
"className": "text-xl font-semibold text-white mb-4",
}, "CPU Usage Over Time"),
vdom.H("div", map[string]any{
"className": "w-full h-96",
},
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:LineChart", map[string]any{
"data": cpuData,
"isAnimationActive": false,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
"stroke": "#374151",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "timestamp",
"stroke": "#9CA3AF",
"fontSize": 12,
}),
vdom.H("recharts:YAxis", map[string]any{
"domain": []int{0, 100},
"stroke": "#9CA3AF",
"fontSize": 12,
}),
vdom.H("recharts:Tooltip", map[string]any{
"labelStyle": map[string]any{
"color": "#374151",
},
"contentStyle": map[string]any{
"backgroundColor": "#1F2937",
"border": "1px solid #374151",
"borderRadius": "6px",
"color": "#F3F4F6",
},
}),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "cpuUsage",
"stroke": "#3B82F6",
"strokeWidth": 2,
"dot": false,
"name": "CPU Usage (%)",
"isAnimationActive": false,
}),
),
),
),
),
// Info section
vdom.H("div", map[string]any{
"className": "bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-semibold text-blue-200 mb-2",
}, "Real-Time CPU Monitoring Features"),
vdom.H("ul", map[string]any{
"className": "space-y-2 text-blue-100",
},
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Live CPU usage data collected using github.com/shirou/gopsutil/v4",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Continuous monitoring with 1-second update intervals",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Rolling window of 60 seconds of historical data",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Real-time statistics: current, average, and peak usage",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Dark theme optimized for Wave Terminal",
),
),
),
),
)
},
)

View file

@ -0,0 +1,23 @@
module tsunami/app/cpuchart
go 1.24.6
require (
github.com/shirou/gopsutil/v4 v4.25.8
github.com/wavetermdev/waveterm/tsunami v0.0.0
)
require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sys v0.35.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,36 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,422 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"strconv"
"time"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Global atoms for config and data
var (
pollIntervalAtom = app.ConfigAtom("pollInterval", 5)
repositoryAtom = app.ConfigAtom("repository", "wavetermdev/waveterm")
workflowAtom = app.ConfigAtom("workflow", "build-helper.yml")
maxWorkflowRunsAtom = app.ConfigAtom("maxWorkflowRuns", 10)
workflowRunsAtom = app.DataAtom("workflowRuns", []WorkflowRun{})
lastErrorAtom = app.DataAtom("lastError", "")
isLoadingAtom = app.DataAtom("isLoading", true)
lastRefreshTimeAtom = app.DataAtom("lastRefreshTime", time.Time{})
)
type WorkflowRun struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
HTMLURL string `json:"html_url"`
RunNumber int `json:"run_number"`
}
type GitHubResponse struct {
TotalCount int `json:"total_count"`
WorkflowRuns []WorkflowRun `json:"workflow_runs"`
}
func fetchWorkflowRuns(repository, workflow string, maxRuns int) ([]WorkflowRun, error) {
apiKey := os.Getenv("GITHUB_APIKEY")
if apiKey == "" {
return nil, fmt.Errorf("GITHUB_APIKEY environment variable not set")
}
url := fmt.Sprintf("https://api.github.com/repos/%s/actions/workflows/%s/runs?per_page=%d", repository, workflow, maxRuns)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "WaveTerminal-GitHubMonitor")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var response GitHubResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return response.WorkflowRuns, nil
}
func getStatusIcon(status, conclusion string) string {
switch status {
case "in_progress", "queued", "pending":
return "🔄"
case "completed":
switch conclusion {
case "success":
return "✅"
case "failure":
return "❌"
case "cancelled":
return "🚫"
case "skipped":
return "⏭️"
default:
return "❓"
}
default:
return "❓"
}
}
func getStatusColor(status, conclusion string) string {
switch status {
case "in_progress", "queued", "pending":
return "text-yellow-400"
case "completed":
switch conclusion {
case "success":
return "text-green-400"
case "failure":
return "text-red-400"
case "cancelled":
return "text-gray-400"
case "skipped":
return "text-blue-400"
default:
return "text-gray-400"
}
default:
return "text-gray-400"
}
}
func formatDuration(start, end time.Time, isRunning bool) string {
if isRunning {
duration := time.Since(start)
return fmt.Sprintf("%v (running)", duration.Round(time.Second))
}
if end.IsZero() {
return "Unknown"
}
duration := end.Sub(start)
return duration.Round(time.Second).String()
}
func getDisplayStatus(status, conclusion string) string {
switch status {
case "in_progress":
return "Running"
case "queued":
return "Queued"
case "pending":
return "Pending"
case "completed":
switch conclusion {
case "success":
return "Success"
case "failure":
return "Failed"
case "cancelled":
return "Cancelled"
case "skipped":
return "Skipped"
default:
return "Completed"
}
default:
return status
}
}
type WorkflowRunItemProps struct {
Run WorkflowRun `json:"run"`
}
var WorkflowRunItem = app.DefineComponent("WorkflowRunItem",
func(props WorkflowRunItemProps) any {
run := props.Run
isRunning := run.Status == "in_progress" || run.Status == "queued" || run.Status == "pending"
return vdom.H("div", map[string]any{
"className": "bg-gray-800 rounded-lg p-4 border border-gray-700 hover:border-gray-600 transition-colors",
},
vdom.H("div", map[string]any{
"className": "flex items-start justify-between",
},
vdom.H("div", map[string]any{
"className": "flex-1 min-w-0",
},
vdom.H("div", map[string]any{
"className": "flex items-center gap-3 mb-2",
},
vdom.H("span", map[string]any{
"className": "text-2xl",
}, getStatusIcon(run.Status, run.Conclusion)),
vdom.H("a", map[string]any{
"href": run.HTMLURL,
"target": "_blank",
"className": "font-semibold text-blue-400 hover:text-blue-300 cursor-pointer",
}, run.Name),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-300",
}, "#", run.RunNumber),
),
vdom.H("div", map[string]any{
"className": "flex items-center gap-4 text-sm",
},
vdom.H("span", map[string]any{
"className": vdom.Classes("font-medium", getStatusColor(run.Status, run.Conclusion)),
}, getDisplayStatus(run.Status, run.Conclusion)),
vdom.H("span", map[string]any{
"className": "text-gray-400",
}, "Duration: ", formatDuration(run.CreatedAt, run.UpdatedAt, isRunning)),
vdom.H("span", map[string]any{
"className": "text-gray-300",
}, "Started: ", run.CreatedAt.Format("15:04:05")),
),
),
),
)
},
)
var App = app.DefineComponent("App",
func(_ struct{}) any {
app.UseSetAppTitle("GitHub Actions Monitor")
fetchData := func() {
currentMaxRuns := maxWorkflowRunsAtom.Get()
runs, err := fetchWorkflowRuns(repositoryAtom.Get(), workflowAtom.Get(), currentMaxRuns)
if err != nil {
log.Printf("Error fetching workflow runs: %v", err)
lastErrorAtom.Set(err.Error())
} else {
sort.Slice(runs, func(i, j int) bool {
return runs[i].CreatedAt.After(runs[j].CreatedAt)
})
workflowRunsAtom.Set(runs)
lastErrorAtom.Set("")
}
lastRefreshTimeAtom.Set(time.Now())
isLoadingAtom.Set(false)
}
// Initial fetch on mount
app.UseEffect(func() func() {
fetchData()
return nil
}, []any{})
// Automatic polling with UseTicker - automatically cleaned up on unmount
app.UseTicker(time.Duration(pollIntervalAtom.Get())*time.Second, func() {
fetchData()
}, []any{pollIntervalAtom.Get()})
handleRefresh := func() {
isLoadingAtom.Set(true)
go func() {
fetchData()
}()
}
workflowRuns := workflowRunsAtom.Get()
lastError := lastErrorAtom.Get()
isLoading := isLoadingAtom.Get()
lastRefreshTime := lastRefreshTimeAtom.Get()
pollInterval := pollIntervalAtom.Get()
repository := repositoryAtom.Get()
workflow := workflowAtom.Get()
maxWorkflowRuns := maxWorkflowRunsAtom.Get()
return vdom.H("div", map[string]any{
"className": "min-h-screen bg-gray-900 text-white p-6",
},
vdom.H("div", map[string]any{
"className": "max-w-6xl mx-auto",
},
vdom.H("div", map[string]any{
"className": "mb-8",
},
vdom.H("h1", map[string]any{
"className": "text-3xl font-bold text-white mb-2",
}, "GitHub Actions Monitor"),
vdom.H("p", map[string]any{
"className": "text-gray-400",
}, "Monitoring ", repositoryAtom.Get(), " ", workflowAtom.Get(), " workflow"),
),
vdom.H("div", map[string]any{
"className": "bg-gray-800 rounded-lg p-4 mb-6",
},
vdom.H("div", map[string]any{
"className": "flex items-center justify-between",
},
vdom.H("div", map[string]any{
"className": "flex items-center gap-4",
},
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-4 py-2 rounded-md text-sm font-medium transition-colors cursor-pointer",
vdom.IfElse(isLoadingAtom.Get(), "bg-gray-600 text-gray-400", "bg-blue-600 hover:bg-blue-700 text-white"),
),
"onClick": vdom.If(!isLoadingAtom.Get(), handleRefresh),
"disabled": isLoadingAtom.Get(),
}, vdom.IfElse(isLoadingAtom.Get(), "Refreshing...", "Refresh")),
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("div", map[string]any{
"className": vdom.Classes("w-2 h-2 rounded-full", vdom.IfElse(lastError == "", "bg-green-500", "bg-red-500")),
}),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, vdom.IfElse(lastError == "", "Connected", "Error")),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-300 ml-2",
}, "Poll interval: ", pollInterval, "s"),
vdom.If(!lastRefreshTime.IsZero(),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-300 ml-4",
}, "Last refresh: ", lastRefreshTime.Format("15:04:05")),
),
),
),
vdom.H("div", map[string]any{
"className": "text-sm text-gray-300",
}, "Last ", maxWorkflowRuns, " workflow runs"),
),
),
vdom.If(lastError != "",
vdom.H("div", map[string]any{
"className": "bg-red-900 bg-opacity-50 border border-red-700 rounded-lg p-4 mb-6",
},
vdom.H("div", map[string]any{
"className": "flex items-center gap-2 text-red-200",
},
vdom.H("span", nil, "❌"),
vdom.H("strong", nil, "Error:"),
),
vdom.H("p", map[string]any{
"className": "text-red-100 mt-1",
}, lastError),
),
),
vdom.H("div", map[string]any{
"className": "space-y-4",
},
vdom.If(isLoading && len(workflowRuns) == 0,
vdom.H("div", map[string]any{
"className": "text-center py-8 text-gray-400",
}, "Loading workflow runs..."),
),
vdom.If(len(workflowRuns) > 0,
vdom.ForEach(workflowRuns, func(run WorkflowRun, idx int) any {
return WorkflowRunItem(WorkflowRunItemProps{
Run: run,
}).WithKey(strconv.FormatInt(run.ID, 10))
}),
),
vdom.If(!isLoading && len(workflowRuns) == 0 && lastError == "",
vdom.H("div", map[string]any{
"className": "text-center py-8 text-gray-400",
}, "No workflow runs found"),
),
),
vdom.H("div", map[string]any{
"className": "mt-8 bg-blue-900 bg-opacity-50 border border-blue-700 rounded-lg p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-semibold text-blue-200 mb-2",
}, "GitHub Actions Monitor Features"),
vdom.H("ul", map[string]any{
"className": "space-y-2 text-blue-100",
},
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Monitors ", repository, " ", workflow, " workflow",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Polls GitHub API every 5 seconds for real-time updates",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Shows status icons: ✅ Success, ❌ Failure, 🔄 Running",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Clickable workflow names open in GitHub (new tab)",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-400 mt-1",
}, "•"),
"Live duration tracking for running jobs",
),
),
),
),
)
},
)

View file

@ -0,0 +1,12 @@
module tsunami/app/githubaction
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,204 @@
package main
import (
"fmt"
"time"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
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}
// Data atom to expose remaining seconds to external systems
remainingSecondsAtom = app.DataAtom("remainingSeconds", WorkMode.Duration*60)
)
type TimerDisplayProps struct {
RemainingSeconds int `json:"remainingSeconds"`
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"`
}
var TimerDisplay = app.DefineComponent("TimerDisplay",
func(props TimerDisplayProps) any {
minutes := props.RemainingSeconds / 60
seconds := props.RemainingSeconds % 60
return vdom.H("div",
map[string]any{"className": "bg-slate-700 p-8 rounded-lg mb-8 text-center"},
vdom.H("div",
map[string]any{"className": "text-xl text-blue-400 mb-2"},
props.Mode,
),
vdom.H("div",
map[string]any{"className": "text-6xl font-bold font-mono text-slate-100"},
fmt.Sprintf("%02d:%02d", minutes, seconds),
),
)
},
)
var ControlButtons = app.DefineComponent("ControlButtons",
func(props ControlButtonsProps) any {
return vdom.H("div",
map[string]any{"className": "flex flex-col gap-4"},
vdom.IfElse(props.IsRunning,
vdom.H("button",
map[string]any{
"className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
"onClick": props.OnPause,
},
"Pause",
),
vdom.H("button",
map[string]any{
"className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
"onClick": props.OnStart,
},
"Start",
),
),
vdom.H("button",
map[string]any{
"className": "px-6 py-3 text-lg border-none rounded bg-blue-500 text-white cursor-pointer hover:bg-blue-600 transition-colors duration-200",
"onClick": props.OnReset,
},
"Reset",
),
vdom.H("div",
map[string]any{"className": "flex gap-4 mt-4"},
vdom.H("button",
map[string]any{
"className": "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",
"onClick": func() { props.OnMode(WorkMode.Duration) },
},
"Work Mode",
),
vdom.H("button",
map[string]any{
"className": "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",
"onClick": func() { props.OnMode(BreakMode.Duration) },
},
"Break Mode",
),
),
)
},
)
var App = app.DefineComponent("App",
func(_ struct{}) any {
app.UseSetAppTitle("Pomodoro Timer (Tsunami Demo)")
isRunning := app.UseLocal(false)
mode := app.UseLocal(WorkMode.Name)
isComplete := app.UseLocal(false)
startTime := app.UseRef(time.Time{})
totalDuration := app.UseRef(time.Duration(0))
// Timer that updates every second using the new pattern
app.UseTicker(time.Second, func() {
if !isRunning.Get() {
return
}
elapsed := time.Since(startTime.Current)
remaining := totalDuration.Current - elapsed
if remaining <= 0 {
// Timer completed
isRunning.Set(false)
remainingSecondsAtom.Set(0)
isComplete.Set(true)
return
}
newSeconds := int(remaining.Seconds())
// Only send update if value actually changed
if newSeconds != remainingSecondsAtom.Get() {
remainingSecondsAtom.Set(newSeconds)
}
}, []any{isRunning.Get()})
startTimer := func() {
if isRunning.Get() {
return // Timer already running
}
isComplete.Set(false)
startTime.Current = time.Now()
totalDuration.Current = time.Duration(remainingSecondsAtom.Get()) * time.Second
isRunning.Set(true)
}
pauseTimer := func() {
if !isRunning.Get() {
return
}
// Calculate remaining time and update remainingSeconds
elapsed := time.Since(startTime.Current)
remaining := totalDuration.Current - elapsed
if remaining > 0 {
remainingSecondsAtom.Set(int(remaining.Seconds()))
}
isRunning.Set(false)
}
resetTimer := func() {
isRunning.Set(false)
isComplete.Set(false)
if mode.Get() == WorkMode.Name {
remainingSecondsAtom.Set(WorkMode.Duration * 60)
} else {
remainingSecondsAtom.Set(BreakMode.Duration * 60)
}
}
changeMode := func(duration int) {
isRunning.Set(false)
isComplete.Set(false)
remainingSecondsAtom.Set(duration * 60)
if duration == WorkMode.Duration {
mode.Set(WorkMode.Name)
} else {
mode.Set(BreakMode.Name)
}
}
return vdom.H("div",
map[string]any{"className": "max-w-sm mx-auto my-8 p-8 bg-slate-800 rounded-xl text-slate-100 font-sans"},
vdom.H("h1",
map[string]any{"className": "text-center text-slate-100 mb-8 text-3xl"},
"Pomodoro Timer",
),
TimerDisplay(TimerDisplayProps{
RemainingSeconds: remainingSecondsAtom.Get(),
Mode: mode.Get(),
}),
ControlButtons(ControlButtonsProps{
IsRunning: isRunning.Get(),
OnStart: startTimer,
OnPause: pauseTimer,
OnReset: resetTimer,
OnMode: changeMode,
}),
)
},
)

View file

@ -0,0 +1,12 @@
module tsunami/app/pomodoro
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,459 @@
package main
import (
"math"
"time"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Global atoms for config and data
var (
chartDataAtom = app.DataAtom("chartData", generateInitialData())
chartTypeAtom = app.ConfigAtom("chartType", "line")
isAnimatingAtom = app.SharedAtom("isAnimating", false)
)
type DataPoint struct {
Time int `json:"time"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
Disk float64 `json:"disk"`
}
func generateInitialData() []DataPoint {
data := make([]DataPoint, 20)
for i := 0; i < 20; i++ {
data[i] = DataPoint{
Time: i,
CPU: 50 + 30*math.Sin(float64(i)*0.3) + 10*math.Sin(float64(i)*0.7),
Mem: 40 + 25*math.Cos(float64(i)*0.4) + 15*math.Sin(float64(i)*0.9),
Disk: 30 + 20*math.Sin(float64(i)*0.2) + 10*math.Cos(float64(i)*1.1),
}
}
return data
}
func generateNewDataPoint(currentData []DataPoint) DataPoint {
lastTime := 0
if len(currentData) > 0 {
lastTime = currentData[len(currentData)-1].Time
}
newTime := lastTime + 1
return DataPoint{
Time: newTime,
CPU: 50 + 30*math.Sin(float64(newTime)*0.3) + 10*math.Sin(float64(newTime)*0.7),
Mem: 40 + 25*math.Cos(float64(newTime)*0.4) + 15*math.Sin(float64(newTime)*0.9),
Disk: 30 + 20*math.Sin(float64(newTime)*0.2) + 10*math.Cos(float64(newTime)*1.1),
}
}
var InfoSection = app.DefineComponent("InfoSection", func(_ struct{}) any {
return vdom.H("div", map[string]any{
"className": "bg-blue-50 border border-blue-200 rounded-lg p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-semibold text-blue-900 mb-2",
}, "Recharts Integration Features"),
vdom.H("ul", map[string]any{
"className": "space-y-2 text-blue-800",
},
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-500 mt-1",
}, "•"),
"Support for all major Recharts components (LineChart, AreaChart, BarChart, etc.)",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-500 mt-1",
}, "•"),
"Live data updates with animation support",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-500 mt-1",
}, "•"),
"Responsive containers that resize with the window",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-500 mt-1",
}, "•"),
"Full prop support for customization and styling",
),
vdom.H("li", map[string]any{
"className": "flex items-start gap-2",
},
vdom.H("span", map[string]any{
"className": "text-blue-500 mt-1",
}, "•"),
"Uses recharts: namespace to dispatch to the recharts handler",
),
),
)
},
)
type MiniChartsProps struct {
ChartData []DataPoint `json:"chartData"`
}
var MiniCharts = app.DefineComponent("MiniCharts",
func(props MiniChartsProps) any {
return vdom.H("div", map[string]any{
"className": "grid grid-cols-1 md:grid-cols-3 gap-6 mb-6",
},
// CPU Mini Chart
vdom.H("div", map[string]any{
"className": "bg-white rounded-lg shadow-sm border p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-medium text-gray-900 mb-3",
}, "CPU Usage"),
vdom.H("div", map[string]any{
"className": "h-32",
},
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:LineChart", map[string]any{
"data": props.ChartData,
},
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "cpu",
"stroke": "#8884d8",
"strokeWidth": 2,
"dot": false,
}),
),
),
),
),
// Memory Mini Chart
vdom.H("div", map[string]any{
"className": "bg-white rounded-lg shadow-sm border p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-medium text-gray-900 mb-3",
}, "Memory Usage"),
vdom.H("div", map[string]any{
"className": "h-32",
},
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:AreaChart", map[string]any{
"data": props.ChartData,
},
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "mem",
"stroke": "#82ca9d",
"fill": "#82ca9d",
}),
),
),
),
),
// Disk Mini Chart
vdom.H("div", map[string]any{
"className": "bg-white rounded-lg shadow-sm border p-4",
},
vdom.H("h3", map[string]any{
"className": "text-lg font-medium text-gray-900 mb-3",
}, "Disk Usage"),
vdom.H("div", map[string]any{
"className": "h-32",
},
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:BarChart", map[string]any{
"data": props.ChartData,
},
vdom.H("recharts:Bar", map[string]any{
"dataKey": "disk",
"fill": "#ffc658",
}),
),
),
),
),
)
},
)
var App = app.DefineComponent("App",
func(_ struct{}) any {
app.UseSetAppTitle("Recharts Demo")
tickerFn := func() {
if !isAnimatingAtom.Get() {
return
}
chartDataAtom.SetFn(func(currentData []DataPoint) []DataPoint {
newData := append(currentData, generateNewDataPoint(currentData))
if len(newData) > 20 {
newData = newData[1:]
}
return newData
})
}
app.UseTicker(time.Second, tickerFn, []any{})
handleStartStop := func() {
isAnimatingAtom.Set(!isAnimatingAtom.Get())
}
handleReset := func() {
chartDataAtom.Set(generateInitialData())
isAnimatingAtom.Set(false)
}
handleChartTypeChange := func(newType string) {
chartTypeAtom.Set(newType)
}
chartData := chartDataAtom.Get()
chartType := chartTypeAtom.Get()
isAnimating := isAnimatingAtom.Get()
return vdom.H("div", map[string]any{
"className": "min-h-screen bg-gray-50 p-6",
},
vdom.H("div", map[string]any{
"className": "max-w-6xl mx-auto",
},
// Header
vdom.H("div", map[string]any{
"className": "mb-8",
},
vdom.H("h1", map[string]any{
"className": "text-3xl font-bold text-gray-900 mb-2",
}, "Recharts Integration Demo"),
vdom.H("p", map[string]any{
"className": "text-gray-600",
}, "Demonstrating recharts components in Tsunami VDOM system"),
),
// Controls
vdom.H("div", map[string]any{
"className": "bg-white rounded-lg shadow-sm border p-4 mb-6",
},
vdom.H("div", map[string]any{
"className": "flex items-center gap-4 flex-wrap",
},
// Chart type selector
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("label", map[string]any{
"className": "text-sm font-medium text-gray-700",
}, "Chart Type:"),
vdom.H("select", map[string]any{
"className": "px-3 py-1 border border-gray-300 rounded-md text-sm bg-white text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
"value": chartType,
"onChange": func(e vdom.VDomEvent) {
handleChartTypeChange(e.TargetValue)
},
},
vdom.H("option", map[string]any{"value": "line"}, "Line Chart"),
vdom.H("option", map[string]any{"value": "area"}, "Area Chart"),
vdom.H("option", map[string]any{"value": "bar"}, "Bar Chart"),
),
),
// Animation controls
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
vdom.IfElse(isAnimating,
"bg-red-500 hover:bg-red-600 text-white",
"bg-green-500 hover:bg-green-600 text-white",
),
),
"onClick": handleStartStop,
}, vdom.IfElse(isAnimating, "Stop Animation", "Start Animation")),
vdom.H("button", map[string]any{
"className": "px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md text-sm font-medium transition-colors",
"onClick": handleReset,
}, "Reset Data"),
// Status indicator
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("div", map[string]any{
"className": vdom.Classes(
"w-2 h-2 rounded-full",
vdom.IfElse(isAnimating, "bg-green-500", "bg-gray-400"),
),
}),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-600",
}, vdom.IfElse(isAnimating, "Live Updates", "Static")),
),
),
),
// Main chart
vdom.H("div", map[string]any{
"className": "bg-white rounded-lg shadow-sm border p-6 mb-6",
},
vdom.H("h2", map[string]any{
"className": "text-xl font-semibold text-gray-900 mb-4",
}, "System Metrics Over Time"),
vdom.H("div", map[string]any{
"className": "w-full h-96",
},
// Main chart - switches based on chartType
vdom.IfElse(chartType == "line",
// Line Chart
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:LineChart", map[string]any{
"data": chartData,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Legend", nil),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "cpu",
"stroke": "#8884d8",
"name": "CPU %",
}),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "mem",
"stroke": "#82ca9d",
"name": "Memory %",
}),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "disk",
"stroke": "#ffc658",
"name": "Disk %",
}),
),
),
vdom.IfElse(chartType == "area",
// Area Chart
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:AreaChart", map[string]any{
"data": chartData,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Legend", nil),
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "cpu",
"stackId": "1",
"stroke": "#8884d8",
"fill": "#8884d8",
"name": "CPU %",
}),
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "mem",
"stackId": "1",
"stroke": "#82ca9d",
"fill": "#82ca9d",
"name": "Memory %",
}),
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "disk",
"stackId": "1",
"stroke": "#ffc658",
"fill": "#ffc658",
"name": "Disk %",
}),
),
),
// Bar Chart
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
vdom.H("recharts:BarChart", map[string]any{
"data": chartData,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Legend", nil),
vdom.H("recharts:Bar", map[string]any{
"dataKey": "cpu",
"fill": "#8884d8",
"name": "CPU %",
}),
vdom.H("recharts:Bar", map[string]any{
"dataKey": "mem",
"fill": "#82ca9d",
"name": "Memory %",
}),
vdom.H("recharts:Bar", map[string]any{
"dataKey": "disk",
"fill": "#ffc658",
"name": "Disk %",
}),
),
),
),
),
),
),
// Mini charts row
MiniCharts(MiniChartsProps{
ChartData: chartData,
}),
// Info section
InfoSection(struct{}{}),
),
)
},
)

View file

@ -0,0 +1,12 @@
module tsunami/app/recharts
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
package main
import (
"fmt"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/ui"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Sample data structure for the table
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Email string `json:"email"`
City string `json:"city"`
}
// Create the table component for Person data
var PersonTable = ui.MakeTableComponent[Person]("PersonTable")
// Sample data exposed as DataAtom for external system access
var sampleData = app.DataAtom("sampleData", []Person{
{Name: "Alice Johnson", Age: 28, Email: "alice@example.com", City: "New York"},
{Name: "Bob Smith", Age: 34, Email: "bob@example.com", City: "Los Angeles"},
{Name: "Carol Davis", Age: 22, Email: "carol@example.com", City: "Chicago"},
{Name: "David Wilson", Age: 41, Email: "david@example.com", City: "Houston"},
{Name: "Eve Brown", Age: 29, Email: "eve@example.com", City: "Phoenix"},
{Name: "Frank Miller", Age: 37, Email: "frank@example.com", City: "Philadelphia"},
{Name: "Grace Lee", Age: 25, Email: "grace@example.com", City: "San Antonio"},
{Name: "Henry Taylor", Age: 33, Email: "henry@example.com", City: "San Diego"},
{Name: "Ivy Chen", Age: 26, Email: "ivy@example.com", City: "Dallas"},
{Name: "Jack Anderson", Age: 31, Email: "jack@example.com", City: "San Jose"},
})
// The App component is the required entry point for every Tsunami application
var App = app.DefineComponent("App", func(_ struct{}) any {
app.UseSetAppTitle("Table Test Demo")
// Define table columns
columns := []ui.TableColumn[Person]{
{
AccessorKey: "Name",
Header: "Full Name",
Sortable: true,
Width: "200px",
},
{
AccessorKey: "Age",
Header: "Age",
Sortable: true,
Width: "80px",
},
{
AccessorKey: "Email",
Header: "Email Address",
Sortable: true,
Width: "250px",
},
{
AccessorKey: "City",
Header: "City",
Sortable: true,
Width: "150px",
},
}
// Handle row clicks
handleRowClick := func(person Person, idx int) {
fmt.Printf("Clicked on row %d: %s from %s\n", idx, person.Name, person.City)
}
// Handle sorting
handleSort := func(column string, direction string) {
fmt.Printf("Sorting by %s in %s order\n", column, direction)
}
return vdom.H("div", map[string]any{
"className": "max-w-6xl mx-auto p-6 space-y-6",
},
vdom.H("div", map[string]any{
"className": "text-center",
},
vdom.H("h1", map[string]any{
"className": "text-3xl font-bold text-white mb-2",
}, "Table Component Demo"),
vdom.H("p", map[string]any{
"className": "text-gray-300",
}, "Testing the Tsunami table component with sample data"),
),
vdom.H("div", map[string]any{
"className": "bg-gray-800 p-4 rounded-lg",
},
PersonTable(ui.TableProps[Person]{
Data: sampleData.Get(),
Columns: columns,
OnRowClick: handleRowClick,
OnSort: handleSort,
DefaultSort: "Name",
Selectable: true,
Pagination: &ui.PaginationConfig{
PageSize: 5,
CurrentPage: 0,
ShowSizes: []int{5, 10, 25},
},
}),
),
vdom.H("div", map[string]any{
"className": "text-center text-gray-400 text-sm",
}, "Click on rows to see interactions. Try sorting by clicking column headers."),
)
})

View file

@ -0,0 +1,12 @@
module tsunami/app/tabletest
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

180
tsunami/demo/todo/app.go Normal file
View file

@ -0,0 +1,180 @@
package main
import (
_ "embed"
"strconv"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// 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("InputField", func(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": "flex-1 p-2 border border-border rounded",
"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("TodoItem", func(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,
}, "×"),
)
},
)
// List component demonstrating mapping over data, using WithKey to set key on a component
var TodoList = app.DefineComponent("TodoList", func(props TodoListProps) any {
return vdom.H("div", map[string]any{
"className": "flex flex-col gap-2",
}, vdom.ForEach(props.Todos, func(todo Todo, _ int) 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("App", func(_ any) any {
app.UseSetAppTitle("Todo App (Tsunami Demo)")
// Multiple local atoms example
todosAtom := app.UseLocal([]Todo{
{Id: 1, Text: "Learn VDOM", Completed: false},
{Id: 2, Text: "Build a todo app", Completed: false},
})
nextIdAtom := app.UseLocal(3)
inputTextAtom := app.UseLocal("")
// Event handlers modifying multiple pieces of state
addTodo := func() {
if inputTextAtom.Get() == "" {
return
}
todosAtom.SetFn(func(todos []Todo) []Todo {
return append(todos, Todo{
Id: nextIdAtom.Get(),
Text: inputTextAtom.Get(),
Completed: false,
})
})
nextIdAtom.Set(nextIdAtom.Get() + 1)
inputTextAtom.Set("")
}
// Immutable state update pattern
toggleTodo := func(id int) {
todosAtom.SetFn(func(todos []Todo) []Todo {
for i := range todos {
if todos[i].Id == id {
todos[i].Completed = !todos[i].Completed
break
}
}
return todos
})
}
deleteTodo := func(id int) {
todosAtom.SetFn(func(todos []Todo) []Todo {
newTodos := make([]Todo, 0, len(todos)-1)
for _, todo := range todos {
if todo.Id != id {
newTodos = append(newTodos, todo)
}
}
return newTodos
})
}
return vdom.H("div", map[string]any{
"className": "max-w-[500px] m-5 font-sans",
},
vdom.H("div", map[string]any{
"className": "mb-5",
}, vdom.H("h1", map[string]any{
"className": "text-2xl font-bold",
}, "Todo List")),
vdom.H("div", map[string]any{
"className": "flex gap-2.5 mb-5",
},
InputField(InputFieldProps{
Value: inputTextAtom.Get(),
OnChange: inputTextAtom.Set,
OnEnter: addTodo,
}),
vdom.H("button", map[string]any{
"className": "px-4 py-2 border border-border rounded cursor-pointer",
"onClick": addTodo,
}, "Add Todo"),
),
TodoList(TodoListProps{
Todos: todosAtom.Get(),
OnToggle: toggleTodo,
OnDelete: deleteTodo,
}),
)
},
)

12
tsunami/demo/todo/go.mod Normal file
View file

@ -0,0 +1,12 @@
module tsunami/app/todo
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

4
tsunami/demo/todo/go.sum Normal file
View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,370 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Global atoms for config
var (
serverURLAtom = app.ConfigAtom("serverURL", "")
)
type URLInputProps struct {
Value string `json:"value"`
OnChange func(string) `json:"onChange"`
OnSubmit func() `json:"onSubmit"`
IsLoading bool `json:"isLoading"`
}
type JSONEditorProps struct {
Value string `json:"value"`
OnChange func(string) `json:"onChange"`
OnSubmit func() `json:"onSubmit"`
IsLoading bool `json:"isLoading"`
Placeholder string `json:"placeholder"`
}
type ErrorDisplayProps struct {
Message string `json:"message"`
}
type SuccessDisplayProps struct {
Message string `json:"message"`
}
// parseURL takes flexible URL input and returns a normalized base URL
func parseURL(input string) (string, error) {
if input == "" {
return "", fmt.Errorf("URL cannot be empty")
}
input = strings.TrimSpace(input)
// Handle just port number (e.g., "52848")
if portRegex := regexp.MustCompile(`^\d+$`); portRegex.MatchString(input) {
return fmt.Sprintf("http://localhost:%s", input), nil
}
// Add http:// if no protocol specified
if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
input = "http://" + input
}
// Parse the URL to validate and extract components
parsedURL, err := url.Parse(input)
if err != nil {
return "", fmt.Errorf("invalid URL format: %v", err)
}
if parsedURL.Host == "" {
return "", fmt.Errorf("no host specified in URL")
}
// Return base URL (scheme + host)
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
return baseURL, nil
}
// fetchConfig fetches JSON from the /api/config endpoint
func fetchConfig(baseURL string) (string, error) {
configURL := baseURL + "/api/config"
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(configURL)
if err != nil {
return "", fmt.Errorf("failed to connect to %s: %v", configURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned status %d from %s", resp.StatusCode, configURL)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
// Validate that it's valid JSON
var jsonObj interface{}
if err := json.Unmarshal(body, &jsonObj); err != nil {
return "", fmt.Errorf("response is not valid JSON: %v", err)
}
// Pretty print the JSON
prettyJSON, err := json.MarshalIndent(jsonObj, "", " ")
if err != nil {
return "", fmt.Errorf("failed to format JSON: %v", err)
}
return string(prettyJSON), nil
}
// postConfig sends JSON to the /api/config endpoint
func postConfig(baseURL, jsonContent string) error {
configURL := baseURL + "/api/config"
// Validate JSON before sending
var jsonObj interface{}
if err := json.Unmarshal([]byte(jsonContent), &jsonObj); err != nil {
return fmt.Errorf("invalid JSON: %v", err)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(configURL, "application/json", strings.NewReader(jsonContent))
if err != nil {
return fmt.Errorf("failed to send request to %s: %v", configURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
return nil
}
var URLInput = app.DefineComponent("URLInput",
func(props URLInputProps) any {
keyHandler := &vdom.VDomFunc{
Type: "func",
Fn: func(event vdom.VDomEvent) {
if !props.IsLoading {
props.OnSubmit()
}
},
Keys: []string{"Enter"},
PreventDefault: true,
}
return vdom.H("div", map[string]any{
"className": "flex gap-2 mb-4",
},
vdom.H("input", map[string]any{
"className": "flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500",
"type": "text",
"placeholder": "Enter URL (e.g., localhost:52848, http://localhost:52848/api/config, or just 52848)",
"value": props.Value,
"disabled": props.IsLoading,
"onChange": func(e vdom.VDomEvent) {
props.OnChange(e.TargetValue)
},
"onKeyDown": keyHandler,
}),
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-4 py-2 rounded font-medium cursor-pointer transition-colors",
vdom.IfElse(props.IsLoading,
"bg-slate-600 text-slate-400 cursor-not-allowed",
"bg-blue-600 text-white hover:bg-blue-700",
),
),
"onClick": vdom.If(!props.IsLoading, props.OnSubmit),
"disabled": props.IsLoading,
}, vdom.IfElse(props.IsLoading, "Loading...", "Fetch")),
)
},
)
var JSONEditor = app.DefineComponent("JSONEditor",
func(props JSONEditorProps) any {
if props.Value == "" && props.Placeholder == "" {
return vdom.H("div", map[string]any{
"className": "text-slate-400 text-center py-8",
}, "Enter a URL above and click Fetch to load configuration")
}
return vdom.H("div", map[string]any{
"className": "flex flex-col",
},
vdom.H("textarea", map[string]any{
"className": "w-full h-96 px-3 py-2 bg-slate-700 border border-slate-600 rounded text-slate-100 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500",
"value": props.Value,
"placeholder": props.Placeholder,
"disabled": props.IsLoading,
"onChange": func(e vdom.VDomEvent) {
props.OnChange(e.TargetValue)
},
}),
vdom.If(props.Value != "",
vdom.H("button", map[string]any{
"className": vdom.Classes(
"mt-2 w-full py-2 rounded font-medium cursor-pointer transition-colors",
vdom.IfElse(props.IsLoading,
"bg-slate-600 text-slate-400 cursor-not-allowed",
"bg-green-600 text-white hover:bg-green-700",
),
),
"onClick": vdom.If(!props.IsLoading, props.OnSubmit),
"disabled": props.IsLoading,
}, vdom.IfElse(props.IsLoading, "Submitting...", "Submit Changes")),
),
)
},
)
var ErrorDisplay = app.DefineComponent("ErrorDisplay",
func(props ErrorDisplayProps) any {
if props.Message == "" {
return nil
}
return vdom.H("div", map[string]any{
"className": "bg-red-900 border border-red-700 text-red-100 px-4 py-3 rounded mb-4",
},
vdom.H("div", map[string]any{
"className": "font-medium",
}, "Error"),
vdom.H("div", map[string]any{
"className": "text-sm mt-1",
}, props.Message),
)
},
)
var SuccessDisplay = app.DefineComponent("SuccessDisplay",
func(props SuccessDisplayProps) any {
if props.Message == "" {
return nil
}
return vdom.H("div", map[string]any{
"className": "bg-green-900 border border-green-700 text-green-100 px-4 py-3 rounded mb-4",
},
vdom.H("div", map[string]any{
"className": "font-medium",
}, "Success"),
vdom.H("div", map[string]any{
"className": "text-sm mt-1",
}, props.Message),
)
},
)
var App = app.DefineComponent("App",
func(_ struct{}) any {
app.UseSetAppTitle("Tsunami Config Manager")
// Get atom value once at the top
urlInput := serverURLAtom.Get()
jsonContent := app.UseLocal("")
errorMessage := app.UseLocal("")
successMessage := app.UseLocal("")
isLoading := app.UseLocal(false)
lastFetch := app.UseLocal("")
currentBaseURL := app.UseLocal("")
clearMessages := func() {
errorMessage.Set("")
successMessage.Set("")
}
fetchConfigData := func() {
clearMessages()
baseURL, err := parseURL(serverURLAtom.Get())
if err != nil {
errorMessage.Set(err.Error())
return
}
isLoading.Set(true)
currentBaseURL.Set(baseURL)
go func() {
defer func() {
isLoading.Set(false)
}()
content, err := fetchConfig(baseURL)
if err != nil {
errorMessage.Set(err.Error())
return
}
jsonContent.Set(content)
lastFetch.Set(time.Now().Format("2006-01-02 15:04:05"))
successMessage.Set(fmt.Sprintf("Successfully fetched config from %s", baseURL))
}()
}
submitConfigData := func() {
if currentBaseURL.Get() == "" {
errorMessage.Set("No base URL available. Please fetch config first.")
return
}
clearMessages()
isLoading.Set(true)
go func() {
defer func() {
isLoading.Set(false)
}()
err := postConfig(currentBaseURL.Get(), jsonContent.Get())
if err != nil {
errorMessage.Set(fmt.Sprintf("Failed to submit config: %v", err))
return
}
successMessage.Set(fmt.Sprintf("Successfully submitted config to %s", currentBaseURL.Get()))
}()
}
return vdom.H("div", map[string]any{
"className": "max-w-4xl mx-auto p-6 bg-slate-800 text-slate-100 min-h-screen",
},
vdom.H("div", map[string]any{
"className": "mb-6",
},
vdom.H("h1", map[string]any{
"className": "text-3xl font-bold mb-2",
}, "Tsunami Config Manager"),
vdom.H("p", map[string]any{
"className": "text-slate-400",
}, "Fetch and edit configuration from remote servers"),
),
URLInput(URLInputProps{
Value: urlInput,
OnChange: serverURLAtom.Set,
OnSubmit: fetchConfigData,
IsLoading: isLoading.Get(),
}),
ErrorDisplay(ErrorDisplayProps{
Message: errorMessage.Get(),
}),
SuccessDisplay(SuccessDisplayProps{
Message: successMessage.Get(),
}),
vdom.If(lastFetch.Get() != "",
vdom.H("div", map[string]any{
"className": "text-sm text-slate-400 mb-4",
}, fmt.Sprintf("Last fetched: %s from %s", lastFetch.Get(), currentBaseURL.Get())),
),
JSONEditor(JSONEditorProps{
Value: jsonContent.Get(),
OnChange: jsonContent.Set,
OnSubmit: submitConfigData,
IsLoading: isLoading.Get(),
Placeholder: "JSON configuration will appear here after fetching...",
}),
)
},
)

View file

@ -0,0 +1,12 @@
module tsunami/app/tsunamiconfig
go 1.24.6
require github.com/wavetermdev/waveterm/tsunami v0.0.0
require (
github.com/google/uuid v1.6.0 // indirect
github.com/outrigdev/goid v0.3.0 // indirect
)
replace github.com/wavetermdev/waveterm/tsunami => /Users/mike/work/waveterm/tsunami

View file

@ -0,0 +1,4 @@
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/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"time"
)
const NotifyMaxCadence = 10 * time.Millisecond
const NotifyDebounceTime = 500 * time.Microsecond
const NotifyMaxDebounceTime = 2 * time.Millisecond
func (c *ClientImpl) notifyAsyncRenderWork() {
c.notifyOnce.Do(func() {
c.notifyWakeCh = make(chan struct{}, 1)
go c.asyncInitiationLoop()
})
nowNs := time.Now().UnixNano()
c.notifyLastEventNs.Store(nowNs)
// Establish batch start if there's no active batch.
if c.notifyBatchStartNs.Load() == 0 {
c.notifyBatchStartNs.CompareAndSwap(0, nowNs)
}
// Coalesced wake-up.
select {
case c.notifyWakeCh <- struct{}{}:
default:
}
}
func (c *ClientImpl) asyncInitiationLoop() {
var (
lastSent time.Time
timer *time.Timer
timerC <-chan time.Time
)
schedule := func() {
firstNs := c.notifyBatchStartNs.Load()
if firstNs == 0 {
// No pending batch; stop timer if running.
if timer != nil {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
timerC = nil
return
}
lastNs := c.notifyLastEventNs.Load()
first := time.Unix(0, firstNs)
last := time.Unix(0, lastNs)
cadenceReady := lastSent.Add(NotifyMaxCadence)
// Reset the 2ms "max debounce" window at the cadence boundary:
// deadline = max(first, cadenceReady) + 2ms
anchor := first
if cadenceReady.After(anchor) {
anchor = cadenceReady
}
deadline := anchor.Add(NotifyMaxDebounceTime)
// candidate = min(last+500us, deadline)
candidate := last.Add(NotifyDebounceTime)
if deadline.Before(candidate) {
candidate = deadline
}
// final target = max(cadenceReady, candidate)
target := candidate
if cadenceReady.After(target) {
target = cadenceReady
}
d := time.Until(target)
if d < 0 {
d = 0
}
if timer == nil {
timer = time.NewTimer(d)
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(d)
}
timerC = timer.C
}
for {
select {
case <-c.notifyWakeCh:
schedule()
case <-timerC:
now := time.Now()
// Recompute right before sending; if a late event arrived,
// push the fire time out to respect the debounce.
firstNs := c.notifyBatchStartNs.Load()
if firstNs == 0 {
// Nothing to do.
continue
}
lastNs := c.notifyLastEventNs.Load()
first := time.Unix(0, firstNs)
last := time.Unix(0, lastNs)
cadenceReady := lastSent.Add(NotifyMaxCadence)
anchor := first
if cadenceReady.After(anchor) {
anchor = cadenceReady
}
deadline := anchor.Add(NotifyMaxDebounceTime)
candidate := last.Add(NotifyDebounceTime)
if deadline.Before(candidate) {
candidate = deadline
}
target := candidate
if cadenceReady.After(target) {
target = cadenceReady
}
// If we're early (because a new event just came in), reschedule.
if now.Before(target) {
d := time.Until(target)
if d < 0 {
d = 0
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(d)
continue
}
// Fire.
_ = c.SendAsyncInitiation()
lastSent = now
// Close current batch; a concurrent notify will CAS a new start.
c.notifyBatchStartNs.Store(0)
// If anything is already pending, this will arm the next timer.
schedule()
}
}
}

View file

@ -0,0 +1,86 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"encoding/json"
"fmt"
"sync"
)
type AtomImpl[T any] struct {
lock *sync.Mutex
val T
usedBy map[string]bool // component waveid -> true
}
func MakeAtomImpl[T any](initialVal T) *AtomImpl[T] {
return &AtomImpl[T]{
lock: &sync.Mutex{},
val: initialVal,
usedBy: make(map[string]bool),
}
}
func (a *AtomImpl[T]) GetVal() any {
a.lock.Lock()
defer a.lock.Unlock()
return a.val
}
func (a *AtomImpl[T]) setVal_nolock(val any) error {
if val == nil {
var zero T
a.val = zero
return nil
}
// Try direct assignment if it's already type T
if typed, ok := val.(T); ok {
a.val = typed
return nil
}
// Try JSON marshaling/unmarshaling
jsonBytes, err := json.Marshal(val)
if err != nil {
var result T
return fmt.Errorf("failed to adapt type from %T => %T, input type failed to marshal: %w", val, result, err)
}
var result T
if err := json.Unmarshal(jsonBytes, &result); err != nil {
return fmt.Errorf("failed to adapt type from %T => %T: %w", val, result, err)
}
a.val = result
return nil
}
func (a *AtomImpl[T]) SetVal(val any) error {
a.lock.Lock()
defer a.lock.Unlock()
return a.setVal_nolock(val)
}
func (a *AtomImpl[T]) SetUsedBy(waveId string, used bool) {
a.lock.Lock()
defer a.lock.Unlock()
if used {
a.usedBy[waveId] = true
} else {
delete(a.usedBy, waveId)
}
}
func (a *AtomImpl[T]) GetUsedBy() []string {
a.lock.Lock()
defer a.lock.Unlock()
keys := make([]string, 0, len(a.usedBy))
for compId := range a.usedBy {
keys = append(keys, compId)
}
return keys
}

View file

@ -0,0 +1,318 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"context"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
const TsunamiListenAddrEnvVar = "TSUNAMI_LISTENADDR"
const DefaultListenAddr = "localhost:0"
const DefaultComponentName = "App"
type ssEvent struct {
Event string
Data []byte
}
var defaultClient = makeClient()
type ClientImpl struct {
Lock *sync.Mutex
Root *RootElem
RootElem *vdom.VDomElem
CurrentClientId string
ServerId string
IsDone bool
DoneReason string
DoneCh chan struct{}
SSEventCh chan ssEvent
GlobalEventHandler func(event vdom.VDomEvent)
UrlHandlerMux *http.ServeMux
SetupFn func()
AssetsFS fs.FS
StaticFS fs.FS
ManifestFileBytes []byte
// for notification
// Atomics so we never drop "last event" timing info even if wakeCh is full.
// 0 means "no pending batch".
notifyOnce sync.Once
notifyWakeCh chan struct{}
notifyBatchStartNs atomic.Int64 // ns of first event in current batch
notifyLastEventNs atomic.Int64 // ns of most recent event
}
func makeClient() *ClientImpl {
client := &ClientImpl{
Lock: &sync.Mutex{},
DoneCh: make(chan struct{}),
SSEventCh: make(chan ssEvent, 100),
UrlHandlerMux: http.NewServeMux(),
ServerId: uuid.New().String(),
RootElem: vdom.H(DefaultComponentName, nil),
}
client.Root = MakeRoot(client)
return client
}
func GetDefaultClient() *ClientImpl {
return defaultClient
}
func (c *ClientImpl) GetIsDone() bool {
c.Lock.Lock()
defer c.Lock.Unlock()
return c.IsDone
}
func (c *ClientImpl) 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 *ClientImpl) clientTakeover(clientId string) {
c.Lock.Lock()
defer c.Lock.Unlock()
c.CurrentClientId = clientId
}
func (c *ClientImpl) 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 *ClientImpl) SetGlobalEventHandler(handler func(event vdom.VDomEvent)) {
c.GlobalEventHandler = handler
}
func (c *ClientImpl) getFaviconPath() string {
if c.StaticFS != nil {
faviconNames := []string{"favicon.ico", "favicon.png", "favicon.svg", "favicon.gif", "favicon.jpg"}
for _, name := range faviconNames {
if _, err := c.StaticFS.Open(name); err == nil {
return "/static/" + name
}
}
}
return "/wave-logo-256.png"
}
func (c *ClientImpl) makeBackendOpts() *rpctypes.VDomBackendOpts {
return &rpctypes.VDomBackendOpts{
Title: c.Root.AppTitle,
GlobalKeyboardEvents: c.GlobalEventHandler != nil,
FaviconPath: c.getFaviconPath(),
}
}
func (c *ClientImpl) runMainE() error {
if c.SetupFn != nil {
c.SetupFn()
}
err := c.listenAndServe(context.Background())
if err != nil {
return err
}
<-c.DoneCh
return nil
}
func (c *ClientImpl) RegisterSetupFn(fn func()) {
c.SetupFn = fn
}
func (c *ClientImpl) RunMain() {
err := c.runMainE()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func (c *ClientImpl) listenAndServe(ctx context.Context) error {
// Create HTTP handlers
handlers := newHTTPHandlers(c)
// Create a new ServeMux and register handlers
mux := http.NewServeMux()
handlers.registerHandlers(mux, handlerOpts{
AssetsFS: c.AssetsFS,
StaticFS: c.StaticFS,
ManifestFile: c.ManifestFileBytes,
})
// 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: listenAddr,
Handler: mux,
}
// Start listening
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
// Log the address we're listening on
port := listener.Addr().(*net.TCPAddr).Port
log.Printf("[tsunami] listening at http://localhost:%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 *ClientImpl) SendAsyncInitiation() error {
log.Printf("send async initiation\n")
if c.GetIsDone() {
return fmt.Errorf("client is done")
}
select {
case c.SSEventCh <- ssEvent{Event: "asyncinitiation", Data: nil}:
return nil
default:
return fmt.Errorf("SSEvent channel is full")
}
}
func makeNullRendered() *rpctypes.RenderedElem {
return &rpctypes.RenderedElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
}
func structToProps(props any) map[string]any {
m, err := util.StructToMap(props)
if err != nil {
return nil
}
return m
}
func DefineComponentEx[P any](client *ClientImpl, name string, renderFn func(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.H(name, structToProps(props))
}
}
func (c *ClientImpl) registerComponent(name string, cfunc any) error {
return c.Root.RegisterComponent(name, cfunc)
}
func (c *ClientImpl) fullRender() (*rpctypes.VDomBackendUpdate, error) {
opts := &RenderOpts{Resync: true}
c.Root.RunWork(opts)
c.Root.Render(c.RootElem, opts)
renderedVDom := c.Root.MakeRendered()
if renderedVDom == nil {
renderedVDom = makeNullRendered()
}
return &rpctypes.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
ServerId: c.ServerId,
HasWork: len(c.Root.EffectWorkQueue) > 0,
FullUpdate: true,
Opts: c.makeBackendOpts(),
RenderUpdates: []rpctypes.VDomRenderUpdate{
{UpdateType: "root", VDom: renderedVDom},
},
RefOperations: c.Root.GetRefOperations(),
}, nil
}
func (c *ClientImpl) incrementalRender() (*rpctypes.VDomBackendUpdate, error) {
opts := &RenderOpts{Resync: false}
c.Root.RunWork(opts)
renderedVDom := c.Root.MakeRendered()
if renderedVDom == nil {
renderedVDom = makeNullRendered()
}
return &rpctypes.VDomBackendUpdate{
Type: "backendupdate",
Ts: time.Now().UnixMilli(),
ServerId: c.ServerId,
HasWork: len(c.Root.EffectWorkQueue) > 0,
FullUpdate: false,
Opts: c.makeBackendOpts(),
RenderUpdates: []rpctypes.VDomRenderUpdate{
{UpdateType: "root", VDom: renderedVDom},
},
RefOperations: c.Root.GetRefOperations(),
}, nil
}
func (c *ClientImpl) HandleDynFunc(pattern string, fn func(http.ResponseWriter, *http.Request)) {
if !strings.HasPrefix(pattern, "/dyn/") {
log.Printf("invalid dyn pattern: %s (must start with /dyn/)", pattern)
return
}
c.UrlHandlerMux.HandleFunc(pattern, fn)
}
func (c *ClientImpl) RunEvents(events []vdom.VDomEvent) {
for _, event := range events {
c.Root.Event(event, c.GlobalEventHandler)
}
}

52
tsunami/engine/comp.go Normal file
View file

@ -0,0 +1,52 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
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
type ChildKey struct {
Tag string
Idx int
Key string
}
// ComponentImpl represents a node in the persistent shadow component tree.
// This is Tsunami's equivalent to React's Fiber nodes - it maintains component
// identity, state, and lifecycle across renders while the VDomElem input/output
// structures are ephemeral.
type ComponentImpl struct {
WaveId string // Unique identifier for this component instance
Tag string // Component type (HTML tag, custom component name, "#text", etc.)
Key string // User-provided key for reconciliation (like React keys)
ContainingComp string // Which vdom component's render function created this ComponentImpl
Elem *vdom.VDomElem // Reference to the current input VDomElem being rendered
Mounted bool // Whether this component is currently mounted
// Hooks system (React-like)
Hooks []*Hook // Array of hooks (state, effects, etc.) attached to this component
// Atom dependency tracking
UsedAtoms map[string]bool // atomName -> true, tracks which atoms this component uses
// Component content - exactly ONE of these patterns is used:
// Pattern 1: Text nodes
Text string // For "#text" components - stores the actual text content
// Pattern 2: Base/DOM elements with children
Children []*ComponentImpl // For HTML tags, fragments - array of child components
// Pattern 3: Custom components that render to other components
RenderedComp *ComponentImpl // For custom components - points to what this component rendered to
}
func (c *ComponentImpl) compMatch(tag string, key string) bool {
if c == nil {
return false
}
return c.Tag == tag && c.Key == key
}

159
tsunami/engine/globalctx.go Normal file
View file

@ -0,0 +1,159 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"sync"
"github.com/outrigdev/goid"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
const (
GlobalContextType_async = "async"
GlobalContextType_render = "render"
GlobalContextType_effect = "effect"
GlobalContextType_event = "event"
)
// is set ONLY when we're in the render function of a component
// used for hooks, and automatic dependency tracking
var globalRenderContext *RenderContextImpl
var globalRenderGoId uint64
var globalEventContext *EventContextImpl
var globalEventGoId uint64
var globalEffectContext *EffectContextImpl
var globalEffectGoId uint64
var globalCtxMutex sync.Mutex
type EventContextImpl struct {
Event vdom.VDomEvent
Root *RootElem
}
type EffectContextImpl struct {
WorkElem EffectWorkElem
WorkType string // "run" or "unmount"
Root *RootElem
}
func setGlobalRenderContext(vc *RenderContextImpl) {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalRenderContext = vc
globalRenderGoId = goid.Get()
}
func clearGlobalRenderContext() {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalRenderContext = nil
globalRenderGoId = 0
}
func withGlobalRenderCtx[T any](vc *RenderContextImpl, fn func() T) T {
setGlobalRenderContext(vc)
defer clearGlobalRenderContext()
return fn()
}
func GetGlobalRenderContext() *RenderContextImpl {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
gid := goid.Get()
if gid != globalRenderGoId {
return nil
}
return globalRenderContext
}
func setGlobalEventContext(ec *EventContextImpl) {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalEventContext = ec
globalEventGoId = goid.Get()
}
func clearGlobalEventContext() {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalEventContext = nil
globalEventGoId = 0
}
func withGlobalEventCtx[T any](ec *EventContextImpl, fn func() T) T {
setGlobalEventContext(ec)
defer clearGlobalEventContext()
return fn()
}
func GetGlobalEventContext() *EventContextImpl {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
gid := goid.Get()
if gid != globalEventGoId {
return nil
}
return globalEventContext
}
func setGlobalEffectContext(ec *EffectContextImpl) {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalEffectContext = ec
globalEffectGoId = goid.Get()
}
func clearGlobalEffectContext() {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
globalEffectContext = nil
globalEffectGoId = 0
}
func withGlobalEffectCtx[T any](ec *EffectContextImpl, fn func() T) T {
setGlobalEffectContext(ec)
defer clearGlobalEffectContext()
return fn()
}
func GetGlobalEffectContext() *EffectContextImpl {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
gid := goid.Get()
if gid != globalEffectGoId {
return nil
}
return globalEffectContext
}
// inContextType returns the current global context type.
// Returns one of:
// - GlobalContextType_render: when in a component render function
// - GlobalContextType_event: when in an event handler
// - GlobalContextType_effect: when in an effect function
// - GlobalContextType_async: when not in any specific context (default/async)
func inContextType() string {
globalCtxMutex.Lock()
defer globalCtxMutex.Unlock()
gid := goid.Get()
if globalRenderContext != nil && gid == globalRenderGoId {
return GlobalContextType_render
}
if globalEventContext != nil && gid == globalEventGoId {
return GlobalContextType_event
}
if globalEffectContext != nil && gid == globalEffectGoId {
return GlobalContextType_effect
}
return GlobalContextType_async
}

167
tsunami/engine/hooks.go Normal file
View file

@ -0,0 +1,167 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"log"
"strconv"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// 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 RenderContextImpl struct {
Root *RootElem
Comp *ComponentImpl
HookIdx int
RenderOpts *RenderOpts
UsedAtoms map[string]bool // Track atoms used during this render
}
func makeContextVal(root *RootElem, comp *ComponentImpl, opts *RenderOpts) *RenderContextImpl {
return &RenderContextImpl{
Root: root,
Comp: comp,
HookIdx: 0,
RenderOpts: opts,
UsedAtoms: make(map[string]bool),
}
}
func (vc *RenderContextImpl) GetCompWaveId() string {
if vc.Comp == nil {
return ""
}
return vc.Comp.WaveId
}
func (vc *RenderContextImpl) getOrderedHook() *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, &Hook{Idx: len(vc.Comp.Hooks)})
}
hookVal := vc.Comp.Hooks[vc.HookIdx]
vc.HookIdx++
return hookVal
}
func (vc *RenderContextImpl) getCompName() string {
if vc.Comp == nil || vc.Comp.Elem == nil {
return ""
}
return vc.Comp.Elem.Tag
}
func UseRenderTs(vc *RenderContextImpl) int64 {
return vc.Root.RenderTs
}
func UseId(vc *RenderContextImpl) string {
return vc.GetCompWaveId()
}
func UseLocal(vc *RenderContextImpl, initialVal any) string {
hookVal := vc.getOrderedHook()
atomName := "$local." + vc.GetCompWaveId() + "#" + strconv.Itoa(hookVal.Idx)
if !hookVal.Init {
hookVal.Init = true
atom := MakeAtomImpl(initialVal)
vc.Root.RegisterAtom(atomName, atom)
closedAtomName := atomName
hookVal.UnmountFn = func() {
vc.Root.RemoveAtom(closedAtomName)
}
}
return atomName
}
func UseVDomRef(vc *RenderContextImpl) any {
hookVal := vc.getOrderedHook()
if !hookVal.Init {
hookVal.Init = true
refId := vc.GetCompWaveId() + ":" + strconv.Itoa(hookVal.Idx)
hookVal.Val = &vdom.VDomRef{Type: vdom.ObjectType_Ref, RefId: refId}
}
refVal, ok := hookVal.Val.(*vdom.VDomRef)
if !ok {
panic("UseVDomRef hook value is not a ref (possible out of order or conditional hooks)")
}
return refVal
}
func UseRef(vc *RenderContextImpl, hookInitialVal any) any {
hookVal := vc.getOrderedHook()
if !hookVal.Init {
hookVal.Init = true
hookVal.Val = hookInitialVal
}
return hookVal.Val
}
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(vc *RenderContextImpl, fn func() func(), deps []any) {
hookVal := vc.getOrderedHook()
compTag := ""
if vc.Comp != nil {
compTag = vc.Comp.Tag
}
if !hookVal.Init {
hookVal.Init = true
hookVal.Fn = fn
hookVal.Deps = deps
vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
return
}
// If deps is nil, always run (like React with no dependency array)
if deps == nil {
hookVal.Fn = fn
hookVal.Deps = deps
vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
return
}
if depsEqual(hookVal.Deps, deps) {
return
}
hookVal.Fn = fn
hookVal.Deps = deps
vc.Root.addEffectWork(vc.GetCompWaveId(), hookVal.Idx, compTag)
}
func UseResync(vc *RenderContextImpl) bool {
if vc.RenderOpts == nil {
return false
}
return vc.RenderOpts.Resync
}
func UseSetAppTitle(vc *RenderContextImpl, title string) {
if vc.getCompName() != "App" {
log.Printf("UseSetAppTitle can only be called from the App component")
return
}
vc.Root.AppTitle = title
}

319
tsunami/engine/render.go Normal file
View file

@ -0,0 +1,319 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"fmt"
"reflect"
"unicode"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// see render.md for a complete guide to how tsunami rendering, lifecycle, and reconciliation works
type RenderOpts struct {
Resync bool
}
func (r *RootElem) Render(elem *vdom.VDomElem, opts *RenderOpts) {
r.render(elem, &r.Root, "root", opts)
}
func getElemKey(elem *vdom.VDomElem) string {
if elem == nil {
return ""
}
keyVal, ok := elem.Props[vdom.KeyPropKey]
if !ok {
return ""
}
return fmt.Sprint(keyVal)
}
func (r *RootElem) render(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if elem == nil || elem.Tag == "" {
r.unmount(comp)
return
}
elemKey := getElemKey(elem)
if *comp == nil || !(*comp).compMatch(elem.Tag, elemKey) {
r.unmount(comp)
r.createComp(elem.Tag, elemKey, containingComp, comp)
}
(*comp).Elem = elem
if elem.Tag == vdom.TextTag {
// Pattern 1: Text Nodes
r.renderText(elem.Text, comp)
return
}
if isBaseTag(elem.Tag) {
// Pattern 2: Base elements
r.renderSimple(elem, comp, containingComp, opts)
return
}
cfunc := r.CFuncs[elem.Tag]
if cfunc == nil {
text := fmt.Sprintf("<%s>", elem.Tag)
r.renderText(text, comp)
return
}
// Pattern 3: components
r.renderComponent(cfunc, elem, comp, opts)
}
// Pattern 1
func (r *RootElem) renderText(text string, comp **ComponentImpl) {
// No need to clear Children/Comp - text components cannot have them
if (*comp).Text != text {
(*comp).Text = text
}
}
// Pattern 2
func (r *RootElem) renderSimple(elem *vdom.VDomElem, comp **ComponentImpl, containingComp string, opts *RenderOpts) {
if (*comp).RenderedComp != nil {
// Clear Comp since base elements don't use it
r.unmount(&(*comp).RenderedComp)
}
(*comp).Children = r.renderChildren(elem.Children, (*comp).Children, containingComp, opts)
}
// Pattern 3
func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **ComponentImpl, opts *RenderOpts) {
if (*comp).Children != nil {
// Clear Children since custom components don't use them
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
vc := makeContextVal(r, *comp, opts)
rtnElemArr := withGlobalRenderCtx(vc, func() []vdom.VDomElem {
renderedElem := callCFuncWithErrorGuard(cfunc, props, elem.Tag)
return vdom.ToElems(renderedElem)
})
// Process atom usage after render
r.updateComponentAtomUsage(*comp, vc.UsedAtoms)
var rtnElem *vdom.VDomElem
if len(rtnElemArr) == 0 {
rtnElem = nil
} else if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0]
} else {
rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
}
r.render(rtnElem, &(*comp).RenderedComp, elem.Tag, opts)
}
func (r *RootElem) unmount(comp **ComponentImpl) {
if *comp == nil {
return
}
waveId := (*comp).WaveId
for _, hook := range (*comp).Hooks {
if hook.UnmountFn != nil {
hook.UnmountFn()
}
}
if (*comp).RenderedComp != nil {
r.unmount(&(*comp).RenderedComp)
}
if (*comp).Children != nil {
for _, child := range (*comp).Children {
r.unmount(&child)
}
}
delete(r.CompMap, waveId)
r.cleanupUsedByForUnmount(*comp)
*comp = nil
}
func (r *RootElem) createComp(tag string, key string, containingComp string, comp **ComponentImpl) {
*comp = &ComponentImpl{WaveId: uuid.New().String(), Tag: tag, Key: key, ContainingComp: containingComp}
r.CompMap[(*comp).WaveId] = *comp
}
// handles reconcilation
// maps children via key or index (exclusively)
func (r *RootElem) renderChildren(elems []vdom.VDomElem, curChildren []*ComponentImpl, containingComp string, opts *RenderOpts) []*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 := getElemKey(&elem)
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], containingComp, opts)
}
for _, child := range curChildren {
if !usedMap[child] {
r.unmount(&child)
}
}
return newChildren
}
// creates an error component for display when a component panics
func renderErrorComponent(componentName string, errorMsg string) any {
return vdom.H("div", map[string]any{
"className": "p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono",
},
vdom.H("div", map[string]any{
"className": "font-bold mb-2",
}, fmt.Sprintf("Component Error: %s", componentName)),
vdom.H("div", nil, errorMsg),
)
}
// safely calls the component function with panic recovery
func callCFuncWithErrorGuard(cfunc any, props map[string]any, componentName string) (result any) {
defer func() {
if panicErr := util.PanicHandler(fmt.Sprintf("render component '%s'", componentName), recover()); panicErr != nil {
result = renderErrorComponent(componentName, panicErr.Error())
}
}()
result = callCFunc(cfunc, props)
return result
}
// uses reflection to call the component function
func callCFunc(cfunc any, props map[string]any) any {
rval := reflect.ValueOf(cfunc)
rtype := rval.Type()
if rtype.NumIn() != 1 {
fmt.Printf("component function must have exactly 1 parameter, got %d\n", rtype.NumIn())
return nil
}
argType := rtype.In(0)
var arg1Val reflect.Value
if argType.Kind() == reflect.Interface && argType.NumMethod() == 0 {
arg1Val = reflect.New(argType)
} else {
arg1Val = reflect.New(argType)
if argType.Kind() == reflect.Map {
arg1Val.Elem().Set(reflect.ValueOf(props))
} else {
err := util.MapToStruct(props, arg1Val.Interface())
if err != nil {
fmt.Printf("error converting props: %v\n", err)
}
}
}
rtnVal := rval.Call([]reflect.Value{arg1Val.Elem()})
if len(rtnVal) == 0 {
return nil
}
return rtnVal[0].Interface()
}
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
}
if vdomFunc, ok := v.(vdom.VDomFunc); ok {
// ensure Type is set on all VDomFuncs
vdomFunc.Type = vdom.ObjectType_Func
vdomProps[k] = vdomFunc
continue
}
if vdomRef, ok := v.(vdom.VDomRef); ok {
// ensure Type is set on all VDomRefs
vdomRef.Type = vdom.ObjectType_Ref
vdomProps[k] = vdomRef
continue
}
val := reflect.ValueOf(v)
if val.Kind() == reflect.Func {
// convert go functions passed to event handlers to VDomFuncs
vdomProps[k] = vdom.VDomFunc{Type: vdom.ObjectType_Func}
continue
}
vdomProps[k] = v
}
return vdomProps
}
func (r *RootElem) MakeRendered() *rpctypes.RenderedElem {
if r.Root == nil {
return nil
}
return r.convertCompToRendered(r.Root)
}
func (r *RootElem) convertCompToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
if c == nil {
return nil
}
if c.RenderedComp != nil {
return r.convertCompToRendered(c.RenderedComp)
}
if len(c.Children) == 0 && r.CFuncs[c.Tag] != nil {
return nil
}
return r.convertBaseToRendered(c)
}
func (r *RootElem) convertBaseToRendered(c *ComponentImpl) *rpctypes.RenderedElem {
elem := &rpctypes.RenderedElem{WaveId: c.WaveId, Tag: c.Tag}
if c.Elem != nil {
elem.Props = convertPropsToVDom(c.Elem.Props)
}
for _, child := range c.Children {
childElem := r.convertCompToRendered(child)
if childElem != nil {
elem.Children = append(elem.Children, *childElem)
}
}
if c.Tag == vdom.TextTag {
elem.Text = c.Text
}
return elem
}
func isBaseTag(tag string) bool {
if tag == "" {
return false
}
if tag == vdom.TextTag || tag == vdom.WaveTextTag || tag == vdom.WaveNullTag || tag == vdom.FragmentTag {
return true
}
if tag[0] == '#' {
return true
}
firstChar := rune(tag[0])
return unicode.IsLower(firstChar)
}

262
tsunami/engine/render.md Normal file
View file

@ -0,0 +1,262 @@
# Tsunami Rendering Engine
The Tsunami rendering engine implements a React-like component system with virtual DOM reconciliation. It maintains a persistent shadow component tree that efficiently updates in response to new VDom input, similar to React's Fiber architecture.
## Core Architecture
### Two-Phase VDom System
Tsunami uses separate types for different phases of the rendering pipeline:
- **VDomElem**: Input format used by developers (JSX-like elements created with `vdom.H()`)
- **ComponentImpl**: Internal shadow tree that maintains component identity and state across renders
- **RenderedElem**: Output format sent to the frontend with populated WaveIds
This separation mirrors React's approach where JSX elements, Fiber nodes, and DOM operations use different data structures optimized for their specific purposes.
### ComponentImpl: The Shadow Tree
The `ComponentImpl` structure is Tsunami's equivalent to React's Fiber nodes. It maintains a persistent tree that survives between renders, preserving component identity, state, and lifecycle information.
Each ComponentImpl contains:
- **Identity fields**: WaveId (unique identifier), Tag (component type), Key (for reconciliation)
- **State management**: Hooks array for React-like state and effects
- **Content organization**: Exactly one of three mutually exclusive patterns
## Three Component Patterns
The engine organizes components into three distinct patterns, each using different fields in ComponentImpl:
### Pattern 1: Text Components
```go
Text string // Text content (Pattern 1: text nodes only)
Children = nil // Not used
RenderedComp = nil // Not used
```
Used for `#text` components that render string content directly. These are the leaf nodes of the component tree.
**Example**: `vdom.H("#text", nil, "Hello World")` creates a ComponentImpl with `Text = "Hello World"`
### Pattern 2: Base/DOM Elements
```go
Text = "" // Not used
Children []*ComponentImpl // Child components (Pattern 2: containers only)
RenderedComp = nil // Not used
```
Used for HTML elements, fragments, and Wave-specific elements that act as containers. These components render multiple children but don't transform into other component types.
**Example**: `vdom.H("div", nil, child1, child2)` creates a ComponentImpl with `Children = [child1Comp, child2Comp]`
**Base elements include**:
- HTML tags with lowercase first letter (`"div"`, `"span"`, `"button"`)
- Hash-prefixed special elements (`"#fragment"`, `"#text"`)
- Wave-specific elements (`"wave:text"`, `"wave:null"`)
### Pattern 3: Custom Components
```go
Text = "" // Not used
Children = nil // Not used
RenderedComp *ComponentImpl // Rendered output (Pattern 3: custom components only)
```
Used for user-defined components that transform into other components through their render functions. These create component chains where custom components render to base elements.
**Example**: A `TodoItem` component renders to a `div`, creating the chain:
```
TodoItem ComponentImpl (Pattern 3)
└── RenderedComp → div ComponentImpl (Pattern 2)
└── Children → [text, button, etc.]
```
## Rendering Flow
### 1. Reconciliation and Pattern Routing
The main `render()` function performs React-like reconciliation:
1. **Null handling**: `elem == nil` unmounts the component
2. **Component matching**: Existing components are reused if tag and key match
3. **Pattern routing**: Elements are routed to the appropriate pattern based on tag type
```go
if elem.Tag == vdom.TextTag {
// Pattern 1: Text Nodes
r.renderText(elem.Text, comp)
} else if isBaseTag(elem.Tag) {
// Pattern 2: Base elements
r.renderSimple(elem, comp, opts)
} else {
// Pattern 3: Custom components
r.renderComponent(cfunc, elem, comp, opts)
}
```
### 2. Pattern-Specific Rendering
Each pattern has its own rendering function that manages field usage:
**renderText()**: Simply stores text content, no cleanup needed since text components can't have other patterns.
**renderSimple()**: Clears any existing `RenderedComp` (Pattern 3) and renders children into the `Children` field (Pattern 2).
**renderComponent()**: Clears any existing `Children` (Pattern 2), calls the component function, and renders the result into `RenderedComp` (Pattern 3).
### 3. Component Function Execution
Custom components are Go functions called via reflection:
1. **Props conversion**: The VDomElem props map is converted to the expected Go struct type
2. **Function execution**: The component function is called with context and typed props
3. **Result processing**: Returned elements are converted to VDomElem arrays
4. **Fragment wrapping**: Multiple returned elements are automatically wrapped in fragments
```go
// Single element: renders directly to RenderedComp
// Multiple elements: wrapped in fragment, then rendered to RenderedComp
if len(rtnElemArr) == 1 {
rtnElem = &rtnElemArr[0]
} else {
rtnElem = &vdom.VDomElem{Tag: vdom.FragmentTag, Children: rtnElemArr}
}
```
## Key-Based Reconciliation
The children reconciliation system implements React's key-matching logic:
### ChildKey Structure
```go
type ChildKey struct {
Tag string // Component type must match
Idx int // Position index for non-keyed elements
Key string // Explicit key for keyed elements
}
```
### Matching Rules
1. **Keyed elements**: Match by tag + key, position ignored
- `<div key="a">` only matches `<div key="a">`
- Position changes don't break identity
2. **Non-keyed elements**: Match by tag + position
- `<div>` at position 0 only matches `<div>` at position 0
- Moving elements breaks identity and causes remount
3. **Key transitions**: Keyed and non-keyed elements never match
- `<div>``<div key="hello">` causes remount
- Adding/removing keys breaks component identity
### Reconciliation Algorithm
```go
// Build map of existing children by ChildKey
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
}
}
// Match new elements against existing map
for idx, elem := range elems {
elemKey := getElemKey(&elem)
if elemKey != "" {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: 0, Key: elemKey}]
} else {
curChild = curCM[ChildKey{Tag: elem.Tag, Idx: idx, Key: ""}]
}
// Reuse existing component or create new one
}
```
## Component Lifecycle
### Mounting
New components are created with:
- Unique WaveId for tracking
- Tag and Key for reconciliation
- Registration in global ComponentMap
- Empty pattern fields (populated during rendering)
### Unmounting
The unmounting process ensures complete cleanup:
1. **Hook cleanup**: All hook `UnmountFn` callbacks are executed
2. **Pattern-specific cleanup**:
- Pattern 3: Recursively unmount `RenderedComp`
- Pattern 2: Recursively unmount all `Children`
- Pattern 1: No child cleanup needed
3. **Global cleanup**: Remove from ComponentMap and dependency tracking
This prevents memory leaks and ensures proper lifecycle management.
### Component vs Rendered Content Lifecycle
A key distinction in Tsunami (matching React) is that component mounting/unmounting is separate from what they render:
- **Component returns `nil`**: Component stays mounted (keeps state/hooks), but `RenderedComp` becomes `nil`
- **Component returns content again**: Component reuses existing identity, new content gets mounted
This preserves component state across rendering/not-rendering cycles.
## Output Generation
The shadow tree gets converted to frontend-ready format through `MakeRendered()`:
1. **Component chain following**: For Pattern 3 components, follow `RenderedComp` until reaching a base element
2. **Base element conversion**: Convert Pattern 1/2 components to RenderedElem with WaveIds
3. **Null component filtering**: Components with `RenderedComp == nil` don't appear in output
Only base elements (Pattern 1/2) appear in the final output - custom components (Pattern 3) are invisible, having transformed into base elements.
## React Similarities and Differences
### Similarities
- **Reconciliation**: Same key-based matching and component reuse logic
- **Hooks**: Same lifecycle patterns with cleanup functions
- **Component identity**: Persistent component instances across renders
- **Null rendering**: Components can render nothing while staying mounted
### Key Differences
- **Server-side**: Runs entirely in Go backend, sends VDom to frontend
- **Component chaining**: Pattern 3 allows direct component-to-component rendering via `RenderedComp`
- **Explicit patterns**: Three mutually exclusive patterns vs React's more flexible structure
- **Type separation**: Clear separation between input VDom, shadow tree, and output types
### Performance Optimizations
The three-pattern system provides significant optimizations:
- **Base element efficiency**: HTML elements use `Children` directly without intermediate transformation nodes
- **Component chain efficiency**: Custom components chain via `RenderedComp` without wrapper overhead
- **Memory efficiency**: Each pattern only allocates fields it actually uses
This avoids React's issue where every element creates wrapper nodes, leading to shorter traversal paths and fewer allocations.
## Pattern Transition Rules
Components never transition between patterns - they maintain their pattern for their entire lifecycle:
- **Tag determines pattern**: `#text` → Pattern 1, base tags → Pattern 2, custom tags → Pattern 3
- **Tag changes cause remount**: Different tag = different component = complete unmount/remount
- **Pattern fields are exclusive**: Only one pattern's fields are populated per component
This ensures clean memory management and predictable behavior - no cross-pattern cleanup is needed within individual render functions.

453
tsunami/engine/rootelem.go Normal file
View file

@ -0,0 +1,453 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"fmt"
"log"
"reflect"
"strconv"
"strings"
"sync"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
const ChildrenPropKey = "children"
type EffectWorkElem struct {
WaveId string
EffectIndex int
CompTag string
}
type genAtom interface {
GetVal() any
SetVal(any) error
SetUsedBy(string, bool)
GetUsedBy() []string
}
type RootElem struct {
Root *ComponentImpl
RenderTs int64
AppTitle string
CFuncs map[string]any // component name => render function
CompMap map[string]*ComponentImpl // component waveid -> component
EffectWorkQueue []*EffectWorkElem
needsRenderMap map[string]bool // key: waveid
needsRenderLock sync.Mutex
Atoms map[string]genAtom // key: atomName
atomLock sync.Mutex
RefOperations []vdom.VDomRefOperation
Client *ClientImpl
}
func (r *RootElem) addRenderWork(id string) {
defer func() {
if inContextType() == GlobalContextType_async {
r.Client.notifyAsyncRenderWork()
}
}()
r.needsRenderLock.Lock()
defer r.needsRenderLock.Unlock()
if r.needsRenderMap == nil {
r.needsRenderMap = make(map[string]bool)
}
r.needsRenderMap[id] = true
}
func (r *RootElem) getAndClearRenderWork() []string {
r.needsRenderLock.Lock()
defer r.needsRenderLock.Unlock()
if len(r.needsRenderMap) == 0 {
return nil
}
ids := make([]string, 0, len(r.needsRenderMap))
for id := range r.needsRenderMap {
ids = append(ids, id)
}
r.needsRenderMap = nil
return ids
}
func (r *RootElem) addEffectWork(id string, effectIndex int, compTag string) {
r.EffectWorkQueue = append(r.EffectWorkQueue, &EffectWorkElem{WaveId: id, EffectIndex: effectIndex, CompTag: compTag})
}
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.GetVal()
}
}
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.GetVal()
}
}
return result
}
func MakeRoot(client *ClientImpl) *RootElem {
return &RootElem{
Root: nil,
CFuncs: make(map[string]any),
CompMap: make(map[string]*ComponentImpl),
Atoms: make(map[string]genAtom),
Client: client,
}
}
func (r *RootElem) RegisterAtom(name string, atom genAtom) {
r.atomLock.Lock()
defer r.atomLock.Unlock()
if _, ok := r.Atoms[name]; ok {
panic(fmt.Sprintf("atom %s already exists", name))
}
r.Atoms[name] = atom
}
// cleanupUsedByForUnmount uses the reverse mapping for efficient cleanup
func (r *RootElem) cleanupUsedByForUnmount(comp *ComponentImpl) {
r.atomLock.Lock()
defer r.atomLock.Unlock()
// Use reverse mapping for efficient cleanup
for atomName := range comp.UsedAtoms {
if atom, ok := r.Atoms[atomName]; ok {
atom.SetUsedBy(comp.WaveId, false)
}
}
// Clear the component's atom tracking
comp.UsedAtoms = nil
}
func (r *RootElem) updateComponentAtomUsage(comp *ComponentImpl, newUsedAtoms map[string]bool) {
r.atomLock.Lock()
defer r.atomLock.Unlock()
oldUsedAtoms := comp.UsedAtoms
// Remove component from atoms it no longer uses
for atomName := range oldUsedAtoms {
if !newUsedAtoms[atomName] {
if atom, ok := r.Atoms[atomName]; ok {
atom.SetUsedBy(comp.WaveId, false)
}
}
}
// Add component to atoms it now uses
for atomName := range newUsedAtoms {
if !oldUsedAtoms[atomName] {
if atom, ok := r.Atoms[atomName]; ok {
atom.SetUsedBy(comp.WaveId, true)
}
}
}
// Update component's atom usage map
if len(newUsedAtoms) == 0 {
comp.UsedAtoms = nil
} else {
comp.UsedAtoms = make(map[string]bool)
for atomName := range newUsedAtoms {
comp.UsedAtoms[atomName] = true
}
}
}
func (r *RootElem) AtomAddRenderWork(atomName string) {
r.atomLock.Lock()
defer r.atomLock.Unlock()
atom, ok := r.Atoms[atomName]
if !ok {
return
}
usedBy := atom.GetUsedBy()
if len(usedBy) == 0 {
return
}
for _, compId := range usedBy {
r.addRenderWork(compId)
}
}
func (r *RootElem) GetAtomVal(name string) any {
r.atomLock.Lock()
defer r.atomLock.Unlock()
atom, ok := r.Atoms[name]
if !ok {
return nil
}
return atom.GetVal()
}
func (r *RootElem) SetAtomVal(name string, val any) error {
r.atomLock.Lock()
defer r.atomLock.Unlock()
atom, ok := r.Atoms[name]
if !ok {
return fmt.Errorf("atom %q not found", name)
}
return atom.SetVal(val)
}
func (r *RootElem) RemoveAtom(name string) {
r.atomLock.Lock()
defer r.atomLock.Unlock()
delete(r.Atoms, name)
}
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() != 1 {
return fmt.Errorf("Component function must take exactly 1 argument")
}
if rtype.NumOut() != 1 {
return fmt.Errorf("Component function must return exactly 1 value")
}
// first argument can be a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it)
arg1Type := rtype.In(0)
if arg1Type.Kind() == reflect.Ptr {
arg1Type = arg1Type.Elem()
}
if arg1Type.Kind() == reflect.Map {
if arg1Type.Key().Kind() != reflect.String ||
!(arg1Type.Elem().Kind() == reflect.Interface && arg1Type.Elem().NumMethod() == 0) {
return fmt.Errorf("Map argument must be map[string]any")
}
} else if arg1Type.Kind() != reflect.Struct &&
!(arg1Type.Kind() == reflect.Interface && arg1Type.NumMethod() == 0) {
return fmt.Errorf("Component function 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 callVDomFn(fnVal any, data vdom.VDomEvent) {
if fnVal == nil {
return
}
fn := fnVal
if vdf, ok := fnVal.(*vdom.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(event vdom.VDomEvent, globalEventHandler func(vdom.VDomEvent)) {
defer func() {
if event.GlobalEventType != "" {
util.PanicHandler(fmt.Sprintf("Global event handler - event:%s", event.GlobalEventType), recover())
} else {
comp := r.CompMap[event.WaveId]
tag := ""
if comp != nil && comp.Elem != nil {
tag = comp.Elem.Tag
}
compName := ""
if comp != nil {
compName = comp.ContainingComp
}
util.PanicHandler(fmt.Sprintf("Event handler - comp: %s, tag: %s, prop: %s", compName, tag, event.EventType), recover())
}
}()
eventCtx := &EventContextImpl{Event: event, Root: r}
withGlobalEventCtx(eventCtx, func() any {
if event.GlobalEventType != "" {
if globalEventHandler == nil {
log.Printf("global event %s but no handler", event.GlobalEventType)
return nil
}
globalEventHandler(event)
return nil
}
comp := r.CompMap[event.WaveId]
if comp == nil || comp.Elem == nil {
return nil
}
fnVal := comp.Elem.Props[event.EventType]
callVDomFn(fnVal, event)
return nil
})
}
func (r *RootElem) runEffectUnmount(work *EffectWorkElem, hook *Hook) {
defer func() {
comp := r.CompMap[work.WaveId]
compName := ""
if comp != nil {
compName = comp.ContainingComp
}
util.PanicHandler(fmt.Sprintf("UseEffect unmount - comp: %s", compName), recover())
}()
if hook.UnmountFn == nil {
return
}
effectCtx := &EffectContextImpl{
WorkElem: *work,
WorkType: "unmount",
Root: r,
}
withGlobalEffectCtx(effectCtx, func() any {
hook.UnmountFn()
return nil
})
}
func (r *RootElem) runEffect(work *EffectWorkElem, hook *Hook) {
defer func() {
comp := r.CompMap[work.WaveId]
compName := ""
if comp != nil {
compName = comp.ContainingComp
}
util.PanicHandler(fmt.Sprintf("UseEffect run - comp: %s", compName), recover())
}()
if hook.Fn == nil {
return
}
effectCtx := &EffectContextImpl{
WorkElem: *work,
WorkType: "run",
Root: r,
}
unmountFn := withGlobalEffectCtx(effectCtx, func() func() {
return hook.Fn()
})
hook.UnmountFn = unmountFn
}
// 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(opts *RenderOpts) {
workQueue := r.EffectWorkQueue
r.EffectWorkQueue = nil
// first, run effect cleanups
for _, work := range workQueue {
comp := r.CompMap[work.WaveId]
if comp == nil {
continue
}
hook := comp.Hooks[work.EffectIndex]
r.runEffectUnmount(work, hook)
}
// now run, new effects
for _, work := range workQueue {
comp := r.CompMap[work.WaveId]
if comp == nil {
continue
}
hook := comp.Hooks[work.EffectIndex]
r.runEffect(work, hook)
}
// now check if we need a render
renderIds := r.getAndClearRenderWork()
if len(renderIds) > 0 {
r.render(r.Root.Elem, &r.Root, "root", opts)
}
}
func (r *RootElem) UpdateRef(updateRef rpctypes.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.(*vdom.VDomRef)
if !ok {
return
}
ref.HasCurrent = updateRef.HasCurrent
ref.Position = updateRef.Position
r.addRenderWork(waveId)
}
func (r *RootElem) QueueRefOp(op vdom.VDomRefOperation) {
r.RefOperations = append(r.RefOperations, op)
}
func (r *RootElem) GetRefOperations() []vdom.VDomRefOperation {
ops := r.RefOperations
r.RefOperations = nil
return ops
}

View file

@ -0,0 +1,472 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package engine
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"mime"
"net/http"
"strings"
"sync"
"time"
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
"github.com/wavetermdev/waveterm/tsunami/util"
)
const SSEKeepAliveDuration = 5 * time.Second
func init() {
// Add explicit mapping for .json files
mime.AddExtensionType(".json", "application/json")
}
type handlerOpts struct {
AssetsFS fs.FS
StaticFS fs.FS
ManifestFile []byte
}
type httpHandlers struct {
Client *ClientImpl
renderLock sync.Mutex
}
func newHTTPHandlers(client *ClientImpl) *httpHandlers {
return &httpHandlers{
Client: client,
}
}
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("/dyn/", h.handleDynContent)
// Add handler for static files at /static/ path
if opts.StaticFS != nil {
mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS))
}
// Add fallback handler for embedded static files in production mode
if opts.AssetsFS != nil {
mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS))
}
}
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.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
}
startTime := time.Now()
update, err := h.processFrontendUpdate(&feUpdate)
duration := time.Since(startTime)
if err != nil {
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
return
}
if update == nil {
w.WriteHeader(http.StatusOK)
log.Printf("render %4s %4dms %4dk %s", "none", duration.Milliseconds(), 0, feUpdate.Reason)
return
}
w.Header().Set("Content-Type", "application/json")
// Encode to bytes first to calculate size
responseBytes, err := json.Marshal(update)
if err != nil {
log.Printf("failed to encode response: %v", err)
http.Error(w, "failed to encode response", http.StatusInternalServerError)
return
}
updateSizeKB := len(responseBytes) / 1024
renderType := "inc"
if update.FullUpdate {
renderType = "full"
}
log.Printf("render %4s %4dms %4dk %s", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason)
if _, err := w.Write(responseBytes); err != nil {
log.Printf("failed to write 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")
return nil, nil
}
if h.Client.GetIsDone() {
return nil, nil
}
h.Client.Root.RenderTs = feUpdate.Ts
// run events
h.Client.RunEvents(feUpdate.Events)
// 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 {
return nil, renderErr
}
update.CreateTransferElems()
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) 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, _ *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
}
var failedKeys []string
for key, value := range configData {
atomName := "$config." + key
if err := h.Client.Root.SetAtomVal(atomName, value); err != nil {
failedKeys = append(failedKeys, key)
}
}
w.Header().Set("Content-Type", "application/json")
var response map[string]any
if len(failedKeys) > 0 {
response = map[string]any{
"error": fmt.Sprintf("Failed to update keys: %s", strings.Join(failedKeys, ", ")),
}
} else {
response = map[string]any{
"success": true,
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
defer func() {
panicErr := util.PanicHandler("handleDynContent", recover())
if panicErr != nil {
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
}
}()
// Strip /assets prefix and update the request URL
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/dyn")
if r.URL.Path == "" {
r.URL.Path = "/"
}
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
}
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, 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
// 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
}
// 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")
rc.Flush()
case event := <-h.Client.SSEventCh:
if event.Event == "" {
break
}
fmt.Fprintf(w, "event: %s\n", event.Event)
fmt.Fprintf(w, "data: %s\n", string(event.Data))
fmt.Fprintf(w, "\n")
rc.Flush()
}
}
}
// 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) {
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, 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 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 the file server
fileServer.ServeHTTP(w, r)
}
}
func (h *httpHandlers) handleManifest(manifestFileBytes []byte) 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 manifestFileBytes == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(manifestFileBytes)
}
}
func (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc {
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
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
}
// 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
}
}

1
tsunami/frontend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
scaffold/

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tsunami App</title>
<link rel="icon" href="public/wave-logo-256.png" />
</head>
<body className="bg-background text-primary">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,39 @@
{
"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",
"build:dev": "NODE_ENV=development vite build --mode development",
"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",
"recharts": "^3.1.2",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.12",
"@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"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View file

@ -0,0 +1,18 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { TsunamiModel } from "@/model/tsunami-model";
import { VDomView } from "./vdom";
// Global model instance
const globalModel = new TsunamiModel();
function App() {
return (
<div className="min-h-screen bg-background text-foreground">
<VDomView model={globalModel} />
</div>
);
}
export default App;

View file

@ -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<Components> = {
h1: ({ children }) => <h1 className="text-3xl font-bold mb-4 mt-6 text-foreground">{children}</h1>,
h2: ({ children }) => <h2 className="text-2xl font-bold mb-3 mt-5 text-foreground">{children}</h2>,
h3: ({ children }) => <h3 className="text-xl font-bold mb-3 mt-4 text-foreground">{children}</h3>,
h4: ({ children }) => <h4 className="text-lg font-bold mb-2 mt-3 text-foreground">{children}</h4>,
h5: ({ children }) => <h5 className="text-base font-bold mb-2 mt-3 text-foreground">{children}</h5>,
h6: ({ children }) => <h6 className="text-sm font-bold mb-2 mt-3 text-foreground">{children}</h6>,
p: ({ children }) => <p className="mb-4 leading-relaxed text-secondary">{children}</p>,
a: ({ href, children }) => (
<a href={href} className="text-accent hover:text-accent-300 underline">
{children}
</a>
),
ul: ({ children }) => <ul className="list-disc list-inside mb-4 space-y-1 text-secondary">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside mb-4 space-y-1 text-secondary">{children}</ol>,
li: ({ children }) => <li className="ml-4">{children}</li>,
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-panel text-foreground px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
return (
<code className={className}>
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-panel text-foreground p-4 rounded-lg overflow-x-auto mb-4 text-sm font-mono">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-border pl-4 italic mb-4 text-muted">
{children}
</blockquote>
),
hr: () => <hr className="border-border my-6" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full border-collapse border border-border">
{children}
</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-4 py-2 bg-panel font-bold text-left text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-4 py-2 text-secondary">
{children}
</td>
),
};
export function Markdown({ text, style, className, scrollable = true }: MarkdownProps) {
const scrollClasses = scrollable ? "overflow-auto" : "";
const baseClasses = "prose prose-sm max-w-none";
return (
<div
className={twMerge(baseClasses, scrollClasses, className)}
style={style}
>
<ReactMarkdown components={markdownComponents}>
{text || ''}
</ReactMarkdown>
</div>
);
}

View file

@ -0,0 +1,107 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
type Props = {
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
onInput?: (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
ttlMs?: number; // default 100
ref?: React.Ref<HTMLInputElement | HTMLTextAreaElement>;
_tagName: "input" | "textarea";
} & Omit<React.InputHTMLAttributes<HTMLInputElement> & React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onInput">;
/**
* OptimisticInput - A React input component that provides optimistic UI updates for Tsunami's framework.
*
* Problem: In Tsunami's reactive framework, every onChange event is sent to the server, which can cause
* the cursor to jump or typing to feel laggy as the server responds with updates.
*
* Solution: This component applies updates optimistically by maintaining a "shadow" value that shows
* immediately in the UI while waiting for server acknowledgment. If the server responds with the same
* value within the TTL period (default 100ms), the optimistic update is confirmed. If the server
* doesn't respond or responds with a different value, the input reverts to the server value.
*
* Key behaviors:
* - For controlled inputs (value provided): Uses optimistic updates with shadow state
* - For uncontrolled inputs (value undefined): Behaves like a normal React input
* - Skips optimistic logic when disabled or readonly
* - Handles IME composition properly to avoid interfering with multi-byte character input
* - Supports both onChange and onInput event handlers
* - Preserves cursor position through React's natural behavior (no manual cursor management)
*
* Example usage:
* ```tsx
* <OptimisticInput
* value={serverValue}
* onChange={(e) => sendToServer(e.target.value)}
* ttlMs={200}
* />
* ```
*/
function OptimisticInput({ value, onChange, onInput, ttlMs = 100, ref: forwardedRef, _tagName, ...rest }: Props) {
const [shadow, setShadow] = React.useState<string | null>(null);
const timer = React.useRef<number | undefined>(undefined);
const startTTL = React.useCallback(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = window.setTimeout(() => {
// no ack within TTL → revert to server
setShadow(null);
// caret will follow serverValue; optionally restore selRef here if you track a server caret
}, ttlMs);
}, [ttlMs]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Skip validation during IME composition
// (works in modern browsers/React via nativeEvent)
// @ts-expect-error React typing doesn't surface this directly
if (e.nativeEvent?.isComposing) return;
// If uncontrolled (value is undefined), skip optimistic logic
if (value === undefined) {
onChange?.(e);
onInput?.(e);
return;
}
// Skip optimistic logic if readonly or disabled
if (rest.disabled || rest.readOnly) {
onChange?.(e);
onInput?.(e);
return;
}
const v = e.currentTarget.value;
setShadow(v); // optimistic echo
startTTL(); // wait for ack
onChange?.(e);
onInput?.(e);
};
// Ack: backend caught up → drop shadow (and stop the TTL)
React.useLayoutEffect(() => {
if (shadow !== null && shadow === value) {
setShadow(null);
if (timer.current) clearTimeout(timer.current);
}
}, [value, shadow]);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[]
);
const realValue = value === undefined ? undefined : (shadow ?? value ?? "");
if (_tagName === "textarea") {
return <textarea ref={forwardedRef as React.Ref<HTMLTextAreaElement>} value={realValue} onChange={handleChange} {...rest} />;
}
return <input ref={forwardedRef as React.Ref<HTMLInputElement>} value={realValue} onChange={handleChange} {...rest} />;
}
export default OptimisticInput;

View file

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,127 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
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 restoreVDomElems(backendUpdate: VDomBackendUpdate) {
if (!backendUpdate.transferelems || !backendUpdate.renderupdates) {
return;
}
// Step 1: Create text map from transfertext
const textMap = new Map<number, string>();
if (backendUpdate.transfertext) {
backendUpdate.transfertext.forEach((textEntry) => {
textMap.set(textEntry.id, textEntry.text);
});
}
// Step 2: Map of waveid to VDomElem, skipping any without a waveid
const elemMap = new Map<string, VDomElem>();
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 3: 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) => {
// Check if this is a text reference
if (childId.startsWith("t:")) {
const textId = parseInt(childId.slice(2));
const textContent = textMap.get(textId);
if (textContent != null) {
return {
tag: TextTag,
text: textContent,
};
}
return null;
}
// Regular element reference
return elemMap.get(childId);
})
.filter((child) => child != null); // Explicit null check
});
// Step 4: Update renderupdates with rebuilt VDomElem trees
backendUpdate.renderupdates.forEach((update) => {
if (update.vdomwaveid) {
update.vdom = elemMap.get(update.vdomwaveid);
}
});
}
export function applyCanvasOp(canvas: HTMLCanvasElement, canvasOp: VDomRefOperation, refStore: Map<string, any>) {
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}`);
}
}

View file

@ -0,0 +1,620 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import debug from "debug";
import * as jotai from "jotai";
import { getOrCreateClientId } from "@/util/clientid";
import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import { PLATFORM, PlatformMacOS } from "@/util/platformutil";
import { getDefaultStore } from "jotai";
import { applyCanvasOp, restoreVDomElems } from "./model-utils";
const dlog = debug("wave:vdom");
type RefContainer = {
refFn: (elem: HTMLElement) => void;
vdomRef: VDomRef;
elem: HTMLElement;
updated: boolean;
};
function makeVDomIdMap(vdom: VDomElem, idMap: Map<string, VDomElem>) {
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<any>;
event.targetvalue = changeEvent.target?.value;
event.targetchecked = changeEvent.target?.checked;
}
if (propName == "onClick" || propName == "onMouseDown") {
const mouseEvent = reactEvent as React.MouseEvent<any>;
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;
}
}
export class TsunamiModel {
clientId: string;
serverId: string;
viewRef: React.RefObject<HTMLDivElement> = { current: null };
vdomRoot: jotai.PrimitiveAtom<VDomElem> = jotai.atom();
refs: Map<string, RefContainer> = new Map(); // key is refid
batchedEvents: VDomEvent[] = [];
messages: VDomMessage[] = [];
needsResync: boolean = true;
vdomNodeVersion: WeakMap<VDomElem, jotai.PrimitiveAtom<number>> = new WeakMap();
rootRefId: string = crypto.randomUUID();
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<boolean>;
serverEventSource: EventSource;
refOutputStore: Map<string, any> = new Map();
globalVersion: jotai.PrimitiveAtom<number> = jotai.atom(0);
hasBackendWork: boolean = false;
noPadding: jotai.PrimitiveAtom<boolean>;
cachedFaviconPath: string | null = null;
reason: string | null = null;
constructor() {
this.clientId = getOrCreateClientId();
this.contextActive = jotai.atom(false);
this.reset();
this.noPadding = jotai.atom(true);
this.setupServerEventSource();
this.queueUpdate(true, "initial");
}
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, "asyncinitiation");
});
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.refs.clear();
this.batchedEvents = [];
this.messages = [];
this.needsResync = true;
this.vdomNodeVersion = new WeakMap();
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;
this.reason = null;
getDefaultStore().set(this.contextActive, false);
}
keyDownHandler(e: VDomKeyboardEvent): boolean {
if (!this.backendOpts?.globalkeyboardevents) {
return false;
}
if (e.cmd || e.meta) {
return false;
}
this.batchedEvents.push({
globaleventtype: "onKeyDown",
waveid: null,
eventtype: "onKeyDown",
keydata: e,
});
this.queueUpdate(false, "globalkeyboard");
return true;
}
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;
}
mergeReasons(newReason: string): string {
if (!this.reason) {
return newReason;
}
const existingReasons = this.reason.split(",");
const newReasons = newReason.split(",");
for (const reason of newReasons) {
if (!existingReasons.includes(reason)) {
existingReasons.push(reason);
}
}
return existingReasons.join(",");
}
queueUpdate(quick: boolean = false, reason: string | null) {
if (this.disposed) {
return;
}
if (reason) {
this.reason = this.mergeReasons(reason);
}
this.needsUpdate = true;
let delay = 10;
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) {
return;
}
if (this.hasPendingRequest) {
if (force) {
this.needsImmediateUpdate = true;
}
return;
}
if (!force && !this.needsUpdate) {
return;
}
this.hasPendingRequest = true;
this.needsImmediateUpdate = false;
try {
const feUpdate = this.createFeUpdate();
dlog("fe-update", feUpdate);
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}`);
}
// 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);
dlog("be-update", backendUpdate);
this.handleBackendUpdate(backendUpdate);
}
dlog("update cycle done");
} finally {
this.lastUpdateTs = Date.now();
this.hasPendingRequest = false;
}
if (this.needsImmediateUpdate) {
this.queueUpdate(true, null); // reason should already be set, dont try to add a new one
}
}
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;
}
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);
getDefaultStore().set(atom, getDefaultStore().get(atom) + 1);
}
addErrorMessage(message: string) {
this.messages.push({
messagetype: "error",
message: message,
});
}
handleRenderUpdates(update: VDomBackendUpdate, idMap: Map<string, VDomElem>) {
if (!update.renderupdates) {
return;
}
for (let renderUpdate of update.renderupdates) {
if (renderUpdate.updatetype == "root") {
getDefaultStore().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}`);
}
}
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<string, VDomElem>) {
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}`);
}
}
}
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 reset if needed
if (this.serverId != null && this.serverId !== update.serverid) {
// Server ID changed - reset the model state
this.reset();
this.setupServerEventSource();
}
this.serverId = update.serverid;
getDefaultStore().set(this.contextActive, true);
const idMap = new Map<string, VDomElem>();
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;
}
if (update.opts.faviconpath !== undefined) {
this.updateFavicon(update.opts.faviconpath);
}
}
makeVDomIdMap(vdomRoot, idMap);
this.handleRenderUpdates(update, idMap);
this.handleRefOperations(update, idMap);
if (update.messages) {
for (let message of update.messages) {
console.log("vdom-message", message.messagetype, message.message);
if (message.stacktrace) {
console.log("vdom-message-stacktrace", message.stacktrace);
}
}
}
getDefaultStore().set(this.globalVersion, getDefaultStore().get(this.globalVersion) + 1);
if (update.haswork) {
this.hasBackendWork = true;
}
}
renderDone(version: number) {
// called when the render is done
dlog("renderDone", version);
let reasons: string[] = [];
let needsQueue = false;
if (this.hasRefUpdates()) {
reasons.push("refupdates");
needsQueue = true;
}
if (this.hasBackendWork) {
reasons.push("backendwork");
needsQueue = true;
this.hasBackendWork = false;
}
if (needsQueue) {
this.queueUpdate(true, reasons.join(","));
}
}
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, "event");
}
createFeUpdate(): VDomFrontendUpdate {
const isFocused = document.hasFocus();
const renderContext: VDomRenderContext = {
focused: isFocused,
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(),
clientid: this.clientId,
rendercontext: renderContext,
dispose: this.shouldDispose,
resync: this.needsResync,
events: this.batchedEvents,
refupdates: this.getRefUpdates(),
reason: this.reason,
};
this.needsResync = false;
this.batchedEvents = [];
this.reason = null;
if (this.shouldDispose) {
this.disposed = true;
}
return feUpdate;
}
}

View file

@ -0,0 +1,168 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import {
LineChart,
AreaChart,
BarChart,
PieChart,
ScatterChart,
RadarChart,
ComposedChart,
CartesianGrid,
XAxis,
YAxis,
ZAxis,
Tooltip,
Legend,
Line,
Area,
Bar,
Pie,
Cell,
Scatter,
Radar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
ReferenceDot,
Brush,
ErrorBar,
LabelList,
FunnelChart,
Funnel,
Treemap,
} from "recharts";
import type { TsunamiModel } from "@/model/tsunami-model";
import { convertElemToTag } from "@/vdom";
type VDomRechartsTagType = (props: { elem: VDomElem; model: TsunamiModel }) => React.ReactElement;
// Map recharts component names to their actual components
const RechartsComponentMap: Record<string, React.ComponentType<any>> = {
LineChart,
AreaChart,
BarChart,
PieChart,
ScatterChart,
RadarChart,
ComposedChart,
CartesianGrid,
XAxis,
YAxis,
ZAxis,
Tooltip,
Legend,
Line,
Area,
Bar,
Pie,
Cell,
Scatter,
Radar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
ReferenceDot,
Brush,
ErrorBar,
LabelList,
FunnelChart,
Funnel,
Treemap,
};
// Handler for recharts components - uses the same pattern as VDomTag from vdom.tsx
function RechartsTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
// Convert props
const props = convertRechartsProps(model, elem);
// Extract the component name from the tag (remove "recharts:" prefix)
const componentName = elem.tag.replace("recharts:", "");
// Get the React component from the map
const RechartsComponent = RechartsComponentMap[componentName];
if (!RechartsComponent) {
return <div>{"Invalid Recharts Component <" + elem.tag + ">"}</div>;
}
const children = convertRechartsChildren(elem, model);
// Add the waveid as key
props.key = "recharts-" + elem.waveid;
return React.createElement(RechartsComponent, props, children);
}
// Simplified version of useVDom for recharts - handles basic prop conversion
function convertRechartsProps(model: TsunamiModel, elem: VDomElem): any {
// For now, do a basic prop conversion without full binding support
// This can be enhanced later to use the full useVDom functionality
if (!elem.props) {
return {};
}
const props: any = {};
for (const [key, value] of Object.entries(elem.props)) {
if (value != null) {
props[key] = value;
}
}
return props;
}
// Convert children for recharts components - return literal Recharts components
function convertRechartsChildren(elem: VDomElem, model: TsunamiModel): React.ReactNode[] | null {
if (!elem.children || elem.children.length === 0) {
return null;
}
const children: React.ReactNode[] = [];
for (const child of elem.children) {
if (!child) continue;
if (child.tag === "#text") {
// Allow text nodes (rare but valid)
children.push(child.text ?? "");
continue;
}
if (child.tag?.startsWith("recharts:")) {
// Extract component name and get the actual Recharts component
const componentName = child.tag.replace("recharts:", "");
const RechartsComponent = RechartsComponentMap[componentName];
if (RechartsComponent) {
// Convert props using the same logic as convertRechartsProps
const childProps = convertRechartsProps(model, child);
childProps.key = "recharts-" + child.waveid;
// Recursively convert children
const grandChildren = convertRechartsChildren(child, model);
// Create the raw Recharts component directly
children.push(React.createElement(RechartsComponent, childProps, grandChildren));
}
continue;
}
// Non-Recharts nodes under charts aren't supported; drop silently
// Could add warning: console.warn("Unsupported child type in Recharts:", child.tag);
}
return children.length > 0 ? children : null;
}
export { RechartsTag };

View file

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

15
tsunami/frontend/src/types/custom.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
type KeyPressDecl = {
mods: {
Cmd?: boolean;
Option?: boolean;
Shift?: boolean;
Ctrl?: boolean;
Alt?: boolean;
Meta?: boolean;
};
key: string;
keyType: string;
};

188
tsunami/frontend/src/types/vdom.d.ts vendored Normal file
View file

@ -0,0 +1,188 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// rpctypes.VDomBackendOpts
type VDomBackendOpts = {
globalkeyboardevents?: boolean;
title?: string;
faviconpath?: string;
};
// rpctypes.VDomBackendUpdate
type VDomBackendUpdate = {
type: "backendupdate";
ts: number;
serverid: string;
opts?: VDomBackendOpts;
haswork?: boolean;
fullupdate?: boolean;
renderupdates?: VDomRenderUpdate[];
transferelems?: VDomTransferElem[];
transfertext?: VDomText[];
refoperations?: VDomRefOperation[];
messages?: VDomMessage[];
};
// rpctypes.RenderedElem
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?: VDomKeyboardEvent;
mousedata?: VDomPointerData;
};
// vdom.VDomFrontendUpdate
type VDomFrontendUpdate = {
type: "frontendupdate";
ts: number;
clientid: string;
forcetakeover?: boolean;
correlationid?: string;
reason?: string;
dispose?: boolean;
resync?: boolean;
rendercontext: VDomRenderContext;
events?: VDomEvent[];
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;
};
// rpctypes.VDomRefUpdate
type VDomRefUpdate = {
refid: string;
hascurrent: boolean;
position?: VDomRefPosition;
};
// rpctypes.VDomRenderContext
type VDomRenderContext = {
focused: boolean;
width: number;
height: number;
rootrefid: string;
background?: boolean;
};
// rpctypes.VDomRenderUpdate
type VDomRenderUpdate = {
updatetype: "root" | "append" | "replace" | "remove" | "insert";
waveid?: string;
vdomwaveid?: string;
vdom?: VDomElem;
index?: number;
};
// rpctypes.VDomTransferElem
type VDomTransferElem = {
waveid?: string;
tag: string;
props?: { [key: string]: any };
children?: string[];
text?: string;
};
// rpctypes.VDomText
type VDomText = {
id: number;
text: string;
};
// rpctypes.VDomUrlRequestResponse
type VDomUrlRequestResponse = {
statuscode?: number;
headers?: { [key: string]: string };
body?: Uint8Array;
};
// vdom.VDomKeyboardEvent
type VDomKeyboardEvent = {
type: string;
key: string;
code: string;
shift?: boolean;
control?: boolean;
alt?: boolean;
meta?: boolean;
cmd?: boolean;
option?: boolean;
repeat?: boolean;
location?: number;
};
// vdom.VDomPointerData
type VDomPointerData = {
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;
};

View file

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

View file

@ -0,0 +1,342 @@
// 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: VDomKeyboardEvent) => 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: VDomKeyboardEvent): 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: VDomKeyboardEvent): boolean {
if (event.alt || event.meta || event.control) {
return false;
}
return countGraphemes(event.key) == 1;
}
const inputKeyMap = new Map<string, boolean>([
["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: VDomKeyboardEvent): boolean {
if (isCharacterKeyEvent(event)) {
return true;
}
for (let key of inputKeyMap.keys()) {
if (checkKeyPressed(event, key)) {
return true;
}
}
}
function checkKeyPressed(event: VDomKeyboardEvent, 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): VDomKeyboardEvent {
let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;
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;
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): VDomKeyboardEvent {
let rtn: VDomKeyboardEvent = {} as VDomKeyboardEvent;
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: VDomKeyboardEvent): 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,
};

View file

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

View file

@ -0,0 +1,344 @@
// 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 { twMerge } from "tailwind-merge";
import { Markdown } from "@/element/markdown";
import { getTextChildren } from "@/model/model-utils";
import type { TsunamiModel } from "@/model/tsunami-model";
import { RechartsTag } from "@/recharts/recharts";
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
import OptimisticInput from "./input";
const TextTag = "#text";
const FragmentTag = "#fragment";
const WaveTextTag = "wave:text";
const WaveNullTag = "wave:null";
const StyleTagName = "style";
const VDomObjType_Ref = "ref";
const VDomObjType_Func = "func";
const dlog = debug("wave:vdom");
type VDomReactTagType = (props: { elem: VDomElem; model: TsunamiModel }) => React.ReactElement;
const WaveTagMap: Record<string, VDomReactTagType> = {
"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);
};
}
export 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);
}
type GenericPropsType = { [key: string]: any };
function convertProps(elem: VDomElem, model: TsunamiModel): GenericPropsType {
let props: GenericPropsType = {};
if (elem.props == null) {
return props;
}
for (let key in elem.props) {
let val = elem.props[key];
if (val == null) {
continue;
}
if (key == "ref") {
if (val == null) {
continue;
}
if (isObject(val) && val.type == VDomObjType_Ref) {
const valRef = val as VDomRef;
const refContainer = model.getOrCreateRefContainer(valRef);
props[key] = refContainer.refFn;
}
continue;
}
if (key == "className" && typeof val === "string") {
props[key] = twMerge(val);
continue;
}
if (isObject(val) && val.type == VDomObjType_Func) {
const valFunc = val as VDomFunc;
props[key] = convertVDomFunc(model, valFunc, elem.waveid, key);
continue;
}
props[key] = val;
}
return props;
}
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 useVDom(model: TsunamiModel, elem: VDomElem): GenericPropsType {
const version = jotai.useAtomValue(model.getVDomNodeVersionAtom(elem)); // this triggers updates when vdom nodes change
let props = convertProps(elem, model);
return props;
}
function WaveMarkdown({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
const props = useVDom(model, elem);
return (
<Markdown text={props?.text} style={props?.style} className={props?.className} scrollable={props?.scrollable} />
);
}
function StyleTag({ elem, model }: { elem: VDomElem; model: TsunamiModel }) {
const styleText = getTextChildren(elem);
if (styleText == null) {
return null;
}
return <style>{styleText}</style>;
}
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;
}
// Dispatch recharts: prefixed tags to RechartsTag
if (elem.tag.startsWith("recharts:")) {
return <RechartsTag elem={elem} model={model} />;
}
const waveTag = WaveTagMap[elem.tag];
if (waveTag) {
return waveTag({ elem, model });
}
if (elem.tag == StyleTagName) {
return <StyleTag elem={elem} model={model} />;
}
if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
}
let childrenComps = convertChildren(elem, model);
if (elem.tag == FragmentTag) {
return childrenComps;
}
// Use OptimisticInput for input and textarea elements
if (elem.tag === "input" || elem.tag === "textarea") {
props.key = "e-" + elem.waveid;
const optimisticProps = {
...props,
_tagName: elem.tag as "input" | "textarea",
};
return React.createElement(OptimisticInput, optimisticProps, childrenComps);
}
props.key = "e-" + elem.waveid;
return React.createElement(elem.tag, props, childrenComps);
}
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 <div className="vdom">{rtn}</div>;
}
type VDomViewProps = {
model: TsunamiModel;
};
function VDomInnerView({ model }: VDomViewProps) {
let [styleMounted, setStyleMounted] = React.useState(false);
const handleStyleLoad = () => {
setStyleMounted(true);
};
return (
<>
<link rel="stylesheet" href={`/static/tw.css?x=${model.serverId}`} onLoad={handleStyleLoad} />
{styleMounted ? <VDomRoot model={model} /> : null}
</>
);
}
function VDomView({ model }: VDomViewProps) {
let viewRef = React.useRef(null);
let contextActive = jotai.useAtomValue(model.contextActive);
model.viewRef = viewRef;
return (
<div className={clsx("overflow-auto w-full min-h-full")} ref={viewRef}>
{contextActive ? <VDomInnerView model={model} /> : null}
</div>
);
}
export { VDomView };

View file

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

View file

@ -0,0 +1,34 @@
// 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,
proxy: {
"/api": {
target: "http://localhost:12026",
changeOrigin: true,
},
"/assets": {
target: "http://localhost:12026",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
minify: process.env.NODE_ENV === "development" ? false : "esbuild",
sourcemap: process.env.NODE_ENV === "development" ? true : false,
},
});

15
tsunami/go.mod Normal file
View file

@ -0,0 +1,15 @@
module github.com/wavetermdev/waveterm/tsunami
go 1.24.6
require (
github.com/google/uuid v1.6.0
github.com/outrigdev/goid v0.3.0
github.com/spf13/cobra v1.10.1
golang.org/x/mod v0.27.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)

16
tsunami/go.sum Normal file
View file

@ -0,0 +1,16 @@
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws=
github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE=
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=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
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=

View file

@ -0,0 +1,72 @@
# 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
// Global keyboard events are automatically enabled when you set a global event handler
func init() {
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:
Global keyboard events are automatically enabled when you set a global event handler. Set up the handler in a place where you have access to necessary state updates.

337
tsunami/prompts/graphing.md Normal file
View file

@ -0,0 +1,337 @@
# Graphing with Recharts in Tsunami
The Tsunami framework provides seamless integration with the Recharts library (v3), allowing you to create rich, interactive charts and graphs using familiar React patterns but with Go's type safety and performance.
## How Recharts Works in Tsunami
Recharts components are accessed through the `recharts:` namespace in your VDOM elements. This tells Tsunami's renderer to dispatch these elements to the specialized recharts handler instead of creating regular HTML elements.
```go
// Basic chart structure
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": 300,
},
vdom.H("recharts:LineChart", map[string]any{
"data": chartData,
},
vdom.H("recharts:Line", map[string]any{
"dataKey": "value",
"stroke": "#8884d8",
}),
),
)
```
## Key Concepts
### Namespace Usage
All recharts components use the `recharts:` prefix and have the same names as their React counterparts:
- `recharts:ResponsiveContainer` - Container that responds to parent size changes
- `recharts:LineChart`, `recharts:AreaChart`, `recharts:BarChart` - Chart types
- `recharts:XAxis`, `recharts:YAxis` - Axis components
- `recharts:CartesianGrid`, `recharts:Tooltip`, `recharts:Legend` - Supporting components
- `recharts:Line`, `recharts:Area`, `recharts:Bar` - Data series components
Every Recharts component from the React library is available with the `recharts:` prefix.
### Data Structure
Charts expect Go structs or slices that can be serialized to JSON. Use json tags to control field names:
```go
type DataPoint struct {
Time int `json:"time"`
Value float64 `json:"value"`
Label string `json:"label"`
}
data := []DataPoint{
{Time: 1, Value: 100, Label: "Jan"},
{Time: 2, Value: 150, Label: "Feb"},
{Time: 3, Value: 120, Label: "Mar"},
}
```
### Props and Configuration
Recharts components accept the same props as the React version, passed as Go map[string]any:
```go
vdom.H("recharts:Line", map[string]any{
"type": "monotone", // Line interpolation
"dataKey": "value", // Field name from data struct
"stroke": "#8884d8", // Line color
"strokeWidth": 2, // Line thickness
"dot": false, // Hide data points
})
```
## Chart Examples
### Simple Line Chart
```go
type MetricsData struct {
Time int `json:"time"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
}
func renderLineChart(data []MetricsData) any {
return vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": 400,
},
vdom.H("recharts:LineChart", map[string]any{
"data": data,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Legend", nil),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "cpu",
"stroke": "#8884d8",
"name": "CPU %",
}),
vdom.H("recharts:Line", map[string]any{
"type": "monotone",
"dataKey": "mem",
"stroke": "#82ca9d",
"name": "Memory %",
}),
),
)
}
```
### Area Chart with Stacking
```go
func renderAreaChart(data []MetricsData) any {
return vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": 300,
},
vdom.H("recharts:AreaChart", map[string]any{
"data": data,
},
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "cpu",
"stackId": "1",
"stroke": "#8884d8",
"fill": "#8884d8",
}),
vdom.H("recharts:Area", map[string]any{
"type": "monotone",
"dataKey": "mem",
"stackId": "1",
"stroke": "#82ca9d",
"fill": "#82ca9d",
}),
),
)
}
```
### Bar Chart
```go
func renderBarChart(data []MetricsData) any {
return vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": 350,
},
vdom.H("recharts:BarChart", map[string]any{
"data": data,
},
vdom.H("recharts:CartesianGrid", map[string]any{
"strokeDasharray": "3 3",
}),
vdom.H("recharts:XAxis", map[string]any{
"dataKey": "time",
}),
vdom.H("recharts:YAxis", nil),
vdom.H("recharts:Tooltip", nil),
vdom.H("recharts:Legend", nil),
vdom.H("recharts:Bar", map[string]any{
"dataKey": "cpu",
"fill": "#8884d8",
"name": "CPU %",
}),
vdom.H("recharts:Bar", map[string]any{
"dataKey": "mem",
"fill": "#82ca9d",
"name": "Memory %",
}),
),
)
}
```
## Live Data Updates
Charts automatically re-render when their data changes through Tsunami's reactive state system:
```go
var App = app.DefineComponent("App",
func(_ struct{}) any {
// State management
chartData, setChartData, setChartDataFn := app.UseData[[]MetricsData]("metrics")
// Timer for live updates
app.UseEffect(func() func() {
ticker := time.NewTicker(1 * time.Second)
done := make(chan bool)
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
// Update data and trigger re-render
setChartDataFn(func(current []MetricsData) []MetricsData {
newPoint := generateNewDataPoint()
updated := append(current, newPoint)
// Keep only last 20 points
if len(updated) > 20 {
updated = updated[1:]
}
return updated
})
app.SendAsyncInitiation() // This is necessary to force the FE to update
}
}
}()
return func() {
ticker.Stop()
close(done)
}
}, []any{})
return renderLineChart(chartData)
},
)
```
## Responsive Design
### Container Sizing
Always use `ResponsiveContainer` for charts that should adapt to their container:
```go
// Responsive - adapts to parent container
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
})
// Fixed size
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": 400,
"height": 300,
})
```
### Mobile-Friendly Charts
Use Tailwind classes to create responsive chart layouts:
```go
vdom.H("div", map[string]any{
"className": "w-full h-64 md:h-96 lg:h-[32rem]",
},
vdom.H("recharts:ResponsiveContainer", map[string]any{
"width": "100%",
"height": "100%",
},
// chart content
),
)
```
## Advanced Features
### Custom Styling
You can customize chart appearance through props:
```go
vdom.H("recharts:Tooltip", map[string]any{
"labelStyle": map[string]any{
"color": "#333",
},
"contentStyle": map[string]any{
"backgroundColor": "#f8f9fa",
"border": "1px solid #dee2e6",
},
})
```
### Event Handling
Charts support interaction events:
```go
vdom.H("recharts:LineChart", map[string]any{
"data": chartData,
"onClick": func(event map[string]any) {
// Handle chart click
fmt.Printf("Chart clicked: %+v\n", event)
},
})
```
## Best Practices
### Data Management
- Use global atoms (app.UseData) for chart data that updates frequently
- Implement data windowing for large datasets to maintain performance
- Structure data with appropriate json tags for clean field names
### Performance
- Limit data points for real-time charts (typically 20-100 points)
- Use app.UseEffect cleanup functions to prevent memory leaks with timers
- Consider data aggregation for historical views
### Styling
- Use consistent color schemes across charts
- Leverage Tailwind classes for chart containers and surrounding UI
- Consider dark/light theme support in color choices
### State Updates
- Use functional setters (`setDataFn`) for complex data transformations
- Call app.SendAsyncInitiation() after async state updates
- Implement proper cleanup in app.UseEffect for timers and goroutines
## Differences from React Recharts
1. **Namespace**: All components use `recharts:` prefix
2. **Props**: Pass as Go `map[string]any` instead of JSX props
3. **Data**: Use Go structs with json tags instead of JavaScript objects
4. **Events**: Event handlers receive Go types, not JavaScript events
5. **Styling**: Combine Recharts styling with Tailwind classes for layout
The core Recharts API remains the same - consult the official Recharts documentation for detailed prop references and advanced features. The Tsunami integration simply adapts the React patterns to Go's type system while maintaining the familiar development experience.

1359
tsunami/prompts/system.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,190 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package rpctypes
import (
"fmt"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// rendered element (output from rendering pipeline)
type RenderedElem struct {
WaveId string `json:"waveid,omitempty"` // required, except for #text nodes
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []RenderedElem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
type VDomUrlRequestResponse struct {
StatusCode int `json:"statuscode,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
}
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"`
Reason string `json:"reason,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 []vdom.VDomEvent `json:"events,omitempty"`
RefUpdates []VDomRefUpdate `json:"refupdates,omitempty"`
Messages []VDomMessage `json:"messages,omitempty"`
}
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"`
FullUpdate bool `json:"fullupdate,omitempty"`
RenderUpdates []VDomRenderUpdate `json:"renderupdates,omitempty"`
TransferElems []VDomTransferElem `json:"transferelems,omitempty"`
TransferText []VDomText `json:"transfertext,omitempty"`
RefOperations []vdom.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"`
}
type VDomText struct {
Id int `json:"id"`
Text string `json:"text"`
}
func (beUpdate *VDomBackendUpdate) CreateTransferElems() {
var renderedElems []RenderedElem
for idx, reUpdate := range beUpdate.RenderUpdates {
if reUpdate.VDom == nil {
continue
}
renderedElems = append(renderedElems, *reUpdate.VDom)
beUpdate.RenderUpdates[idx].VDomWaveId = reUpdate.VDom.WaveId
beUpdate.RenderUpdates[idx].VDom = nil
}
transferElems, transferText := ConvertElemsToTransferElems(renderedElems)
transferElems = DedupTransferElems(transferElems)
beUpdate.TransferElems = transferElems
beUpdate.TransferText = transferText
}
func ConvertElemsToTransferElems(elems []RenderedElem) ([]VDomTransferElem, []VDomText) {
var transferElems []VDomTransferElem
var transferText []VDomText
textMap := make(map[string]int) // map text content to ID for deduplication
// Helper function to recursively process each RenderedElem in preorder
var processElem func(elem RenderedElem) string
processElem = func(elem RenderedElem) string {
// Handle #text nodes with deduplication
if elem.Tag == "#text" {
textId, exists := textMap[elem.Text]
if !exists {
// New text content, create new entry
textId = len(textMap) + 1
textMap[elem.Text] = textId
transferText = append(transferText, VDomText{
Id: textId,
Text: elem.Text,
})
}
// Return sentinel string with ID (no VDomTransferElem created)
textIdStr := fmt.Sprintf("t:%d", textId)
return textIdStr
}
// 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, transferText
}
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
}
type VDomRenderContext struct {
Focused bool `json:"focused"`
Width int `json:"width"`
Height int `json:"height"`
RootRefId string `json:"rootrefid"`
Background bool `json:"background,omitempty"`
}
type VDomRefUpdate struct {
RefId string `json:"refid"`
HasCurrent bool `json:"hascurrent"`
Position *vdom.VDomRefPosition `json:"position,omitempty"`
}
type VDomBackendOpts struct {
Title string `json:"title,omitempty"`
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
FaviconPath string `json:"faviconpath,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 *RenderedElem `json:"vdom,omitempty"` // these get removed for transfer (encoded to transferelems)
Index *int `json:"index,omitempty"`
}
type VDomMessage struct {
MessageType string `json:"messagetype"`
Message string `json:"message"`
StackTrace string `json:"stacktrace,omitempty"`
Params []any `json:"params,omitempty"`
}

View file

@ -0,0 +1,21 @@
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")
subStaticFS, _ := fs.Sub(staticFS, "static")
app.RegisterEmbeds(subDistFS, subStaticFS, nil)
app.RunMain()
}

View file

@ -0,0 +1,3 @@
dist/
node_modules/
bin/

View file

@ -0,0 +1,61 @@
/* Copyright 2025, Command Line Inc. */
/* SPDX-License-Identifier: Apache-2.0 */
@import "tailwindcss";
@source inline("bg-background text-primary"); /* index.html */
@source inline("p-4 border border-red-500 bg-red-100 text-red-800 rounded font-mono font-bold mb-2"); /* error component */
@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;
}

View file

@ -0,0 +1,3 @@
package tsunamibase
var TsunamiVersion = "0.0.0"

545
tsunami/ui/table.go Normal file
View file

@ -0,0 +1,545 @@
package ui
import (
"fmt"
"reflect"
"sort"
"strconv"
"github.com/wavetermdev/waveterm/tsunami/app"
"github.com/wavetermdev/waveterm/tsunami/vdom"
)
// Core table types
type CellContext[T any] struct {
Data T `json:"row"`
Value any `json:"value"`
RowIdx int `json:"rowIdx"`
ColIdx int `json:"colIdx"`
Column string `json:"column"`
}
type RowContext[T any] struct {
Data T `json:"row"`
RowIdx int `json:"rowIdx"`
}
type HeaderContext struct {
Column string `json:"column"`
IsSorted bool `json:"isSorted"`
SortDirection string `json:"sortDirection"`
}
// Column definition - similar to TanStack Table
type TableColumn[T any] struct {
AccessorKey string `json:"accessorKey"` // Field name in the data
AccessorFn func(rowCtx RowContext[T]) any `json:"-"` // Function to extract value from row
Header string `json:"header"` // Display name
Width string `json:"width,omitempty"`
Sortable bool `json:"sortable"`
CellClassName string
HeaderClassName string
CellRender func(ctx CellContext[T]) any `json:"-"` // Custom cell renderer
HeaderRender func(ctx HeaderContext) any `json:"-"` // Custom header renderer
}
// Table props
type TableProps[T any] struct {
Data []T `json:"data"`
Columns []TableColumn[T] `json:"columns"`
RowRender func(ctx RowContext[T]) any `json:"-"` // Custom row renderer (overrides columns)
RowClassName func(ctx RowContext[T]) string `json:"-"` // Custom row class names
OnRowClick func(row T, idx int) `json:"-"`
OnSort func(column string, direction string) `json:"-"`
DefaultSort string `json:"defaultSort,omitempty"`
Pagination *PaginationConfig `json:"pagination,omitempty"`
Selectable bool `json:"selectable"`
SelectedRows []int `json:"selectedRows,omitempty"`
OnSelectionChange func(selectedRows []int) `json:"-"`
}
type PaginationConfig struct {
PageSize int `json:"pageSize"`
CurrentPage int `json:"currentPage"`
ShowSizes []int `json:"showSizes,omitempty"` // [10, 25, 50, 100]
}
// Helper to extract field value from struct/map using reflection
func getFieldValueWithReflection(item any, key string) any {
if item == nil {
return nil
}
// Handle map[string]any
if m, ok := item.(map[string]any); ok {
return m[key]
}
// Handle struct via reflection
v := reflect.ValueOf(item)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil
}
field := v.FieldByName(key)
if !field.IsValid() {
return nil
}
return field.Interface()
}
// Generic helper to extract field value using either AccessorFn or reflection
func getFieldValue[T any](row T, rowIdx int, col TableColumn[T]) any {
if col.AccessorFn != nil {
return col.AccessorFn(RowContext[T]{
Data: row,
RowIdx: rowIdx,
})
}
return getFieldValueWithReflection(row, col.AccessorKey)
}
// Helper to find column by accessor key
func findColumnByKey[T any](columns []TableColumn[T], key string) *TableColumn[T] {
for _, col := range columns {
if col.AccessorKey == key {
return &col
}
}
return nil
}
// Sort data by column
func sortData[T any](data []T, col TableColumn[T], direction string) []T {
if len(data) == 0 || (col.AccessorKey == "" && col.AccessorFn == nil) {
return data
}
sorted := make([]T, len(data))
copy(sorted, data)
sort.Slice(sorted, func(i, j int) bool {
valI := getFieldValue(sorted[i], i, col)
valJ := getFieldValue(sorted[j], j, col)
// Handle nil values
if valI == nil && valJ == nil {
return false
}
if valI == nil {
return direction == "asc"
}
if valJ == nil {
return direction == "desc"
}
// Convert to strings for comparison (could be enhanced for numbers/dates)
strI := fmt.Sprintf("%v", valI)
strJ := fmt.Sprintf("%v", valJ)
if direction == "asc" {
return strI < strJ
}
return strI > strJ
})
return sorted
}
// Paginate data
func paginateData[T any](data []T, config *PaginationConfig) []T {
if config == nil || len(data) == 0 {
return data
}
start := config.CurrentPage * config.PageSize
end := start + config.PageSize
if start >= len(data) {
return []T{}
}
if end > len(data) {
end = len(data)
}
return data[start:end]
}
// Default cell renderer
func defaultCellRenderer[T any](ctx CellContext[T]) any {
if ctx.Value == nil {
return vdom.H("span", map[string]any{
"className": "text-gray-500",
}, "-")
}
return vdom.H("span", nil, fmt.Sprintf("%v", ctx.Value))
}
// Default header renderer
func defaultHeaderRenderer(ctx HeaderContext) any {
return vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("span", nil, ctx.Column),
vdom.If(ctx.IsSorted,
vdom.H("span", map[string]any{
"className": "text-blue-400",
}, vdom.IfElse(ctx.SortDirection == "asc", "↑", "↓")),
),
)
}
// Helper function to safely call RowClassName function
func makeRowClassName[T any](rowClassNameFunc func(ctx RowContext[T]) string, rowCtx RowContext[T]) string {
if rowClassNameFunc == nil {
return ""
}
return rowClassNameFunc(rowCtx)
}
func MakeTableComponent[T any](componentName string) vdom.Component[TableProps[T]] {
return app.DefineComponent(componentName, genTableRenderFunc[T])
}
func genTableRenderFunc[T any](props TableProps[T]) any {
// State for sorting
sortColumnAtom := app.UseLocal(props.DefaultSort)
sortDirectionAtom := app.UseLocal("asc")
// State for pagination - initialize with prop values
initialPage := 0
initialPageSize := 25
if props.Pagination != nil {
initialPage = props.Pagination.CurrentPage
initialPageSize = props.Pagination.PageSize
}
currentPageAtom := app.UseLocal(initialPage)
pageSizeAtom := app.UseLocal(initialPageSize)
// State for selection - initialize with empty slice if nil
initialSelection := props.SelectedRows
if initialSelection == nil {
initialSelection = []int{}
}
selectedRowsAtom := app.UseLocal(initialSelection)
// Handle sorting
handleSort := func(column string) {
currentSort := sortColumnAtom.Get()
currentDir := sortDirectionAtom.Get()
if currentSort == column {
// Toggle direction
newDir := vdom.IfElse(currentDir == "asc", "desc", "asc").(string)
sortDirectionAtom.Set(newDir)
if props.OnSort != nil {
props.OnSort(column, newDir)
}
} else {
// New column
sortColumnAtom.Set(column)
sortDirectionAtom.Set("asc")
if props.OnSort != nil {
props.OnSort(column, "asc")
}
}
}
// Handle row selection
handleRowSelect := func(rowIdx int) {
if !props.Selectable {
return
}
selectedRowsAtom.SetFn(func(current []int) []int {
// Toggle selection
for i, idx := range current {
if idx == rowIdx {
// Remove
return append(current[:i], current[i+1:]...)
}
}
// Add
return append(current, rowIdx)
})
if props.OnSelectionChange != nil {
props.OnSelectionChange(selectedRowsAtom.Get())
}
}
// Handle pagination
handlePageChange := func(page int) {
currentPageAtom.Set(page)
}
handlePageSizeChange := func(size int) {
pageSizeAtom.Set(size)
currentPageAtom.Set(0) // Reset to first page
}
// Process data
processedData := props.Data
if sortColumnAtom.Get() != "" {
if sortCol := findColumnByKey(props.Columns, sortColumnAtom.Get()); sortCol != nil {
processedData = sortData(processedData, *sortCol, sortDirectionAtom.Get())
}
}
totalRows := len(processedData)
// Apply pagination
paginationConfig := &PaginationConfig{
PageSize: pageSizeAtom.Get(),
CurrentPage: currentPageAtom.Get(),
}
paginatedData := paginateData(processedData, paginationConfig)
// Get current state values
currentSort := sortColumnAtom.Get()
currentDir := sortDirectionAtom.Get()
currentSelected := selectedRowsAtom.Get()
return vdom.H("div", map[string]any{
"className": "w-full",
},
// Table
vdom.H("div", map[string]any{
"className": "overflow-auto border border-gray-600 rounded-lg",
},
vdom.H("table", map[string]any{
"className": "w-full bg-gray-900 text-gray-200",
},
// Header
vdom.H("thead", map[string]any{
"className": "bg-gray-800 border-b border-gray-600 text-white",
},
vdom.H("tr", nil,
vdom.If(props.Selectable,
vdom.H("th", map[string]any{
"className": "p-3 text-left",
"style": map[string]any{"width": "40px"},
},
vdom.H("input", map[string]any{
"type": "checkbox",
"checked": len(currentSelected) == len(paginatedData) && len(paginatedData) > 0,
"onChange": func() {
if len(currentSelected) == len(paginatedData) {
selectedRowsAtom.Set([]int{})
} else {
allSelected := make([]int, len(paginatedData))
for i := range paginatedData {
allSelected[i] = i
}
selectedRowsAtom.Set(allSelected)
}
},
}),
),
),
vdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {
isSorted := currentSort == col.AccessorKey
headerCtx := HeaderContext{
Column: col.Header,
IsSorted: isSorted,
SortDirection: currentDir,
}
headerContent := defaultHeaderRenderer(headerCtx)
if col.HeaderRender != nil {
headerContent = col.HeaderRender(headerCtx)
}
return vdom.H("th", map[string]any{
"key": col.AccessorKey,
"className": vdom.Classes(
"p-3 text-left font-semibold",
vdom.If(col.Sortable, "cursor-pointer hover:bg-gray-700"),
col.HeaderClassName,
),
"style": vdom.If(col.Width != "", map[string]any{"width": col.Width}),
"onClick": vdom.If(col.Sortable, func() { handleSort(col.AccessorKey) }),
}, headerContent)
}),
),
),
// Body
vdom.H("tbody", map[string]any{
"className": "divide-y divide-gray-700",
},
vdom.ForEach(paginatedData, func(row T, rowIdx int) any {
isSelected := func() bool {
for _, idx := range currentSelected {
if idx == rowIdx {
return true
}
}
return false
}()
// Custom row renderer
if props.RowRender != nil {
return props.RowRender(RowContext[T]{
Data: row,
RowIdx: rowIdx,
})
}
// Default row rendering with columns
rowCtx := RowContext[T]{
Data: row,
RowIdx: rowIdx,
}
return vdom.H("tr", map[string]any{
"key": rowIdx,
"className": vdom.Classes(
"hover:bg-gray-800 transition-colors",
vdom.If(isSelected, "bg-blue-900"),
vdom.If(props.OnRowClick != nil, "cursor-pointer"),
makeRowClassName(props.RowClassName, rowCtx),
),
"onClick": func() {
if props.OnRowClick != nil {
props.OnRowClick(row, rowIdx)
}
},
},
vdom.If(props.Selectable,
vdom.H("td", map[string]any{
"className": "p-3",
},
vdom.H("input", map[string]any{
"type": "checkbox",
"checked": isSelected,
"onChange": func() { handleRowSelect(rowIdx) },
}),
),
),
vdom.ForEach(props.Columns, func(col TableColumn[T], colIdx int) any {
var value any
value = getFieldValue(row, rowIdx, col)
cellCtx := CellContext[T]{
Data: row,
Value: value,
RowIdx: rowIdx,
ColIdx: colIdx,
Column: col.AccessorKey,
}
cellContent := defaultCellRenderer(cellCtx)
if col.CellRender != nil {
cellContent = col.CellRender(cellCtx)
}
return vdom.H("td", map[string]any{
"key": col.AccessorKey,
"className": vdom.Classes("p-3", col.CellClassName),
}, cellContent)
}),
)
}),
),
),
),
// Pagination
vdom.If(props.Pagination != nil,
renderPagination(totalRows, paginationConfig, handlePageChange, handlePageSizeChange),
),
)
}
// Pagination component
func renderPagination(totalRows int, config *PaginationConfig, onPageChange func(int), onPageSizeChange func(int)) any {
totalPages := (totalRows + config.PageSize - 1) / config.PageSize
currentPage := config.CurrentPage
return vdom.H("div", map[string]any{
"className": "flex items-center justify-between mt-4 px-4 py-3 bg-gray-800 rounded-lg",
},
// Page size selector
vdom.H("div", map[string]any{
"className": "flex items-center gap-2",
},
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, "Show"),
vdom.H("select", map[string]any{
"className": "bg-gray-700 text-white rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:bg-gray-600 mx-1",
"value": strconv.Itoa(config.PageSize),
"onChange": func(e vdom.VDomEvent) {
if size, err := strconv.Atoi(e.TargetValue); err == nil {
onPageSizeChange(size)
}
},
},
vdom.H("option", map[string]any{"value": "10"}, "10"),
vdom.H("option", map[string]any{"value": "25"}, "25"),
vdom.H("option", map[string]any{"value": "50"}, "50"),
vdom.H("option", map[string]any{"value": "100"}, "100"),
),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, "entries"),
),
// Page info
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400",
}, fmt.Sprintf("Showing %d-%d of %d",
currentPage*config.PageSize+1,
vdom.Ternary(currentPage*config.PageSize+config.PageSize > totalRows,
totalRows,
currentPage*config.PageSize+config.PageSize),
totalRows,
)),
// Page controls
vdom.H("div", map[string]any{
"className": "flex items-center gap-3",
},
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-3 py-1.5 rounded text-sm transition-colors",
vdom.IfElse(currentPage > 0,
"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer",
"bg-gray-600 text-gray-500"),
),
"disabled": currentPage <= 0,
"onClick": func() {
if currentPage > 0 {
onPageChange(currentPage - 1)
}
},
}, "Previous"),
vdom.H("span", map[string]any{
"className": "text-sm text-gray-400 px-2",
}, fmt.Sprintf("Page %d of %d", currentPage+1, totalPages)),
vdom.H("button", map[string]any{
"className": vdom.Classes(
"px-3 py-1.5 rounded text-sm transition-colors",
vdom.IfElse(currentPage < totalPages-1,
"bg-blue-600 hover:bg-blue-700 text-white cursor-pointer",
"bg-gray-600 text-gray-500"),
),
"disabled": currentPage >= totalPages-1,
"onClick": func() {
if currentPage < totalPages-1 {
onPageChange(currentPage + 1)
}
},
}, "Next"),
),
)
}

240
tsunami/util/compare.go Normal file
View file

@ -0,0 +1,240 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package util
import (
"math"
"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
}
}
// 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
}
}

121
tsunami/util/marshal.go Normal file
View file

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

233
tsunami/util/util.go Normal file
View file

@ -0,0 +1,233 @@
package util
import (
"encoding"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"runtime/debug"
"strings"
"time"
)
// PanicHandler handles panic recovery and logging.
// It can be called directly with recover() without checking for nil first.
// Example usage:
// defer func() {
// util.PanicHandler("operation name", recover())
// }()
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
}
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
}
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()
}
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 := FromFloat64[T](f64Val); convOk {
return converted
}
}
panic(fmt.Sprintf("GetTypedAtomValue %q value type mismatch (expected %T, got %T)", atomName, *new(T), rawVal))
}
return result
}
var (
jsonMarshalerT = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
textMarshalerT = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
)
func implementsJSON(t reflect.Type) bool {
if t.Implements(jsonMarshalerT) || t.Implements(textMarshalerT) {
return true
}
if t.Kind() != reflect.Pointer {
pt := reflect.PointerTo(t)
return pt.Implements(jsonMarshalerT) || pt.Implements(textMarshalerT)
}
return false
}
func ValidateAtomType(t reflect.Type, atomName string) error {
seen := make(map[reflect.Type]bool)
return validateAtomTypeRecursive(t, seen, atomName, "")
}
func makeAtomError(atomName string, parentName string, message string) error {
if parentName != "" {
return fmt.Errorf("atom %s: in %s: %s", atomName, parentName, message)
}
return fmt.Errorf("atom %s: %s", atomName, message)
}
func validateAtomTypeRecursive(t reflect.Type, seen map[reflect.Type]bool, atomName string, parentName string) error {
if t == nil {
return makeAtomError(atomName, parentName, "nil type")
}
if seen[t] {
return nil
}
seen[t] = true
// Check if type implements json.Marshaler or encoding.TextMarshaler
if implementsJSON(t) {
return nil
}
switch t.Kind() {
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64, reflect.String:
return nil
case reflect.Ptr:
return validateAtomTypeRecursive(t.Elem(), seen, atomName, parentName)
case reflect.Array, reflect.Slice:
elemType := t.Elem()
// Allow []any as a JSON value slot
if elemType.Kind() == reflect.Interface && elemType.NumMethod() == 0 {
return nil
}
return validateAtomTypeRecursive(elemType, seen, atomName, parentName)
case reflect.Map:
if t.Key().Kind() != reflect.String {
return makeAtomError(atomName, parentName, fmt.Sprintf("map key must be string, got %s", t.Key().Kind()))
}
elemType := t.Elem()
// Allow map[string]any as a JSON value slot
if elemType.Kind() == reflect.Interface && elemType.NumMethod() == 0 {
return nil
}
return validateAtomTypeRecursive(elemType, seen, atomName, parentName)
case reflect.Struct:
structName := t.Name()
if structName == "" {
structName = "anonymous struct"
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
fieldPath := fmt.Sprintf("%s.%s", structName, field.Name)
if !field.IsExported() {
return makeAtomError(atomName, fieldPath, "field is not exported (cannot round trip)")
}
// Check for json:"-" tag
if tag := field.Tag.Get("json"); tag != "" {
if name, _, _ := strings.Cut(tag, ","); name == "-" {
return makeAtomError(atomName, fieldPath, `field has json:"-" (breaks round trip)`)
}
}
if err := validateAtomTypeRecursive(field.Type, seen, atomName, fieldPath); err != nil {
return err
}
}
return nil
case reflect.Interface:
// Allow empty interface (any) as JSON value slot
if t.NumMethod() == 0 {
return nil
}
return makeAtomError(atomName, parentName, "non-empty interface types are not JSON serializable (cannot round trip)")
case reflect.Func, reflect.Chan, reflect.UnsafePointer, reflect.Uintptr, reflect.Complex64, reflect.Complex128:
return makeAtomError(atomName, parentName, fmt.Sprintf("type %s is not JSON serializable", t.Kind()))
default:
return makeAtomError(atomName, parentName, fmt.Sprintf("unsupported type %s", t.Kind()))
}
}

178
tsunami/vdom/vdom.go Normal file
View file

@ -0,0 +1,178 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdom
import (
"fmt"
"reflect"
"strings"
)
// ReactNode types = nil | string | Elem
type Component[P any] func(props P) *VDomElem
// WithKey sets the key property of the VDomElem and returns the element.
// This is particularly useful for defined components since their prop types won't include keys.
// Returns nil if the element is nil, otherwise returns the same element for chaining.
func (e *VDomElem) WithKey(key any) *VDomElem {
if e == nil {
return nil
}
if e.Props == nil {
e.Props = make(map[string]any)
}
e.Props[KeyPropKey] = fmt.Sprint(key)
return e
}
func textElem(text string) VDomElem {
return VDomElem{Tag: TextTag, Text: text}
}
func partToClasses(class any) []string {
if class == nil {
return nil
}
switch c := class.(type) {
case string:
if c != "" {
return []string{c}
}
case []string:
var parts []string
for _, s := range c {
if s != "" {
parts = append(parts, s)
}
}
return parts
case map[string]bool:
var parts []string
for k, v := range c {
if v && k != "" {
parts = append(parts, k)
}
}
return parts
case []any:
var parts []string
for _, item := range c {
parts = append(parts, partToClasses(item)...)
}
return parts
}
return nil
}
// Classes combines multiple class values into a single space-separated string.
// Similar to the JavaScript clsx library, it accepts:
// - strings: added directly if non-empty
// - nil: ignored (useful for vdom.If() statements)
// - []string: all non-empty strings are added
// - map[string]bool: keys with true values are added
// - []any: recursively processed
//
// Returns a space-separated string of all valid class names.
func Classes(classes ...any) string {
var parts []string
for _, class := range classes {
parts = append(parts, partToClasses(class)...)
}
return strings.Join(parts, " ")
}
// H creates a VDomElem with the specified tag, properties, and children.
// This is the primary function for creating virtual DOM elements.
// Children can be strings, VDomElems, *VDomElem, slices, booleans, numeric types,
// or other types which are converted to strings using fmt.Sprint.
// nil children are allowed and removed from the final list.
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 := ToElems(part)
rtn.Children = append(rtn.Children, elems...)
}
}
return rtn
}
// If returns the provided part if the condition is true, otherwise returns nil.
// This is useful for conditional rendering in VDOM children lists, props, and style attributes.
func If(cond bool, part any) any {
if cond {
return part
}
return nil
}
// IfElse returns part if the condition is true, otherwise returns elsePart.
// This provides ternary-like conditional logic for VDOM children, props, and attributes.
// Accepts mixed types - part and elsePart don't need to be the same type, which is especially useful for children.
func IfElse(cond bool, part any, elsePart any) any {
if cond {
return part
}
return elsePart
}
// Ternary returns trueRtn if the condition is true, otherwise returns falseRtn.
// Unlike IfElse, this enforces type safety by requiring both return values to be the same type T.
func Ternary[T any](cond bool, trueRtn T, falseRtn T) T {
if cond {
return trueRtn
} else {
return falseRtn
}
}
// ForEach applies a function to each item in a slice and returns a slice of results.
// The function receives the item and its index, and can return any type for flexible VDOM generation.
func ForEach[T any](items []T, fn func(T, int) any) []any {
elems := make([]any, 0, len(items))
for idx, item := range items {
fnResult := fn(item, idx)
elems = append(elems, fnResult)
}
return elems
}
// ToElems converts various types into VDomElem slices for use in VDOM children.
// It handles strings, booleans, VDomElems, *VDomElem, slices, and other types
// by converting them to appropriate VDomElem representations.
// nil values are ignored and removed from the final slice.
// This is primarily an internal function and not typically called directly by application code.
func ToElems(part any) []VDomElem {
if part == nil {
return nil
}
switch partTyped := part.(type) {
case string:
return []VDomElem{textElem(partTyped)}
case bool:
// matches react
if partTyped {
return []VDomElem{textElem("true")}
}
return nil
case VDomElem:
return []VDomElem{partTyped}
case *VDomElem:
if partTyped == nil {
return nil
}
return []VDomElem{*partTyped}
default:
partVal := reflect.ValueOf(part)
if partVal.Kind() == reflect.Slice {
var rtn []VDomElem
for i := 0; i < partVal.Len(); i++ {
rtn = append(rtn, ToElems(partVal.Index(i).Interface())...)
}
return rtn
}
return []VDomElem{textElem(fmt.Sprint(part))}
}
}

100
tsunami/vdom/vdom_test.go Normal file
View file

@ -0,0 +1,100 @@
package vdom
import (
"encoding/json"
"log"
"reflect"
"testing"
"github.com/wavetermdev/waveterm/tsunami/util"
)
func TestH(t *testing.T) {
elem := H("div", nil, "clicked")
jsonBytes, _ := json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
elem = H("div", nil, "clicked")
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
elem = H("Button", nil, "foo")
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
clickFn := "test-click-function"
clickedDiv := H("div", nil, "test-content")
elem = H("div", nil,
H("h1", nil, "hello world"),
H("Button", map[string]any{"onClick": clickFn}, "hello"),
clickedDiv,
)
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
}
func TestJsonH(t *testing.T) {
elem := H("div", map[string]any{
"data1": 5,
"data2": []any{1, 2, 3},
"data3": map[string]any{"a": 1},
})
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)
}

120
tsunami/vdom/vdom_types.go Normal file
View file

@ -0,0 +1,120 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdom
const TextTag = "#text"
const WaveTextTag = "wave:text"
const WaveNullTag = "wave:null"
const FragmentTag = "#fragment"
const KeyPropKey = "key"
const ObjectType_Ref = "ref"
const ObjectType_Func = "func"
// vdom element
type VDomElem struct {
Tag string `json:"tag"`
Props map[string]any `json:"props,omitempty"`
Children []VDomElem `json:"children,omitempty"`
Text string `json:"text,omitempty"`
}
// 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"` // set to call e.stopPropagation() on the client side
PreventDefault bool `json:"preventdefault,omitempty"` // set to call e.preventDefault() on the client side
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"`
}
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)
}
type VDomRefOperation struct {
RefId string `json:"refid"`
Op string `json:"op"`
Params []any `json:"params,omitempty"`
OutputRef string `json:"outputref,omitempty"`
}

252
yarn.lock
View file

@ -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"
@ -6475,6 +6636,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 +6706,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 +7069,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 +14004,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"
@ -15826,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
@ -18077,7 +18277,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 +18426,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 +18570,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 +21457,28 @@ __metadata:
languageName: node
linkType: hard
"tsunami-frontend@workspace:tsunami/frontend":
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"
"@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"