mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
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:
parent
fb30d7fff3
commit
e7cd584659
96 changed files with 21457 additions and 17 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -59,6 +59,7 @@
|
|||
"gopls": {
|
||||
"analyses": {
|
||||
"QF1003": false
|
||||
}
|
||||
},
|
||||
"directoryFilters": ["-tsunami/frontend/scaffold"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
Taskfile.yml
87
Taskfile.yml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
.view-vdom {
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
|
|
@ -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
2
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module github.com/wavetermdev/waveterm
|
||||
|
||||
go 1.24.2
|
||||
go 1.24.6
|
||||
|
||||
require (
|
||||
github.com/alexflint/go-filemutex v1.3.0
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@
|
|||
},
|
||||
"packageManager": "yarn@4.6.0",
|
||||
"workspaces": [
|
||||
"docs"
|
||||
"docs",
|
||||
"tsunami/frontend"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
BIN
public/logos/wave-logo-256.png
Normal file
BIN
public/logos/wave-logo-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
1
tsunami/.gitignore
vendored
Normal file
1
tsunami/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
bin/
|
||||
123
tsunami/app/atom.go
Normal file
123
tsunami/app/atom.go
Normal 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)
|
||||
}
|
||||
110
tsunami/app/defaultclient.go
Normal file
110
tsunami/app/defaultclient.go
Normal 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
198
tsunami/app/hooks.go
Normal 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
775
tsunami/build/build.go
Normal 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
228
tsunami/build/buildutil.go
Normal 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
126
tsunami/cmd/main-tsunami.go
Normal 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
1
tsunami/demo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
test/
|
||||
360
tsunami/demo/cpuchart/app.go
Normal file
360
tsunami/demo/cpuchart/app.go
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
23
tsunami/demo/cpuchart/go.mod
Normal file
23
tsunami/demo/cpuchart/go.mod
Normal 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
|
||||
36
tsunami/demo/cpuchart/go.sum
Normal file
36
tsunami/demo/cpuchart/go.sum
Normal 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=
|
||||
1276
tsunami/demo/cpuchart/static/tw.css
Normal file
1276
tsunami/demo/cpuchart/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
422
tsunami/demo/githubaction/app.go
Normal file
422
tsunami/demo/githubaction/app.go
Normal 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
12
tsunami/demo/githubaction/go.mod
Normal file
12
tsunami/demo/githubaction/go.mod
Normal 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
|
||||
4
tsunami/demo/githubaction/go.sum
Normal file
4
tsunami/demo/githubaction/go.sum
Normal 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=
|
||||
1333
tsunami/demo/githubaction/static/tw.css
Normal file
1333
tsunami/demo/githubaction/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
204
tsunami/demo/pomodoro/app.go
Normal file
204
tsunami/demo/pomodoro/app.go
Normal 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,
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
12
tsunami/demo/pomodoro/go.mod
Normal file
12
tsunami/demo/pomodoro/go.mod
Normal 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
|
||||
4
tsunami/demo/pomodoro/go.sum
Normal file
4
tsunami/demo/pomodoro/go.sum
Normal 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=
|
||||
1240
tsunami/demo/pomodoro/static/tw.css
Normal file
1240
tsunami/demo/pomodoro/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
459
tsunami/demo/recharts/app.go
Normal file
459
tsunami/demo/recharts/app.go
Normal 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{}{}),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
12
tsunami/demo/recharts/go.mod
Normal file
12
tsunami/demo/recharts/go.mod
Normal 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
|
||||
4
tsunami/demo/recharts/go.sum
Normal file
4
tsunami/demo/recharts/go.sum
Normal 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=
|
||||
1308
tsunami/demo/recharts/static/tw.css
Normal file
1308
tsunami/demo/recharts/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
114
tsunami/demo/tabletest/app.go
Normal file
114
tsunami/demo/tabletest/app.go
Normal 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."),
|
||||
)
|
||||
})
|
||||
12
tsunami/demo/tabletest/go.mod
Normal file
12
tsunami/demo/tabletest/go.mod
Normal 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
|
||||
4
tsunami/demo/tabletest/go.sum
Normal file
4
tsunami/demo/tabletest/go.sum
Normal 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=
|
||||
1292
tsunami/demo/tabletest/static/tw.css
Normal file
1292
tsunami/demo/tabletest/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
180
tsunami/demo/todo/app.go
Normal file
180
tsunami/demo/todo/app.go
Normal 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
12
tsunami/demo/todo/go.mod
Normal 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
4
tsunami/demo/todo/go.sum
Normal 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=
|
||||
1165
tsunami/demo/todo/static/tw.css
Normal file
1165
tsunami/demo/todo/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
68
tsunami/demo/todo/style.css
Normal file
68
tsunami/demo/todo/style.css
Normal 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);
|
||||
}
|
||||
370
tsunami/demo/tsunamiconfig/app.go
Normal file
370
tsunami/demo/tsunamiconfig/app.go
Normal 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...",
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
12
tsunami/demo/tsunamiconfig/go.mod
Normal file
12
tsunami/demo/tsunamiconfig/go.mod
Normal 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
|
||||
4
tsunami/demo/tsunamiconfig/go.sum
Normal file
4
tsunami/demo/tsunamiconfig/go.sum
Normal 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=
|
||||
1283
tsunami/demo/tsunamiconfig/static/tw.css
Normal file
1283
tsunami/demo/tsunamiconfig/static/tw.css
Normal file
File diff suppressed because it is too large
Load diff
162
tsunami/engine/asyncnotify.go
Normal file
162
tsunami/engine/asyncnotify.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
86
tsunami/engine/atomimpl.go
Normal file
86
tsunami/engine/atomimpl.go
Normal 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
|
||||
}
|
||||
318
tsunami/engine/clientimpl.go
Normal file
318
tsunami/engine/clientimpl.go
Normal 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
52
tsunami/engine/comp.go
Normal 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
159
tsunami/engine/globalctx.go
Normal 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
167
tsunami/engine/hooks.go
Normal 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
319
tsunami/engine/render.go
Normal 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
262
tsunami/engine/render.md
Normal 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
453
tsunami/engine/rootelem.go
Normal 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
|
||||
}
|
||||
472
tsunami/engine/serverhandlers.go
Normal file
472
tsunami/engine/serverhandlers.go
Normal 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
1
tsunami/frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
scaffold/
|
||||
13
tsunami/frontend/index.html
Normal file
13
tsunami/frontend/index.html
Normal 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>
|
||||
39
tsunami/frontend/package.json
Normal file
39
tsunami/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
tsunami/frontend/public/fonts/hack-bold.woff2
Normal file
BIN
tsunami/frontend/public/fonts/hack-bold.woff2
Normal file
Binary file not shown.
BIN
tsunami/frontend/public/fonts/hack-regular.woff2
Normal file
BIN
tsunami/frontend/public/fonts/hack-regular.woff2
Normal file
Binary file not shown.
BIN
tsunami/frontend/public/fonts/inter-variable.woff2
Normal file
BIN
tsunami/frontend/public/fonts/inter-variable.woff2
Normal file
Binary file not shown.
BIN
tsunami/frontend/public/wave-logo-256.png
Normal file
BIN
tsunami/frontend/public/wave-logo-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
18
tsunami/frontend/src/app.tsx
Normal file
18
tsunami/frontend/src/app.tsx
Normal 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;
|
||||
87
tsunami/frontend/src/element/markdown.tsx
Normal file
87
tsunami/frontend/src/element/markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
tsunami/frontend/src/input.tsx
Normal file
107
tsunami/frontend/src/input.tsx
Normal 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;
|
||||
13
tsunami/frontend/src/main.tsx
Normal file
13
tsunami/frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
127
tsunami/frontend/src/model/model-utils.ts
Normal file
127
tsunami/frontend/src/model/model-utils.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
620
tsunami/frontend/src/model/tsunami-model.tsx
Normal file
620
tsunami/frontend/src/model/tsunami-model.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
168
tsunami/frontend/src/recharts/recharts.tsx
Normal file
168
tsunami/frontend/src/recharts/recharts.tsx
Normal 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 };
|
||||
62
tsunami/frontend/src/tailwind.css
Normal file
62
tsunami/frontend/src/tailwind.css
Normal 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
15
tsunami/frontend/src/types/custom.d.ts
vendored
Normal 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
188
tsunami/frontend/src/types/vdom.d.ts
vendored
Normal 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;
|
||||
};
|
||||
26
tsunami/frontend/src/util/clientid.ts
Normal file
26
tsunami/frontend/src/util/clientid.ts
Normal 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);
|
||||
}
|
||||
342
tsunami/frontend/src/util/keyutil.ts
Normal file
342
tsunami/frontend/src/util/keyutil.ts
Normal 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,
|
||||
};
|
||||
30
tsunami/frontend/src/util/platformutil.ts
Normal file
30
tsunami/frontend/src/util/platformutil.ts
Normal 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}`;
|
||||
}
|
||||
344
tsunami/frontend/src/vdom.tsx
Normal file
344
tsunami/frontend/src/vdom.tsx
Normal 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 };
|
||||
26
tsunami/frontend/tsconfig.json
Normal file
26
tsunami/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
34
tsunami/frontend/vite.config.ts
Normal file
34
tsunami/frontend/vite.config.ts
Normal 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
15
tsunami/go.mod
Normal 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
16
tsunami/go.sum
Normal 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=
|
||||
72
tsunami/prompts/global-keyboard-handling.md
Normal file
72
tsunami/prompts/global-keyboard-handling.md
Normal 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
337
tsunami/prompts/graphing.md
Normal 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
1359
tsunami/prompts/system.md
Normal file
File diff suppressed because it is too large
Load diff
190
tsunami/rpctypes/protocoltypes.go
Normal file
190
tsunami/rpctypes/protocoltypes.go
Normal 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"`
|
||||
}
|
||||
21
tsunami/templates/app-main.go.tmpl
Normal file
21
tsunami/templates/app-main.go.tmpl
Normal 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()
|
||||
}
|
||||
3
tsunami/templates/gitignore.tmpl
Normal file
3
tsunami/templates/gitignore.tmpl
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
node_modules/
|
||||
bin/
|
||||
61
tsunami/templates/tailwind.css
Normal file
61
tsunami/templates/tailwind.css
Normal 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;
|
||||
}
|
||||
3
tsunami/tsunamibase/tsunamibase.go
Normal file
3
tsunami/tsunamibase/tsunamibase.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package tsunamibase
|
||||
|
||||
var TsunamiVersion = "0.0.0"
|
||||
545
tsunami/ui/table.go
Normal file
545
tsunami/ui/table.go
Normal 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
240
tsunami/util/compare.go
Normal 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
121
tsunami/util/marshal.go
Normal 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
233
tsunami/util/util.go
Normal 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
178
tsunami/vdom/vdom.go
Normal 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
100
tsunami/vdom/vdom_test.go
Normal 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
120
tsunami/vdom/vdom_types.go
Normal 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
252
yarn.lock
|
|
@ -4163,6 +4163,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-android-arm64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-darwin-arm64@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-darwin-arm64@npm:2.5.0"
|
||||
|
|
@ -4170,6 +4177,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-darwin-arm64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-darwin-x64@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-darwin-x64@npm:2.5.0"
|
||||
|
|
@ -4177,6 +4191,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-darwin-x64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-darwin-x64@npm:2.5.1"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-freebsd-x64@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-freebsd-x64@npm:2.5.0"
|
||||
|
|
@ -4184,6 +4205,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-freebsd-x64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.0"
|
||||
|
|
@ -4191,6 +4219,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm-glibc@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1"
|
||||
conditions: os=linux & cpu=arm & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm-musl@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.0"
|
||||
|
|
@ -4198,6 +4233,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm-musl@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1"
|
||||
conditions: os=linux & cpu=arm & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.0"
|
||||
|
|
@ -4205,6 +4247,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm64-glibc@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.0"
|
||||
|
|
@ -4212,6 +4261,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-arm64-musl@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.0"
|
||||
|
|
@ -4219,6 +4275,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-x64-glibc@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-x64-musl@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.0"
|
||||
|
|
@ -4226,6 +4289,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-linux-x64-musl@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-arm64@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-win32-arm64@npm:2.5.0"
|
||||
|
|
@ -4233,6 +4303,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-arm64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-win32-arm64@npm:2.5.1"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-ia32@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-win32-ia32@npm:2.5.0"
|
||||
|
|
@ -4240,6 +4317,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-ia32@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-win32-ia32@npm:2.5.1"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-x64@npm:2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher-win32-x64@npm:2.5.0"
|
||||
|
|
@ -4247,6 +4331,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher-win32-x64@npm:2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher-win32-x64@npm:2.5.1"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher@npm:^2.4.1":
|
||||
version: 2.5.0
|
||||
resolution: "@parcel/watcher@npm:2.5.0"
|
||||
|
|
@ -4300,6 +4391,59 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@parcel/watcher@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "@parcel/watcher@npm:2.5.1"
|
||||
dependencies:
|
||||
"@parcel/watcher-android-arm64": "npm:2.5.1"
|
||||
"@parcel/watcher-darwin-arm64": "npm:2.5.1"
|
||||
"@parcel/watcher-darwin-x64": "npm:2.5.1"
|
||||
"@parcel/watcher-freebsd-x64": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-arm-glibc": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-arm-musl": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-arm64-glibc": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-arm64-musl": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-x64-glibc": "npm:2.5.1"
|
||||
"@parcel/watcher-linux-x64-musl": "npm:2.5.1"
|
||||
"@parcel/watcher-win32-arm64": "npm:2.5.1"
|
||||
"@parcel/watcher-win32-ia32": "npm:2.5.1"
|
||||
"@parcel/watcher-win32-x64": "npm:2.5.1"
|
||||
detect-libc: "npm:^1.0.3"
|
||||
is-glob: "npm:^4.0.3"
|
||||
micromatch: "npm:^4.0.5"
|
||||
node-addon-api: "npm:^7.0.0"
|
||||
node-gyp: "npm:latest"
|
||||
dependenciesMeta:
|
||||
"@parcel/watcher-android-arm64":
|
||||
optional: true
|
||||
"@parcel/watcher-darwin-arm64":
|
||||
optional: true
|
||||
"@parcel/watcher-darwin-x64":
|
||||
optional: true
|
||||
"@parcel/watcher-freebsd-x64":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-arm-glibc":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-arm-musl":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-arm64-glibc":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-arm64-musl":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-x64-glibc":
|
||||
optional: true
|
||||
"@parcel/watcher-linux-x64-musl":
|
||||
optional: true
|
||||
"@parcel/watcher-win32-arm64":
|
||||
optional: true
|
||||
"@parcel/watcher-win32-ia32":
|
||||
optional: true
|
||||
"@parcel/watcher-win32-x64":
|
||||
optional: true
|
||||
checksum: 10c0/8f35073d0c0b34a63d4c8d2213482f0ebc6a25de7b2cdd415d19cb929964a793cb285b68d1d50bfb732b070b3c82a2fdb4eb9c250eab709a1cd9d63345455a82
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pkgjs/parseargs@npm:^0.11.0":
|
||||
version: 0.11.0
|
||||
resolution: "@pkgjs/parseargs@npm:0.11.0"
|
||||
|
|
@ -5586,6 +5730,23 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tailwindcss/cli@npm:^4.1.12":
|
||||
version: 4.1.12
|
||||
resolution: "@tailwindcss/cli@npm:4.1.12"
|
||||
dependencies:
|
||||
"@parcel/watcher": "npm:^2.5.1"
|
||||
"@tailwindcss/node": "npm:4.1.12"
|
||||
"@tailwindcss/oxide": "npm:4.1.12"
|
||||
enhanced-resolve: "npm:^5.18.3"
|
||||
mri: "npm:^1.2.0"
|
||||
picocolors: "npm:^1.1.1"
|
||||
tailwindcss: "npm:4.1.12"
|
||||
bin:
|
||||
tailwindcss: dist/index.mjs
|
||||
checksum: 10c0/629abde6773c630fa72e653e17e675f5ffe080d720d99124b5361e684785e43d7f72e36ee53437a0e53f91c8ea61267a793cce8fe09b3a03288083d4efc19e5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tailwindcss/node@npm:4.1.12":
|
||||
version: 4.1.12
|
||||
resolution: "@tailwindcss/node@npm:4.1.12"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue