From 7ba6317735cd8aca881c89b07e5fd5fc3ec949c2 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 18:47:26 -0700 Subject: [PATCH 01/12] setup caches dir correctly in wavebase --- cmd/server/main-server.go | 5 + pkg/blockcontroller/tsunamicontroller.go | 59 +-- pkg/buildercontroller/buildercontroller.go | 440 +++++++++++++++++++++ pkg/tsunamiutil/tsunamiutil.go | 30 ++ pkg/wavebase/wavebase.go | 48 +++ pkg/wps/wpstypes.go | 2 + 6 files changed, 528 insertions(+), 56 deletions(-) create mode 100644 pkg/buildercontroller/buildercontroller.go create mode 100644 pkg/tsunamiutil/tsunamiutil.go diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 427e6ad2e0..9a1314012c 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -353,6 +353,11 @@ func main() { log.Printf("error ensuring wave presets dir: %v\n", err) return } + err = wavebase.EnsureWaveCachesDir() + if err != nil { + log.Printf("error ensuring wave caches dir: %v\n", err) + return + } waveLock, err := wavebase.AcquireWaveLock() if err != nil { log.Printf("error acquiring wave lock (another instance of Wave is likely running): %v\n", err) diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index a1f90569c9..ebef637f2c 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -14,11 +14,11 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "sync" "syscall" "time" + "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -48,41 +48,6 @@ type TsunamiController struct { port int } -func getCachesDir() string { - var cacheDir string - appBundle := "waveterm" - if wavebase.IsDevMode() { - appBundle = "waveterm-dev" - } - - switch runtime.GOOS { - case "darwin": - // macOS: ~/Library/Caches/ - homeDir := wavebase.GetHomeDir() - cacheDir = filepath.Join(homeDir, "Library", "Caches", appBundle) - case "linux": - // Linux: XDG_CACHE_HOME or ~/.cache/ - xdgCache := os.Getenv("XDG_CACHE_HOME") - if xdgCache != "" { - cacheDir = filepath.Join(xdgCache, appBundle) - } else { - homeDir := wavebase.GetHomeDir() - cacheDir = filepath.Join(homeDir, ".cache", appBundle) - } - case "windows": - localAppData := os.Getenv("LOCALAPPDATA") - if localAppData != "" { - cacheDir = filepath.Join(localAppData, appBundle, "Cache") - } - } - - if cacheDir == "" { - tmpDir := os.TempDir() - cacheDir = filepath.Join(tmpDir, appBundle) - } - - return cacheDir -} func (c *TsunamiController) fetchAndSetSchemas(port int) { url := fmt.Sprintf("http://localhost:%d/api/schemas", port) @@ -124,31 +89,13 @@ func (c *TsunamiController) clearSchemas() { log.Printf("TsunamiController: cleared schemas for block %s", c.blockId) } -func getTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { - cachesDir := getCachesDir() - tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") - fullAppName := appName + "." + osArch - if strings.HasPrefix(osArch, "windows") { - fullAppName = fullAppName + ".exe" - } - fullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName) - - // Create the directory if it doesn't exist - dirPath := filepath.Dir(fullPath) - err := wavebase.TryMkdirs(dirPath, 0755, "tsunami cache directory") - if err != nil { - return "", fmt.Errorf("failed to create tsunami cache directory: %w", err) - } - - return fullPath, nil -} func isBuildCacheUpToDate(appPath string) (bool, error) { appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH - cachePath, err := getTsunamiAppCachePath("local", appName, osArch) + cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return false, err } @@ -214,7 +161,7 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH - cachePath, err := getTsunamiAppCachePath("local", appName, osArch) + cachePath, err := tsunamiutil.GetTsunamiAppCachePath("local", appName, osArch) if err != nil { return fmt.Errorf("failed to get cache path: %w", err) } diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go new file mode 100644 index 0000000000..8427a47139 --- /dev/null +++ b/pkg/buildercontroller/buildercontroller.go @@ -0,0 +1,440 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package buildercontroller + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/utilds" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/tsunami/build" +) + +const ( + BuilderStatus_Init = "init" + BuilderStatus_Building = "building" + BuilderStatus_Running = "running" + BuilderStatus_Error = "error" + BuilderStatus_Stopped = "stopped" +) + +type BuilderProcess struct { + Cmd *exec.Cmd + StdinWriter io.WriteCloser + Port int + WaitCh chan struct{} + WaitRtn error +} + +type BuilderController struct { + lock sync.Mutex + builderId string + appId string + process *BuilderProcess + outputBuffer *utilds.MultiReaderLineBuffer + statusLock sync.Mutex + status string + statusVersion int + port int + exitCode int + errorMsg string +} + +var ( + controllerMap = make(map[string]*BuilderController) // key is builderid + mapLock sync.Mutex +) + +func GetOrCreateController(builderId string) *BuilderController { + mapLock.Lock() + defer mapLock.Unlock() + + bc := controllerMap[builderId] + if bc != nil { + return bc + } + + bc = &BuilderController{ + builderId: builderId, + status: BuilderStatus_Init, + statusVersion: 0, + } + controllerMap[builderId] = bc + + return bc +} + +func DeleteController(builderId string) { + mapLock.Lock() + bc := controllerMap[builderId] + delete(controllerMap, builderId) + mapLock.Unlock() + + if bc != nil { + bc.Stop() + } + + cachesDir := wavebase.GetWaveCachesDir() + builderDir := filepath.Join(cachesDir, "builder", builderId) + if err := os.RemoveAll(builderDir); err != nil { + log.Printf("failed to remove builder cache directory for %s: %v", builderId, err) + } +} + +func GetBuilderAppExecutablePath(builderId string, appName string) (string, error) { + cachesDir := wavebase.GetWaveCachesDir() + builderDir := filepath.Join(cachesDir, "builder", builderId) + + binaryName := appName + if runtime.GOOS == "windows" { + binaryName = binaryName + ".exe" + } + cachePath := filepath.Join(builderDir, binaryName) + + err := wavebase.TryMkdirs(builderDir, 0755, "builder cache directory") + if err != nil { + return "", fmt.Errorf("failed to create builder cache directory: %w", err) + } + + return cachePath, nil +} + +func Shutdown() { + mapLock.Lock() + controllers := make([]*BuilderController, 0, len(controllerMap)) + for _, bc := range controllerMap { + controllers = append(controllers, bc) + } + mapLock.Unlock() + + for _, bc := range controllers { + bc.Stop() + } + + cachesDir := wavebase.GetWaveCachesDir() + builderCacheDir := filepath.Join(cachesDir, "builder") + if err := os.RemoveAll(builderCacheDir); err != nil { + log.Printf("failed to remove builder cache directory: %v", err) + } +} + +func (bc *BuilderController) waitForBuildDone(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + bc.statusLock.Lock() + status := bc.status + bc.statusLock.Unlock() + + if status != BuilderStatus_Building { + return nil + } + + time.Sleep(100 * time.Millisecond) + } +} + +func (bc *BuilderController) Start(ctx context.Context, appId string, appPath string, scaffoldPath string, sdkReplacePath string, builderEnv map[string]string) error { + if err := bc.waitForBuildDone(ctx); err != nil { + return err + } + + bc.lock.Lock() + defer bc.lock.Unlock() + + if bc.appId != appId && bc.process != nil { + log.Printf("BuilderController: stopping previous app %s for builder %s", bc.appId, bc.builderId) + bc.stopProcess_nolock() + } + + bc.appId = appId + bc.outputBuffer = utilds.MakeMultiReaderLineBuffer(1000) + bc.setStatus_nolock(BuilderStatus_Building, 0, 0, "") + + bc.publishOutputLine("", true) + + bc.outputBuffer.SetLineCallback(func(line string) { + bc.publishOutputLine(line, false) + }) + + go bc.buildAndRun(ctx, appPath, scaffoldPath, sdkReplacePath, builderEnv) + + return nil +} + +func (bc *BuilderController) buildAndRun(ctx context.Context, appPath string, scaffoldPath string, sdkReplacePath string, builderEnv map[string]string) { + defer panicRecover(bc.builderId) + + appName := build.GetAppName(appPath) + + cachePath, err := GetBuilderAppExecutablePath(bc.builderId, appName) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to get builder executable path: %w", err)) + return + } + + nodePath := wavebase.GetWaveAppElectronExecPath() + if nodePath == "" { + bc.handleBuildError(fmt.Errorf("electron executable path not set")) + return + } + + _, err = build.TsunamiBuildInternal(build.BuildOpts{ + AppPath: appPath, + Verbose: true, + Open: false, + KeepTemp: false, + OutputFile: cachePath, + ScaffoldPath: scaffoldPath, + SdkReplacePath: sdkReplacePath, + NodePath: nodePath, + }) + if err != nil { + bc.handleBuildError(fmt.Errorf("build failed: %w", err)) + return + } + + info, err := os.Stat(cachePath) + if err != nil { + bc.handleBuildError(fmt.Errorf("build output not found: %w", err)) + return + } + + if runtime.GOOS != "windows" && info.Mode()&0111 == 0 { + bc.handleBuildError(fmt.Errorf("build output is not executable")) + return + } + + process, err := bc.runBuilderApp(ctx, cachePath, builderEnv) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to run app: %w", err)) + return + } + + bc.lock.Lock() + bc.process = process + bc.setStatus_nolock(BuilderStatus_Running, process.Port, 0, "") + bc.lock.Unlock() + + go func() { + <-process.WaitCh + bc.lock.Lock() + if bc.process == process { + bc.process = nil + exitCode := exitCodeFromWaitErr(process.WaitRtn) + bc.setStatus_nolock(BuilderStatus_Stopped, 0, exitCode, "") + } + bc.lock.Unlock() + }() +} + +func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath string, builderEnv map[string]string) (*BuilderProcess, error) { + cmd := exec.Command(appBinPath) + cmd.Env = append(os.Environ(), "TSUNAMI_CLOSEONSTDIN=1") + + for key, value := range builderEnv { + cmd.Env = append(cmd.Env, key+"="+value) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdin pipe: %w", err) + } + + portChan := make(chan int, 1) + portFound := false + + bc.outputBuffer.SetLineCallback(func(line string) { + if !portFound { + if port := build.ParseTsunamiPort(line); port > 0 { + portFound = true + portChan <- port + } + } + bc.publishOutputLine(line, false) + }) + + err = cmd.Start() + if err != nil { + return nil, fmt.Errorf("failed to start process: %w", err) + } + + waitCh := make(chan struct{}) + process := &BuilderProcess{ + Cmd: cmd, + StdinWriter: stdinPipe, + WaitCh: waitCh, + } + + go func() { + process.WaitRtn = cmd.Wait() + close(waitCh) + }() + + go bc.outputBuffer.ReadAll(stdoutPipe) + go bc.outputBuffer.ReadAll(stderrPipe) + + errChan := make(chan error, 1) + go func() { + <-process.WaitCh + select { + case <-portChan: + default: + errChan <- fmt.Errorf("process died before emitting port") + } + }() + + timeout := time.NewTimer(30 * time.Second) + defer timeout.Stop() + + select { + case port := <-portChan: + process.Port = port + return process, nil + case err := <-errChan: + cmd.Process.Kill() + return nil, err + case <-timeout.C: + cmd.Process.Kill() + return nil, fmt.Errorf("timeout waiting for port") + case <-ctx.Done(): + cmd.Process.Kill() + return nil, ctx.Err() + } +} + +func (bc *BuilderController) handleBuildError(err error) { + bc.lock.Lock() + defer bc.lock.Unlock() + bc.setStatus_nolock(BuilderStatus_Error, 0, 1, err.Error()) +} + +func (bc *BuilderController) Stop() error { + if err := bc.waitForBuildDone(context.Background()); err != nil { + return err + } + + bc.lock.Lock() + defer bc.lock.Unlock() + bc.stopProcess_nolock() + bc.setStatus_nolock(BuilderStatus_Stopped, 0, 0, "") + return nil +} + +func (bc *BuilderController) stopProcess_nolock() { + if bc.process == nil { + return + } + + if bc.process.Cmd.Process != nil { + bc.process.Cmd.Process.Kill() + } + + if bc.process.StdinWriter != nil { + bc.process.StdinWriter.Close() + } + + bc.process = nil +} + +func (bc *BuilderController) GetStatus() BuilderStatusData { + bc.statusLock.Lock() + defer bc.statusLock.Unlock() + + bc.statusVersion++ + return BuilderStatusData{ + Status: bc.status, + Port: bc.port, + ExitCode: bc.exitCode, + ErrorMsg: bc.errorMsg, + Version: bc.statusVersion, + } +} + +func (bc *BuilderController) GetOutput() []string { + if bc.outputBuffer == nil { + return []string{} + } + return bc.outputBuffer.GetLines() +} + +func (bc *BuilderController) setStatus_nolock(status string, port int, exitCode int, errorMsg string) { + bc.statusLock.Lock() + bc.status = status + bc.port = port + bc.exitCode = exitCode + bc.errorMsg = errorMsg + bc.statusLock.Unlock() + + go bc.publishStatus() +} + +func (bc *BuilderController) publishStatus() { + status := bc.GetStatus() + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BuilderStatus, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, + Data: status, + }) +} + +func (bc *BuilderController) publishOutputLine(line string, reset bool) { + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BuilderOutput, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Builder, bc.builderId).String()}, + Data: map[string]any{ + "lines": []string{line}, + "reset": reset, + }, + }) +} + +type BuilderStatusData struct { + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` +} + +func exitCodeFromWaitErr(waitErr error) int { + if waitErr == nil { + return 0 + } + if exitError, ok := waitErr.(*exec.ExitError); ok { + return exitError.ExitCode() + } + return 1 +} + +func panicRecover(builderId string) { + if r := recover(); r != nil { + log.Printf("BuilderController panic for builder %s: %v", builderId, r) + } +} diff --git a/pkg/tsunamiutil/tsunamiutil.go b/pkg/tsunamiutil/tsunamiutil.go new file mode 100644 index 0000000000..85657efb8c --- /dev/null +++ b/pkg/tsunamiutil/tsunamiutil.go @@ -0,0 +1,30 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package tsunamiutil + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wavebase" +) + +func GetTsunamiAppCachePath(scope string, appName string, osArch string) (string, error) { + cachesDir := wavebase.GetWaveCachesDir() + tsunamiCacheDir := filepath.Join(cachesDir, "tsunami-build-cache") + fullAppName := appName + "." + osArch + if strings.HasPrefix(osArch, "windows") { + fullAppName = fullAppName + ".exe" + } + fullPath := filepath.Join(tsunamiCacheDir, scope, fullAppName) + + dirPath := filepath.Dir(fullPath) + err := wavebase.TryMkdirs(dirPath, 0755, "tsunami cache directory") + if err != nil { + return "", fmt.Errorf("failed to create tsunami cache directory: %w", err) + } + + return fullPath, nil +} \ No newline at end of file diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go index 930b8a5e08..72f346874b 100644 --- a/pkg/wavebase/wavebase.go +++ b/pkg/wavebase/wavebase.go @@ -69,6 +69,9 @@ const AppPathBinDir = "bin" var baseLock = &sync.Mutex{} var ensureDirCache = map[string]bool{} +var waveCachesDirOnce = &sync.Once{} +var waveCachesDir string + var SupportedWshBinaries = map[string]bool{ "darwin-x64": true, "darwin-arm64": true, @@ -187,6 +190,51 @@ func EnsureWavePresetsDir() error { return CacheEnsureDir(filepath.Join(GetWaveConfigDir(), "presets"), "wavepresets", 0700, "wave presets directory") } +func resolveWaveCachesDir() string { + var cacheDir string + appBundle := "waveterm" + if IsDevMode() { + appBundle = "waveterm-dev" + } + + switch runtime.GOOS { + case "darwin": + homeDir := GetHomeDir() + cacheDir = filepath.Join(homeDir, "Library", "Caches", appBundle) + case "linux": + xdgCache := os.Getenv("XDG_CACHE_HOME") + if xdgCache != "" { + cacheDir = filepath.Join(xdgCache, appBundle) + } else { + homeDir := GetHomeDir() + cacheDir = filepath.Join(homeDir, ".cache", appBundle) + } + case "windows": + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData != "" { + cacheDir = filepath.Join(localAppData, appBundle, "Cache") + } + } + + if cacheDir == "" { + tmpDir := os.TempDir() + cacheDir = filepath.Join(tmpDir, appBundle) + } + + return cacheDir +} + +func GetWaveCachesDir() string { + waveCachesDirOnce.Do(func() { + waveCachesDir = resolveWaveCachesDir() + }) + return waveCachesDir +} + +func EnsureWaveCachesDir() error { + return CacheEnsureDir(GetWaveCachesDir(), "wavecaches", 0700, "wave caches directory") +} + func CacheEnsureDir(dirName string, cacheKey string, perm os.FileMode, dirDesc string) error { baseLock.Lock() ok := ensureDirCache[cacheKey] diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 245f71c0d4..cae43908ff 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -10,6 +10,8 @@ const ( Event_ConnChange = "connchange" Event_SysInfo = "sysinfo" Event_ControllerStatus = "controllerstatus" + Event_BuilderStatus = "builderstatus" + Event_BuilderOutput = "builderoutput" Event_WaveObjUpdate = "waveobj:update" Event_BlockFile = "blockfile" Event_Config = "config" From abb3f3cba0146a36e8ca5aeb8a632eef62298c74 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 19:01:48 -0700 Subject: [PATCH 02/12] delete buildercontroller when builder window is closed. implement a nice preview before there is an app.go --- emain/emain-builder.ts | 1 + frontend/app/store/wshclientapi.ts | 5 +++ frontend/builder/tabs/builder-previewtab.tsx | 34 +++++++++++++++++++- pkg/wshrpc/wshclient/wshclient.go | 6 ++++ pkg/wshrpc/wshserver/wshserver.go | 6 ++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index 4219183c25..a073d99c77 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -103,6 +103,7 @@ export async function createBuilderWindow(appId: string): Promise globalEvents.emit("windows-updated"), 50); }); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 18fc68fe15..eba5a0b70e 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -122,6 +122,11 @@ class RpcApiType { return client.wshRpcCall("deleteblock", data, opts); } + // command "deletebuilder" [call] + DeleteBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("deletebuilder", data, opts); + } + // command "deletesubblock" [call] DeleteSubBlockCommand(client: WshClient, data: CommandDeleteBlockData, opts?: RpcOpts): Promise { return client.wshRpcCall("deletesubblock", data, opts); diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 0071e0a3db..7cc462c20e 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,9 +1,41 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { useAtomValue } from "jotai"; import { memo } from "react"; const BuilderPreviewTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const isLoading = useAtomValue(model.isLoadingAtom); + const originalContent = useAtomValue(model.originalContentAtom); + + const fileExists = originalContent.length > 0; + + if (isLoading) { + return null; + } + + if (!fileExists) { + return ( +
+
+
🏗️
+
+

No App to Preview

+

+ Get started by using the AI chat interface on the left to create your WaveApp. Describe what + you want to build, and the AI will help you generate the code. +

+
+
+ Your app will appear here once app.go is created +
+
+
+ ); + } + return (

Preview Tab

@@ -13,4 +45,4 @@ const BuilderPreviewTab = memo(() => { BuilderPreviewTab.displayName = "BuilderPreviewTab"; -export { BuilderPreviewTab }; \ No newline at end of file +export { BuilderPreviewTab }; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 5c8ec2582c..92b69ae5bf 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -155,6 +155,12 @@ func DeleteBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, o return err } +// command "deletebuilder", wshserver.DeleteBuilderCommand +func DeleteBuilderCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "deletebuilder", data, opts) + return err +} + // command "deletesubblock", wshserver.DeleteSubBlockCommand func DeleteSubBlockCommand(w *wshutil.WshRpc, data wshrpc.CommandDeleteBlockData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "deletesubblock", data, opts) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 8364329efc..b2f506ad83 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -25,6 +25,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" + "github.com/wavetermdev/waveterm/pkg/buildercontroller" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/genconn" "github.com/wavetermdev/waveterm/pkg/panichandler" @@ -957,6 +958,11 @@ func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.Comma return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) } +func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error { + buildercontroller.DeleteController(builderId) + return nil +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { From c5defe4d4315ba36e43870d3524f1a667835f498 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 19:01:59 -0700 Subject: [PATCH 03/12] ah add wshrpctypes.go --- pkg/wshrpc/wshrpctypes.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a3e3f02ed4..25025ead3f 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -159,6 +159,7 @@ const ( Command_WriteAppFile = "writeappfile" Command_DeleteAppFile = "deleteappfile" Command_RenameAppFile = "renameappfile" + Command_DeleteBuilder = "deletebuilder" ) type RespOrErrorUnion[T any] struct { @@ -301,6 +302,7 @@ type WshRpcInterface interface { WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error + DeleteBuilderCommand(ctx context.Context, builderId string) error // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] From 9fb983e7560188584c3d56d7dc8ad60f7c304b94 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 20:31:48 -0700 Subject: [PATCH 04/12] change names of store files to be more consistent --- frontend/app/aipanel/waveai-model.tsx | 2 +- frontend/builder/builder-apppanel.tsx | 50 +++++++++---------- frontend/builder/builder-workspace.tsx | 2 +- ...anelModel.ts => builder-apppanel-model.ts} | 0 ...ocusManager.ts => builder-focusmanager.ts} | 0 frontend/builder/tabs/builder-codetab.tsx | 4 +- frontend/builder/tabs/builder-previewtab.tsx | 2 +- 7 files changed, 30 insertions(+), 30 deletions(-) rename frontend/builder/store/{builderAppPanelModel.ts => builder-apppanel-model.ts} (100%) rename frontend/builder/store/{builderFocusManager.ts => builder-focusmanager.ts} (100%) diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 2184ae3a63..98942100b9 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -14,7 +14,7 @@ import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { getWebServerEndpoint } from "@/util/endpoints"; import { ChatStatus } from "ai"; import * as jotai from "jotai"; diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 44d6be68c1..9f048d1b89 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -1,8 +1,8 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderAppPanelModel, type TabType } from "@/builder/store/builderAppPanelModel"; -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; @@ -58,34 +58,34 @@ const BuilderAppPanel = memo(() => { model.giveFocus(); }; - const handleFocusCapture = useCallback( - (event: React.FocusEvent) => { - BuilderFocusManager.getInstance().setAppFocused(); - }, - [] - ); - - const handlePanelClick = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement; - const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); + const handleFocusCapture = useCallback((event: React.FocusEvent) => { + BuilderFocusManager.getInstance().setAppFocused(); + }, []); - if (isInteractive) { - return; - } + const handlePanelClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, textarea, select, [role="button"], [tabindex]'); - const hasSelection = builderAppHasSelection(); - if (hasSelection) { - BuilderFocusManager.getInstance().setAppFocused(); - return; - } + if (isInteractive) { + return; + } - setTimeout(() => { - if (!builderAppHasSelection()) { + const hasSelection = builderAppHasSelection(); + if (hasSelection) { BuilderFocusManager.getInstance().setAppFocused(); - model.giveFocus(); + return; } - }, 0); - }, [model]); + + setTimeout(() => { + if (!builderAppHasSelection()) { + BuilderFocusManager.getInstance().setAppFocused(); + model.giveFocus(); + } + }, 0); + }, + [model] + ); const handleSave = useCallback(() => { if (builderAppId) { diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 6b1f10d1fe..2ce8f6e374 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -5,7 +5,7 @@ import { AIPanel } from "@/app/aipanel/aipanel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanel } from "@/builder/builder-apppanel"; -import { BuilderFocusManager } from "@/builder/store/builderFocusManager"; +import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; diff --git a/frontend/builder/store/builderAppPanelModel.ts b/frontend/builder/store/builder-apppanel-model.ts similarity index 100% rename from frontend/builder/store/builderAppPanelModel.ts rename to frontend/builder/store/builder-apppanel-model.ts diff --git a/frontend/builder/store/builderFocusManager.ts b/frontend/builder/store/builder-focusmanager.ts similarity index 100% rename from frontend/builder/store/builderFocusManager.ts rename to frontend/builder/store/builder-focusmanager.ts diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 31c80826a3..534d0bb5aa 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -1,9 +1,9 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { waveEventSubscribe } from "@/app/store/wps"; -import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; import { useAtomValue } from "jotai"; diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 7cc462c20e..bb6eb86022 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -1,7 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BuilderAppPanelModel } from "@/builder/store/builderAppPanelModel"; +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { useAtomValue } from "jotai"; import { memo } from "react"; From 90f39c41727e33acee59aaa262314ce92c0068ae Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 21:54:26 -0700 Subject: [PATCH 05/12] got the controller working or at least a successful build --- .roo/rules/rules.md | 1 + frontend/app/store/wshclientapi.ts | 10 +++ frontend/builder/builder-apppanel.tsx | 47 +++++++++-- .../builder/store/builder-apppanel-model.ts | 79 +++++++++++++++++++ frontend/builder/tabs/builder-codetab.tsx | 23 +----- frontend/builder/tabs/builder-previewtab.tsx | 67 ++++++++++++---- frontend/types/gotypes.d.ts | 14 ++++ pkg/buildercontroller/buildercontroller.go | 33 +++++--- pkg/wshrpc/wshclient/wshclient.go | 12 +++ pkg/wshrpc/wshrpctypes.go | 16 ++++ pkg/wshrpc/wshserver/wshserver.go | 25 ++++++ 11 files changed, 272 insertions(+), 55 deletions(-) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index a83a80b3b7..a72e7fffdd 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -34,6 +34,7 @@ It has a TypeScript/React frontend and a Go backend. They talk together over `ws - Use all lowercase filenames (except where case is actually important like Taskfile.yml) - Import the "cn" function from "@/util/util" to do classname / clsx class merge (it uses twMerge underneath) - For element variants use class-variance-authority + - Do NOT create private fields in classes (they are impossible to inspect and are a terrible for application code) - **Component Practices**: - Make sure to add cursor-pointer to buttons/links and clickable items - NEVER use cursor-help (it looks terrible) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index eba5a0b70e..80242d7daa 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -267,6 +267,11 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } + // command "getbuilderstatus" [call] + GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("getbuilderstatus", data, opts); + } + // command "getfullconfig" [call] GetFullConfigCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("getfullconfig", null, opts); @@ -467,6 +472,11 @@ class RpcApiType { return client.wshRpcCall("setview", data, opts); } + // command "startbuilder" [call] + StartBuilderCommand(client: WshClient, data: CommandStartBuilderData, opts?: RpcOpts): Promise { + return client.wshRpcCall("startbuilder", data, opts); + } + // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { return client.wshRpcStream("streamcpudata", data, opts); diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 9f048d1b89..ef42dc9ad0 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -11,7 +11,35 @@ import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useCallback, useRef } from "react"; +import { memo, useCallback, useEffect, useRef } from "react"; + +const StatusDot = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const builderStatus = useAtomValue(model.builderStatusAtom); + + const getStatusDotColor = (status: string | null | undefined): string => { + if (!status) return "bg-gray-500"; + switch (status) { + case "init": + case "stopped": + return "bg-gray-500"; + case "building": + return "bg-warning"; + case "running": + return "bg-success"; + case "error": + return "bg-error"; + default: + return "bg-gray-500"; + } + }; + + const statusDotColor = getStatusDotColor(builderStatus?.status); + + return ; +}); + +StatusDot.displayName = "StatusDot"; type TabButtonProps = { label: string; @@ -19,20 +47,24 @@ type TabButtonProps = { isActive: boolean; isAppFocused: boolean; onClick: () => void; + showStatusDot?: boolean; }; -const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick }: TabButtonProps) => { +const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showStatusDot }: TabButtonProps) => { return ( ); }); @@ -48,6 +80,10 @@ const BuilderAppPanel = memo(() => { const saveNeeded = useAtomValue(model.saveNeededAtom); const builderAppId = useAtomValue(atoms.builderAppId); + useEffect(() => { + model.initialize(); + }, []); + if (focusElemRef.current) { model.setFocusElemRef(focusElemRef.current); } @@ -118,6 +154,7 @@ const BuilderAppPanel = memo(() => { isActive={activeTab === "preview"} isAppFocused={isAppFocused} onClick={() => handleTabClick("preview")} + showStatusDot={true} /> = atom(""); isLoadingAtom: PrimitiveAtom = atom(false); errorAtom: PrimitiveAtom = atom(""); + builderStatusAtom = atom(null) as PrimitiveAtom; saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: any | null } = { current: null }; + statusUnsubFn: (() => void) | null = null; + appGoUpdateUnsubFn: (() => void) | null = null; + initialized = false; private constructor() { this.saveNeededAtom = atom((get) => { @@ -46,6 +52,62 @@ export class BuilderAppPanelModel { globalStore.set(this.codeContentAtom, content); } + async initialize() { + if (this.initialized) return; + this.initialized = true; + + const builderId = globalStore.get(atoms.builderId); + if (!builderId) return; + + if (this.statusUnsubFn) { + this.statusUnsubFn(); + } + + this.statusUnsubFn = waveEventSubscribe({ + eventType: "builderstatus", + scope: WOS.makeORef("builder", builderId), + handler: (event) => { + const status: BuilderStatusData = event.data; + const currentStatus = globalStore.get(this.builderStatusAtom); + if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) { + globalStore.set(this.builderStatusAtom, status); + } + }, + }); + + try { + const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId); + globalStore.set(this.builderStatusAtom, status); + } catch (err) { + console.error("Failed to load builder status:", err); + } + + const appId = globalStore.get(atoms.builderAppId); + await this.loadAppFile(appId); + + this.appGoUpdateUnsubFn = waveEventSubscribe({ + eventType: "waveapp:appgoupdated", + scope: appId, + handler: () => { + this.loadAppFile(appId); + }, + }); + } + + async startBuilder() { + const builderId = globalStore.get(atoms.builderId); + if (!builderId) return; + + try { + await RpcApi.StartBuilderCommand(TabRpcClient, { + builderid: builderId, + }); + } catch (err) { + console.error("Failed to start builder:", err); + globalStore.set(this.errorAtom, `Failed to start builder: ${err.message || "Unknown error"}`); + } + } + async loadAppFile(appId: string) { if (!appId) { globalStore.set(this.errorAtom, "No app selected"); @@ -56,10 +118,12 @@ export class BuilderAppPanelModel { try { globalStore.set(this.isLoadingAtom, true); globalStore.set(this.errorAtom, ""); + const result = await RpcApi.ReadAppFileCommand(TabRpcClient, { appid: appId, filename: "app.go", }); + if (result.notfound) { globalStore.set(this.codeContentAtom, ""); globalStore.set(this.originalContentAtom, ""); @@ -67,6 +131,10 @@ export class BuilderAppPanelModel { const decoded = base64ToString(result.data64); globalStore.set(this.codeContentAtom, decoded); globalStore.set(this.originalContentAtom, decoded); + + if (decoded.trim() !== "") { + await this.startBuilder(); + } } } catch (err) { console.error("Failed to load app.go:", err); @@ -111,4 +179,15 @@ export class BuilderAppPanelModel { setMonacoEditorRef(ref: any) { this.monacoEditorRef.current = ref; } + + dispose() { + if (this.statusUnsubFn) { + this.statusUnsubFn(); + this.statusUnsubFn = null; + } + if (this.appGoUpdateUnsubFn) { + this.appGoUpdateUnsubFn(); + this.appGoUpdateUnsubFn = null; + } + } } diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 534d0bb5aa..cbdd8418eb 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -1,13 +1,12 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { waveEventSubscribe } from "@/app/store/wps"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; import { useAtomValue } from "jotai"; -import { memo, useEffect } from "react"; +import { memo } from "react"; const BuilderCodeTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); @@ -16,26 +15,6 @@ const BuilderCodeTab = memo(() => { const isLoading = useAtomValue(model.isLoadingAtom); const error = useAtomValue(model.errorAtom); - useEffect(() => { - if (builderAppId) { - model.loadAppFile(builderAppId); - } - }, [builderAppId, model]); - - useEffect(() => { - if (!builderAppId) { - return; - } - const unsubscribe = waveEventSubscribe({ - eventType: "waveapp:appgoupdated", - scope: builderAppId, - handler: () => { - model.loadAppFile(builderAppId); - }, - }); - return unsubscribe; - }, [builderAppId, model]); - const handleCodeChange = (newText: string) => { model.setCodeContent(newText); }; diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index bb6eb86022..e5843e6b0f 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -5,10 +5,51 @@ import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; import { useAtomValue } from "jotai"; import { memo } from "react"; +const EmptyStateView = memo(() => { + return ( +
+
+
🏗️
+
+

No App to Preview

+

+ Get started by using the AI chat interface on the left to create your WaveApp. Describe what you + want to build, and the AI will help you generate the code. +

+
+
+ Your app will appear here once app.go is created +
+
+
+ ); +}); + +EmptyStateView.displayName = "EmptyStateView"; + +const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { + return ( +
+
+
+

Build Error

+
+
{errorMsg}
+
+
+
+
+ ); +}); + +ErrorStateView.displayName = "ErrorStateView"; + const BuilderPreviewTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const isLoading = useAtomValue(model.isLoadingAtom); const originalContent = useAtomValue(model.originalContentAtom); + const error = useAtomValue(model.errorAtom); + const builderStatus = useAtomValue(model.builderStatusAtom); const fileExists = originalContent.length > 0; @@ -16,24 +57,16 @@ const BuilderPreviewTab = memo(() => { return null; } + if (error) { + return ; + } + + if (builderStatus?.status === "error" && builderStatus?.errormsg) { + return ; + } + if (!fileExists) { - return ( -
-
-
🏗️
-
-

No App to Preview

-

- Get started by using the AI chat interface on the left to create your WaveApp. Describe what - you want to build, and the AI will help you generate the code. -

-
-
- Your app will appear here once app.go is created -
-
-
- ); + return ; } return ( diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1d215086f4..90b95a27a3 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -110,6 +110,15 @@ declare global { workspaceid?: string; }; + // wshrpc.BuilderStatusData + type BuilderStatusData = { + status: string; + port?: number; + exitcode?: number; + errormsg?: string; + version: number; + }; + // waveobj.Client type Client = WaveObj & { windowids: string[]; @@ -335,6 +344,11 @@ declare global { delete?: boolean; }; + // wshrpc.CommandStartBuilderData + type CommandStartBuilderData = { + builderid: string; + }; + // wshrpc.CommandTermGetScrollbackLinesData type CommandTermGetScrollbackLinesData = { linestart: number; diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 8427a47139..022325beaa 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -15,7 +15,9 @@ import ( "sync" "time" + "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/utilds" + "github.com/wavetermdev/waveterm/pkg/waveappstore" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" @@ -150,7 +152,7 @@ func (bc *BuilderController) waitForBuildDone(ctx context.Context) error { } } -func (bc *BuilderController) Start(ctx context.Context, appId string, appPath string, scaffoldPath string, sdkReplacePath string, builderEnv map[string]string) error { +func (bc *BuilderController) Start(ctx context.Context, appId string, builderEnv map[string]string) error { if err := bc.waitForBuildDone(ctx); err != nil { return err } @@ -173,13 +175,24 @@ func (bc *BuilderController) Start(ctx context.Context, appId string, appPath st bc.publishOutputLine(line, false) }) - go bc.buildAndRun(ctx, appPath, scaffoldPath, sdkReplacePath, builderEnv) + buildCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + go func() { + defer cancel() + defer func() { + panichandler.PanicHandler(fmt.Sprintf("buildercontroller[%s].buildAndRun", bc.builderId), recover()) + }() + bc.buildAndRun(buildCtx, appId, builderEnv) + }() return nil } -func (bc *BuilderController) buildAndRun(ctx context.Context, appPath string, scaffoldPath string, sdkReplacePath string, builderEnv map[string]string) { - defer panicRecover(bc.builderId) +func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, builderEnv map[string]string) { + appPath, err := waveappstore.GetAppDir(appId) + if err != nil { + bc.handleBuildError(fmt.Errorf("failed to get app directory: %w", err)) + return + } appName := build.GetAppName(appPath) @@ -195,6 +208,9 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appPath string, sc return } + scaffoldPath := os.Getenv("TSUNAMI_SCAFFOLDPATH") + sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") + _, err = build.TsunamiBuildInternal(build.BuildOpts{ AppPath: appPath, Verbose: true, @@ -310,7 +326,7 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath strin } }() - timeout := time.NewTimer(30 * time.Second) + timeout := time.NewTimer(5 * time.Second) defer timeout.Stop() select { @@ -325,7 +341,7 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appBinPath strin return nil, fmt.Errorf("timeout waiting for port") case <-ctx.Done(): cmd.Process.Kill() - return nil, ctx.Err() + return nil, fmt.Errorf("cancelled while waiting for app port: %w", ctx.Err()) } } @@ -433,8 +449,3 @@ func exitCodeFromWaitErr(waitErr error) int { return 1 } -func panicRecover(builderId string) { - if r := recover(); r != nil { - log.Printf("BuilderController panic for builder %s: %v", builderId, r) - } -} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 92b69ae5bf..3879b08eec 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -326,6 +326,12 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "getbuilderstatus", wshserver.GetBuilderStatusCommand +func GetBuilderStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BuilderStatusData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.BuilderStatusData](w, "getbuilderstatus", data, opts) + return resp, err +} + // command "getfullconfig", wshserver.GetFullConfigCommand func GetFullConfigCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wconfig.FullConfigType, error) { resp, err := sendRpcRequestCallHelper[wconfig.FullConfigType](w, "getfullconfig", nil, opts) @@ -562,6 +568,12 @@ func SetViewCommand(w *wshutil.WshRpc, data wshrpc.CommandBlockSetViewData, opts return err } +// command "startbuilder", wshserver.StartBuilderCommand +func StartBuilderCommand(w *wshutil.WshRpc, data wshrpc.CommandStartBuilderData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "startbuilder", data, opts) + return err +} + // command "streamcpudata", wshserver.StreamCpuDataCommand func StreamCpuDataCommand(w *wshutil.WshRpc, data wshrpc.CpuDataRequest, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "streamcpudata", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 25025ead3f..3b65a429ac 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -160,6 +160,8 @@ const ( Command_DeleteAppFile = "deleteappfile" Command_RenameAppFile = "renameappfile" Command_DeleteBuilder = "deletebuilder" + Command_StartBuilder = "startbuilder" + Command_GetBuilderStatus = "getbuilderstatus" ) type RespOrErrorUnion[T any] struct { @@ -303,6 +305,8 @@ type WshRpcInterface interface { DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error DeleteBuilderCommand(ctx context.Context, builderId string) error + StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error + GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error) // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] @@ -948,3 +952,15 @@ type CommandRenameAppFileData struct { FromFileName string `json:"fromfilename"` ToFileName string `json:"tofilename"` } + +type CommandStartBuilderData struct { + BuilderId string `json:"builderid"` +} + +type BuilderStatusData struct { + Status string `json:"status"` + Port int `json:"port,omitempty"` + ExitCode int `json:"exitcode,omitempty"` + ErrorMsg string `json:"errormsg,omitempty"` + Version int `json:"version"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index b2f506ad83..44c329ccb7 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -963,6 +963,31 @@ func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) return nil } +func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.CommandStartBuilderData) error { + bc := buildercontroller.GetOrCreateController(data.BuilderId) + rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) + if rtInfo == nil { + return fmt.Errorf("builder rtinfo not found for builderid: %s", data.BuilderId) + } + appId := rtInfo.BuilderAppId + if appId == "" { + return fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) + } + return bc.Start(ctx, appId, nil) +} + +func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { + bc := buildercontroller.GetOrCreateController(builderId) + status := bc.GetStatus() + return &wshrpc.BuilderStatusData{ + Status: status.Status, + Port: status.Port, + ExitCode: status.ExitCode, + ErrorMsg: status.ErrorMsg, + Version: status.Version, + }, nil +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { From 764a34addb6d2d03d80429af7b96d54022263385 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 23:38:02 -0700 Subject: [PATCH 06/12] preview working, build output working, env working... --- frontend/app/store/wshclientapi.ts | 5 + frontend/builder/builder-apppanel.tsx | 73 ++++++++++- frontend/builder/builder-buildpanel.tsx | 46 +++++++ frontend/builder/builder-workspace.tsx | 5 +- .../builder/store/builder-apppanel-model.ts | 72 +++++++++- .../builder/store/builder-buildpanel-model.ts | 72 ++++++++++ frontend/builder/tabs/builder-codetab.tsx | 2 +- frontend/builder/tabs/builder-envtab.tsx | 123 ++++++++++++++++++ frontend/builder/tabs/builder-previewtab.tsx | 103 ++++++++++++--- frontend/types/gotypes.d.ts | 1 + pkg/buildercontroller/buildercontroller.go | 7 + pkg/utilds/multireaderlinebuffer.go | 5 + pkg/waveobj/objrtinfo.go | 1 + pkg/wshrpc/wshclient/wshclient.go | 6 + pkg/wshrpc/wshrpctypes.go | 2 + pkg/wshrpc/wshserver/wshserver.go | 7 +- pkg/wstore/wstore_rtinfo.go | 100 ++++++++------ tsunami/build/build.go | 116 +++++++++++------ tsunami/engine/render.go | 12 ++ 19 files changed, 660 insertions(+), 98 deletions(-) create mode 100644 frontend/builder/builder-buildpanel.tsx create mode 100644 frontend/builder/store/builder-buildpanel-model.ts create mode 100644 frontend/builder/tabs/builder-envtab.tsx diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 80242d7daa..c10f3547b7 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -267,6 +267,11 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } + // command "getbuilderoutput" [call] + GetBuilderOutputCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("getbuilderoutput", data, opts); + } + // command "getbuilderstatus" [call] GetBuilderStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("getbuilderstatus", data, opts); diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index ef42dc9ad0..baadbe9ae0 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -4,6 +4,7 @@ import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; +import { BuilderEnvTab } from "@/builder/tabs/builder-envtab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; @@ -71,6 +72,30 @@ const TabButton = memo(({ label, tabType, isActive, isAppFocused, onClick, showS TabButton.displayName = "TabButton"; +const ErrorStrip = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const errorMsg = useAtomValue(model.errorAtom); + + if (!errorMsg) return null; + return ( +
+
+ + {errorMsg} +
+ +
+ ); +}); + +ErrorStrip.displayName = "ErrorStrip"; + const BuilderAppPanel = memo(() => { const model = BuilderAppPanelModel.getInstance(); const focusElemRef = useRef(null); @@ -78,7 +103,9 @@ const BuilderAppPanel = memo(() => { const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; const saveNeeded = useAtomValue(model.saveNeededAtom); + const envSaveNeeded = useAtomValue(model.envSaveNeededAtom); const builderAppId = useAtomValue(atoms.builderAppId); + const builderId = useAtomValue(atoms.builderId); useEffect(() => { model.initialize(); @@ -129,9 +156,19 @@ const BuilderAppPanel = memo(() => { } }, [builderAppId, model]); + const handleEnvSave = useCallback(() => { + if (builderId) { + model.saveEnvVars(builderId); + } + }, [builderId, model]); + + const handleRestart = useCallback(() => { + model.restartBuilder(); + }, [model]); + return (
{ isAppFocused={isAppFocused} onClick={() => handleTabClick("files")} /> + handleTabClick("env")} + />
+ {activeTab === "preview" && ( + + )} {activeTab === "code" && ( )} + {activeTab === "env" && ( + + )}
+
@@ -202,6 +268,11 @@ const BuilderAppPanel = memo(() => {
+
+ + + +
); diff --git a/frontend/builder/builder-buildpanel.tsx b/frontend/builder/builder-buildpanel.tsx new file mode 100644 index 0000000000..500531b6e5 --- /dev/null +++ b/frontend/builder/builder-buildpanel.tsx @@ -0,0 +1,46 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderBuildPanelModel } from "@/builder/store/builder-buildpanel-model"; +import { useAtomValue } from "jotai"; +import { memo, useEffect, useRef } from "react"; + +const BuilderBuildPanel = memo(() => { + const model = BuilderBuildPanelModel.getInstance(); + const outputLines = useAtomValue(model.outputLines); + const scrollRef = useRef(null); + + useEffect(() => { + model.initialize(); + return () => { + model.dispose(); + }; + }, []); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [outputLines]); + + return ( +
+
+ Build Output +
+
+
+                    {outputLines.length === 0 ? (
+                        Waiting for output...
+                    ) : (
+                        outputLines.join("\n")
+                    )}
+                
+
+
+ ); +}); + +BuilderBuildPanel.displayName = "BuilderBuildPanel"; + +export { BuilderBuildPanel }; diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 2ce8f6e374..3b1a84e412 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -5,6 +5,7 @@ import { AIPanel } from "@/app/aipanel/aipanel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanel } from "@/builder/builder-apppanel"; +import { BuilderBuildPanel } from "@/builder/builder-buildpanel"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; @@ -115,9 +116,7 @@ const BuilderWorkspace = memo(() => { -
- Build Panel -
+
diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index ddb550f6b3..86b25406f4 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -9,7 +9,7 @@ import { atoms, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; -export type TabType = "preview" | "files" | "code"; +export type TabType = "preview" | "files" | "code" | "env"; export class BuilderAppPanelModel { private static instance: BuilderAppPanelModel | null = null; @@ -17,10 +17,13 @@ export class BuilderAppPanelModel { activeTab: PrimitiveAtom = atom("preview"); codeContentAtom: PrimitiveAtom = atom(""); originalContentAtom: PrimitiveAtom = atom(""); + envVarsAtom: PrimitiveAtom> = atom>({}); + originalEnvVarsAtom: PrimitiveAtom> = atom>({}); isLoadingAtom: PrimitiveAtom = atom(false); errorAtom: PrimitiveAtom = atom(""); builderStatusAtom = atom(null) as PrimitiveAtom; saveNeededAtom!: Atom; + envSaveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: any | null } = { current: null }; statusUnsubFn: (() => void) | null = null; @@ -31,6 +34,11 @@ export class BuilderAppPanelModel { this.saveNeededAtom = atom((get) => { return get(this.codeContentAtom) !== get(this.originalContentAtom); }); + this.envSaveNeededAtom = atom((get) => { + const current = get(this.envVarsAtom); + const original = get(this.originalEnvVarsAtom); + return JSON.stringify(current) !== JSON.stringify(original); + }); } static getInstance(): BuilderAppPanelModel { @@ -84,6 +92,7 @@ export class BuilderAppPanelModel { const appId = globalStore.get(atoms.builderAppId); await this.loadAppFile(appId); + await this.loadEnvVars(builderId); this.appGoUpdateUnsubFn = waveEventSubscribe({ eventType: "waveapp:appgoupdated", @@ -94,6 +103,44 @@ export class BuilderAppPanelModel { }); } + async loadEnvVars(builderId: string) { + if (!builderId) return; + + try { + const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("builder", builderId), + }); + const envVars = rtInfo?.["builder:env"] || {}; + globalStore.set(this.envVarsAtom, envVars); + globalStore.set(this.originalEnvVarsAtom, envVars); + } catch (err) { + console.error("Failed to load environment variables:", err); + } + } + + async saveEnvVars(builderId: string) { + if (!builderId) return; + + try { + const envVars = globalStore.get(this.envVarsAtom); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("builder", builderId), + data: { + "builder:env": envVars, + }, + }); + globalStore.set(this.originalEnvVarsAtom, envVars); + globalStore.set(this.errorAtom, ""); + } catch (err) { + console.error("Failed to save environment variables:", err); + globalStore.set(this.errorAtom, `Failed to save environment variables: ${err.message || "Unknown error"}`); + } + } + + setEnvVars(envVars: Record) { + globalStore.set(this.envVarsAtom, envVars); + } + async startBuilder() { const builderId = globalStore.get(atoms.builderId); if (!builderId) return; @@ -108,6 +155,20 @@ export class BuilderAppPanelModel { } } + async restartBuilder() { + const builderId = globalStore.get(atoms.builderId); + if (!builderId) return; + + try { + await RpcApi.ControllerStopCommand(TabRpcClient, builderId); + await new Promise((resolve) => setTimeout(resolve, 500)); + await this.startBuilder(); + } catch (err) { + console.error("Failed to restart builder:", err); + globalStore.set(this.errorAtom, `Failed to restart builder: ${err.message || "Unknown error"}`); + } + } + async loadAppFile(appId: string) { if (!appId) { globalStore.set(this.errorAtom, "No app selected"); @@ -133,7 +194,10 @@ export class BuilderAppPanelModel { globalStore.set(this.originalContentAtom, decoded); if (decoded.trim() !== "") { - await this.startBuilder(); + const currentStatus = globalStore.get(this.builderStatusAtom); + if (currentStatus?.status !== "running" && currentStatus?.status !== "building") { + await this.startBuilder(); + } } } } catch (err) { @@ -163,6 +227,10 @@ export class BuilderAppPanelModel { } } + clearError() { + globalStore.set(this.errorAtom, ""); + } + giveFocus() { const activeTab = globalStore.get(this.activeTab); if (activeTab === "code" && this.monacoEditorRef.current) { diff --git a/frontend/builder/store/builder-buildpanel-model.ts b/frontend/builder/store/builder-buildpanel-model.ts new file mode 100644 index 0000000000..e1e376f160 --- /dev/null +++ b/frontend/builder/store/builder-buildpanel-model.ts @@ -0,0 +1,72 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms, WOS } from "@/store/global"; +import { atom, type PrimitiveAtom } from "jotai"; + +export class BuilderBuildPanelModel { + private static instance: BuilderBuildPanelModel | null = null; + + outputLines: PrimitiveAtom = atom([]); + outputUnsubFn: (() => void) | null = null; + initialized = false; + + private constructor() {} + + static getInstance(): BuilderBuildPanelModel { + if (!BuilderBuildPanelModel.instance) { + BuilderBuildPanelModel.instance = new BuilderBuildPanelModel(); + } + return BuilderBuildPanelModel.instance; + } + + async initialize() { + if (this.initialized) return; + this.initialized = true; + + const builderId = globalStore.get(atoms.builderId); + if (!builderId) return; + + if (this.outputUnsubFn) { + this.outputUnsubFn(); + } + + this.outputUnsubFn = waveEventSubscribe({ + eventType: "builderoutput", + scope: WOS.makeORef("builder", builderId), + handler: (event) => { + const data = event.data as { lines?: string[]; reset?: boolean }; + if (!data) return; + + if (data.reset) { + globalStore.set(this.outputLines, data.lines || []); + } else if (data.lines && data.lines.length > 0) { + globalStore.set(this.outputLines, (prev) => [...prev, ...data.lines]); + } + }, + }); + + try { + const output = await RpcApi.GetBuilderOutputCommand(TabRpcClient, builderId); + globalStore.set(this.outputLines, output || []); + } catch (err) { + console.error("Failed to load builder output:", err); + } + } + + clearOutput() { + globalStore.set(this.outputLines, []); + } + + dispose() { + if (this.outputUnsubFn) { + this.outputUnsubFn(); + this.outputUnsubFn = null; + } + this.initialized = false; + } +} \ No newline at end of file diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index cbdd8418eb..37197506cd 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -55,7 +55,7 @@ const BuilderCodeTab = memo(() => { return (
{ + const model = BuilderAppPanelModel.getInstance(); + const builderId = useAtomValue(atoms.builderId); + const envVarsObj = useAtomValue(model.envVarsAtom); + const error = useAtomValue(model.errorAtom); + + const [envVars, setEnvVars] = useState([]); + + useEffect(() => { + setEnvVars(Object.entries(envVarsObj).map(([name, value]) => ({ name, value }))); + }, [envVarsObj]); + + const updateModel = useCallback((vars: EnvVar[]) => { + const obj: Record = {}; + vars.forEach((v) => { + if (v.name.trim()) { + obj[v.name] = v.value; + } + }); + model.setEnvVars(obj); + }, [model]); + + const handleAddVar = useCallback(() => { + const newVars = [...envVars, { name: "", value: "" }]; + setEnvVars(newVars); + }, [envVars]); + + const handleRemoveVar = useCallback((index: number) => { + const newVars = envVars.filter((_, i) => i !== index); + setEnvVars(newVars); + updateModel(newVars); + }, [envVars, updateModel]); + + const handleNameChange = useCallback((index: number, name: string) => { + const newVars = [...envVars]; + newVars[index] = { ...newVars[index], name }; + setEnvVars(newVars); + updateModel(newVars); + }, [envVars, updateModel]); + + const handleValueChange = useCallback((index: number, value: string) => { + const newVars = [...envVars]; + newVars[index] = { ...newVars[index], value }; + setEnvVars(newVars); + updateModel(newVars); + }, [envVars, updateModel]); + + return ( +
+
+

Environment Variables

+ +
+ +
+ These environment variables are transient and only used during builder testing. They are not bundled with the app. +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {envVars.length === 0 ? ( +
+ No environment variables defined. Click "Add Variable" to create one. +
+ ) : ( + envVars.map((envVar, index) => ( +
+ handleNameChange(index, e.target.value)} + placeholder="Variable Name" + className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> + handleValueChange(index, e.target.value)} + placeholder="Value" + className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> + +
+ )) + )} +
+
+
+ ); +}); + +BuilderEnvTab.displayName = "BuilderEnvTab"; + +export { BuilderEnvTab }; \ No newline at end of file diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index e5843e6b0f..ceca7273c1 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -2,13 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { atoms } from "@/store/global"; import { useAtomValue } from "jotai"; -import { memo } from "react"; +import { memo, useState } from "react"; const EmptyStateView = memo(() => { return (
-
+
🏗️

No App to Preview

@@ -17,7 +18,7 @@ const EmptyStateView = memo(() => { want to build, and the AI will help you generate the code.

-
+
Your app will appear here once app.go is created
@@ -28,13 +29,14 @@ const EmptyStateView = memo(() => { EmptyStateView.displayName = "EmptyStateView"; const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { + const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; return (

Build Error

-
{errorMsg}
+
{displayMsg}
@@ -44,12 +46,64 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { ErrorStateView.displayName = "ErrorStateView"; +const BuildingStateView = memo(() => { + return ( +
+
+
⚙️
+
+

App is Building...

+

+ Your WaveApp is being compiled and prepared. This may take a few moments. +

+
+
+
+ ); +}); + +BuildingStateView.displayName = "BuildingStateView"; + +const StoppedStateView = memo(({ onStart }: { onStart: () => void }) => { + const [isStarting, setIsStarting] = useState(false); + + const handleStart = () => { + setIsStarting(true); + onStart(); + setTimeout(() => setIsStarting(false), 2000); + }; + + return ( +
+
+
+

App is Not Running

+

+ Your WaveApp is currently not running. Click the button below to start it. +

+
+ {!isStarting && ( + + )} + {isStarting &&
Starting...
} +
+
+ ); +}); + +StoppedStateView.displayName = "StoppedStateView"; + const BuilderPreviewTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); const isLoading = useAtomValue(model.isLoadingAtom); const originalContent = useAtomValue(model.originalContentAtom); - const error = useAtomValue(model.errorAtom); const builderStatus = useAtomValue(model.builderStatusAtom); + const builderId = useAtomValue(atoms.builderId); const fileExists = originalContent.length > 0; @@ -57,23 +111,40 @@ const BuilderPreviewTab = memo(() => { return null; } - if (error) { - return ; - } - - if (builderStatus?.status === "error" && builderStatus?.errormsg) { - return ; + if (builderStatus?.status === "error") { + return ; } if (!fileExists) { return ; } - return ( -
-

Preview Tab

-
- ); + const status = builderStatus?.status || "init"; + + if (status === "init") { + return null; + } + + if (status === "building") { + return ; + } + + if (status === "stopped") { + return model.startBuilder()} />; + } + + const shouldShowWebView = status === "running" && builderStatus?.port && builderStatus.port !== 0; + + if (shouldShowWebView) { + const previewUrl = `http://localhost:${builderStatus.port}/?clientid=wave:${builderId}`; + return ( +
+ +
+ ); + } + + return null; }); BuilderPreviewTab.displayName = "BuilderPreviewTab"; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 90b95a27a3..93147c67f6 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -797,6 +797,7 @@ declare global { "shell:lastcmdexitcode"?: number; "builder:layout"?: {[key: string]: number}; "builder:appid"?: string; + "builder:env"?: {[key: string]: string}; "waveai:chatid"?: string; }; diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 022325beaa..c9fe8cab2c 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -211,6 +211,8 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil scaffoldPath := os.Getenv("TSUNAMI_SCAFFOLDPATH") sdkReplacePath := os.Getenv("TSUNAMI_SDKREPLACEPATH") + outputCapture := build.MakeOutputCapture() + _, err = build.TsunamiBuildInternal(build.BuildOpts{ AppPath: appPath, Verbose: true, @@ -220,12 +222,17 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil ScaffoldPath: scaffoldPath, SdkReplacePath: sdkReplacePath, NodePath: nodePath, + OutputCapture: outputCapture, }) if err != nil { bc.handleBuildError(fmt.Errorf("build failed: %w", err)) return } + for _, line := range outputCapture.GetLines() { + bc.outputBuffer.AddLine(line) + } + info, err := os.Stat(cachePath) if err != nil { bc.handleBuildError(fmt.Errorf("build output not found: %w", err)) diff --git a/pkg/utilds/multireaderlinebuffer.go b/pkg/utilds/multireaderlinebuffer.go index 657d3649a2..efc38c13d5 100644 --- a/pkg/utilds/multireaderlinebuffer.go +++ b/pkg/utilds/multireaderlinebuffer.go @@ -53,6 +53,11 @@ func (mrlb *MultiReaderLineBuffer) callLineCallback(line string) { } } +func (mrlb *MultiReaderLineBuffer) AddLine(line string) { + mrlb.addLine(line) + mrlb.callLineCallback(line) +} + func (mrlb *MultiReaderLineBuffer) addLine(line string) { mrlb.lock.Lock() defer mrlb.lock.Unlock() diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 655d5a6c8c..a09ca9c714 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -20,6 +20,7 @@ type ObjRTInfo struct { BuilderLayout map[string]float64 `json:"builder:layout,omitempty"` BuilderAppId string `json:"builder:appid,omitempty"` + BuilderEnv map[string]string `json:"builder:env,omitempty"` WaveAIChatId string `json:"waveai:chatid,omitempty"` } diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 3879b08eec..82cf14b00d 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -326,6 +326,12 @@ func FocusWindowCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) er return err } +// command "getbuilderoutput", wshserver.GetBuilderOutputCommand +func GetBuilderOutputCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "getbuilderoutput", data, opts) + return resp, err +} + // command "getbuilderstatus", wshserver.GetBuilderStatusCommand func GetBuilderStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BuilderStatusData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.BuilderStatusData](w, "getbuilderstatus", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 3b65a429ac..a15075663d 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -162,6 +162,7 @@ const ( Command_DeleteBuilder = "deletebuilder" Command_StartBuilder = "startbuilder" Command_GetBuilderStatus = "getbuilderstatus" + Command_GetBuilderOutput = "getbuilderoutput" ) type RespOrErrorUnion[T any] struct { @@ -307,6 +308,7 @@ type WshRpcInterface interface { DeleteBuilderCommand(ctx context.Context, builderId string) error StartBuilderCommand(ctx context.Context, data CommandStartBuilderData) error GetBuilderStatusCommand(ctx context.Context, builderId string) (*BuilderStatusData, error) + GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 44c329ccb7..51e14730b7 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -973,7 +973,7 @@ func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.Comman if appId == "" { return fmt.Errorf("builder appid not set for builderid: %s", data.BuilderId) } - return bc.Start(ctx, appId, nil) + return bc.Start(ctx, appId, rtInfo.BuilderEnv) } func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { @@ -988,6 +988,11 @@ func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId stri }, nil } +func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) { + bc := buildercontroller.GetOrCreateController(builderId) + return bc.GetOutput(), nil +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { diff --git a/pkg/wstore/wstore_rtinfo.go b/pkg/wstore/wstore_rtinfo.go index 372d1db813..912a3ccac0 100644 --- a/pkg/wstore/wstore_rtinfo.go +++ b/pkg/wstore/wstore_rtinfo.go @@ -16,6 +16,68 @@ var ( rtInfoMutex sync.RWMutex ) +func setFieldValue(fieldValue reflect.Value, value any) { + if value == nil { + fieldValue.Set(reflect.Zero(fieldValue.Type())) + return + } + + if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { + fieldValue.SetString(valueStr) + return + } + + if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { + fieldValue.SetBool(valueBool) + return + } + + if fieldValue.Kind() == reflect.Int { + switch v := value.(type) { + case int: + fieldValue.SetInt(int64(v)) + case int64: + fieldValue.SetInt(v) + case float64: + fieldValue.SetInt(int64(v)) + } + return + } + + if fieldValue.Kind() == reflect.Map { + if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.Float64 { + if inputMap, ok := value.(map[string]any); ok { + outputMap := make(map[string]float64) + for k, v := range inputMap { + if floatVal, ok := v.(float64); ok { + outputMap[k] = floatVal + } + } + fieldValue.Set(reflect.ValueOf(outputMap)) + } + return + } + + if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.String { + if inputMap, ok := value.(map[string]any); ok { + outputMap := make(map[string]string) + for k, v := range inputMap { + if strVal, ok := v.(string); ok { + outputMap[k] = strVal + } + } + fieldValue.Set(reflect.ValueOf(outputMap)) + } + return + } + return + } + + if fieldValue.Kind() == reflect.Interface { + fieldValue.Set(reflect.ValueOf(value)) + } +} + // SetRTInfo merges the provided info map into the ObjRTInfo for the given ORef. // Only updates fields that exist in the ObjRTInfo struct. // Removes fields that have nil values. @@ -58,43 +120,7 @@ func SetRTInfo(oref waveobj.ORef, info map[string]any) { continue } - if value == nil { - // Set to zero value (empty string for string fields) - fieldValue.Set(reflect.Zero(fieldValue.Type())) - } else { - // Convert and set the value - if valueStr, ok := value.(string); ok && fieldValue.Kind() == reflect.String { - fieldValue.SetString(valueStr) - } else if valueBool, ok := value.(bool); ok && fieldValue.Kind() == reflect.Bool { - fieldValue.SetBool(valueBool) - } else if fieldValue.Kind() == reflect.Int { - // Handle int fields - need to convert from various numeric types - switch v := value.(type) { - case int: - fieldValue.SetInt(int64(v)) - case int64: - fieldValue.SetInt(v) - case float64: - fieldValue.SetInt(int64(v)) - } - } else if fieldValue.Kind() == reflect.Map { - // Handle map[string]float64 fields - if fieldValue.Type().Key().Kind() == reflect.String && fieldValue.Type().Elem().Kind() == reflect.Float64 { - if inputMap, ok := value.(map[string]any); ok { - outputMap := make(map[string]float64) - for k, v := range inputMap { - if floatVal, ok := v.(float64); ok { - outputMap[k] = floatVal - } - } - fieldValue.Set(reflect.ValueOf(outputMap)) - } - } - } else if fieldValue.Kind() == reflect.Interface { - // Handle any/interface{} fields - fieldValue.Set(reflect.ValueOf(value)) - } - } + setFieldValue(fieldValue, value) } } diff --git a/tsunami/build/build.go b/tsunami/build/build.go index dfdd0e28dc..e50b7eb084 100644 --- a/tsunami/build/build.go +++ b/tsunami/build/build.go @@ -29,6 +29,39 @@ import ( const MinSupportedGoMinorVersion = 22 const TsunamiUIImportPath = "github.com/wavetermdev/waveterm/tsunami/ui" +type OutputCapture struct { + lock sync.Mutex + lines []string +} + +func MakeOutputCapture() *OutputCapture { + return &OutputCapture{ + lines: make([]string, 0), + } +} + +func (oc *OutputCapture) Printf(format string, args ...interface{}) { + if oc == nil { + log.Printf(format, args...) + return + } + line := fmt.Sprintf(format, args...) + oc.lock.Lock() + defer oc.lock.Unlock() + oc.lines = append(oc.lines, line) +} + +func (oc *OutputCapture) GetLines() []string { + if oc == nil { + return nil + } + oc.lock.Lock() + defer oc.lock.Unlock() + result := make([]string, len(oc.lines)) + copy(result, oc.lines) + return result +} + type BuildOpts struct { AppPath string Verbose bool @@ -39,6 +72,7 @@ type BuildOpts struct { SdkReplacePath string NodePath string MoveFileBack bool + OutputCapture *OutputCapture } func GetAppName(appPath string) string { @@ -97,6 +131,8 @@ func findGoExecutable() (string, error) { } func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { + oc := opts.OutputCapture + // Find Go executable using enhanced search goPath, err := findGoExecutable() if err != nil { @@ -113,7 +149,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { // Parse go version output and check for 1.22+ versionStr := strings.TrimSpace(string(output)) if verbose { - log.Printf("Found %s", versionStr) + oc.Printf("Found %s", versionStr) } // Extract version like "go1.22.0" from output @@ -159,7 +195,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } if verbose { - log.Printf("Using custom node path: %s", opts.NodePath) + oc.Printf("Using custom node path: %s", opts.NodePath) } } else { // Use standard PATH lookup @@ -169,7 +205,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } if verbose { - log.Printf("Found node in PATH") + oc.Printf("Found node in PATH") } } @@ -180,6 +216,7 @@ func verifyEnvironment(verbose bool, opts BuildOpts) (*BuildEnv, error) { } func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose bool) error { + oc := opts.OutputCapture modulePath := fmt.Sprintf("tsunami/app/%s", appName) // Check if go.mod already exists in temp directory (copied from app path) @@ -190,7 +227,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo if _, err := os.Stat(tempGoModPath); err == nil { // go.mod exists in temp dir, parse it if verbose { - log.Printf("Found existing go.mod in temp directory, parsing it") + oc.Printf("Found existing go.mod in temp directory, parsing it") } // Parse the existing go.mod @@ -206,7 +243,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } 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") + oc.Printf("No existing go.mod found, creating new one") } modFile = &modfile.File{} @@ -244,9 +281,9 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } 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) + oc.Printf("Created go.mod with module path: %s", modulePath) + oc.Printf("Added require: github.com/wavetermdev/waveterm/tsunami v0.0.0") + oc.Printf("Added replace directive: github.com/wavetermdev/waveterm/tsunami => %s", opts.SdkReplacePath) } // Run go mod tidy to clean up dependencies @@ -254,7 +291,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo tidyCmd.Dir = tempDir if verbose { - log.Printf("Running go mod tidy") + oc.Printf("Running go mod tidy") tidyCmd.Stdout = os.Stdout tidyCmd.Stderr = os.Stderr } @@ -264,7 +301,7 @@ func createGoMod(tempDir, appName, goVersion string, opts BuildOpts, verbose boo } if verbose { - log.Printf("Successfully ran go mod tidy") + oc.Printf("Successfully ran go mod tidy") } return nil @@ -409,6 +446,8 @@ func TsunamiBuild(opts BuildOpts) error { } func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { + oc := opts.OutputCapture + buildEnv, err := verifyEnvironment(opts.Verbose, opts) if err != nil { return nil, err @@ -446,26 +485,26 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { buildEnv.TempDir = tempDir - log.Printf("Building tsunami app from %s\n", opts.AppPath) + oc.Printf("Building tsunami app from %s", opts.AppPath) if opts.Verbose || opts.KeepTemp { - log.Printf("Temp dir: %s\n", tempDir) + oc.Printf("Temp dir: %s", tempDir) } // Copy files from app path (go.mod, go.sum, static/, *.go) - copyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose) + copyStats, err := copyFilesFromAppFS(appFS, opts.AppPath, tempDir, opts.Verbose, oc) if err != nil { return buildEnv, fmt.Errorf("failed to copy files from app path: %w", err) } // Copy scaffold directory contents selectively - scaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, opts.Verbose) + scaffoldCount, err := copyScaffoldFS(scaffoldFS, tempDir, opts.Verbose, oc) 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 (go.mod: %t, go.sum: %t)\n", + oc.Printf("Copied %d go files, %d static files, %d scaffold files (go.mod: %t, go.sum: %t)", copyStats.GoFiles, copyStats.StaticFiles, scaffoldCount, copyStats.GoMod, copyStats.GoSum) } @@ -496,10 +535,10 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { return buildEnv, fmt.Errorf("failed to create ui symlink: %w", err) } if opts.Verbose { - log.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath) + oc.Printf("Created UI symlink: %s -> %s", uiLinkPath, uiTargetPath) } } else if opts.Verbose { - log.Printf("Skipping UI symlink creation - no UI package imports found") + oc.Printf("Skipping UI symlink creation - no UI package imports found") } // Generate Tailwind CSS @@ -514,19 +553,19 @@ func TsunamiBuildInternal(opts BuildOpts) (*BuildEnv, error) { // Move generated files back to original directory if opts.MoveFileBack && canWrite { - if err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose); err != nil { + if err := moveFilesBack(tempDir, opts.AppPath, opts.Verbose, oc); err != nil { return buildEnv, fmt.Errorf("failed to move files back: %w", err) } } else if opts.MoveFileBack && !canWrite { if opts.Verbose { - log.Printf("Skipping move files back - app path is not writable: %s", opts.AppPath) + oc.Printf("Skipping move files back - app path is not writable: %s", opts.AppPath) } } return buildEnv, nil } -func moveFilesBack(tempDir, originalDir string, verbose bool) error { +func moveFilesBack(tempDir, originalDir string, verbose bool, oc *OutputCapture) error { // Move go.mod back to original directory goModSrc := filepath.Join(tempDir, "go.mod") goModDest := filepath.Join(originalDir, "go.mod") @@ -534,7 +573,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy go.mod back: %w", err) } if verbose { - log.Printf("Moved go.mod back to %s", goModDest) + oc.Printf("Moved go.mod back to %s", goModDest) } // Move go.sum back to original directory (only if it exists) @@ -545,7 +584,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy go.sum back: %w", err) } if verbose { - log.Printf("Moved go.sum back to %s", goSumDest) + oc.Printf("Moved go.sum back to %s", goSumDest) } } @@ -555,7 +594,7 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to create static directory: %w", err) } if verbose { - log.Printf("Ensured static directory exists at %s", staticDir) + oc.Printf("Ensured static directory exists at %s", staticDir) } // Move tw.css back to original directory @@ -565,13 +604,14 @@ func moveFilesBack(tempDir, originalDir string, verbose bool) error { return fmt.Errorf("failed to copy tw.css back: %w", err) } if verbose { - log.Printf("Moved tw.css back to %s", twCssDest) + oc.Printf("Moved tw.css back to %s", twCssDest) } return nil } func runGoBuild(tempDir string, opts BuildOpts) error { + oc := opts.OutputCapture var outputPath string if opts.OutputFile != "" { // Convert to absolute path resolved against current working directory @@ -603,7 +643,7 @@ func runGoBuild(tempDir string, opts BuildOpts) error { buildCmd.Dir = tempDir if opts.Verbose { - log.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) + oc.Printf("Running: %s", strings.Join(buildCmd.Args, " ")) buildCmd.Stdout = os.Stdout buildCmd.Stderr = os.Stderr } @@ -614,9 +654,9 @@ func runGoBuild(tempDir string, opts BuildOpts) error { if opts.Verbose { if opts.OutputFile != "" { - log.Printf("Application built successfully at %s", outputPath) + oc.Printf("Application built successfully at %s", outputPath) } else { - log.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) + oc.Printf("Application built successfully at %s", filepath.Join(tempDir, "bin", "app")) } } @@ -624,6 +664,7 @@ func runGoBuild(tempDir string, opts BuildOpts) error { } func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error { + oc := opts.OutputCapture // tailwind.css is already in tempDir from scaffold copy tailwindOutput := filepath.Join(tempDir, "static", "tw.css") @@ -634,7 +675,7 @@ func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error tailwindCmd.Env = append(os.Environ(), "ELECTRON_RUN_AS_NODE=1") if verbose { - log.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) + oc.Printf("Running: %s", strings.Join(tailwindCmd.Args, " ")) } if err := tailwindCmd.Run(); err != nil { @@ -642,7 +683,7 @@ func generateAppTailwindCss(tempDir string, verbose bool, opts BuildOpts) error } if verbose { - log.Printf("Tailwind CSS generated successfully") + oc.Printf("Tailwind CSS generated successfully") } return nil @@ -681,7 +722,7 @@ func copyGoFilesFromFS(fsys fs.FS, destDir string) (int, error) { } // appPath is just used for logging (we do the copies from appFS) -func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*CopyStats, error) { +func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool, oc *OutputCapture) (*CopyStats, error) { stats := &CopyStats{} // Copy go.mod if it exists @@ -692,7 +733,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } stats.GoMod = copied if copied && verbose { - log.Printf("Copied go.mod from %s", filepath.Join(appPath, "go.mod")) + oc.Printf("Copied go.mod from %s", filepath.Join(appPath, "go.mod")) } // Copy go.sum if it exists @@ -703,7 +744,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } stats.GoSum = copied if copied && verbose { - log.Printf("Copied go.sum from %s", filepath.Join(appPath, "go.sum")) + oc.Printf("Copied go.sum from %s", filepath.Join(appPath, "go.sum")) } // Copy manifest.json if it exists @@ -713,7 +754,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co return nil, err } if copied && verbose { - log.Printf("Copied manifest.json from %s", filepath.Join(appPath, "manifest.json")) + oc.Printf("Copied manifest.json from %s", filepath.Join(appPath, "manifest.json")) } // Copy static directory @@ -735,6 +776,7 @@ func copyFilesFromAppFS(appFS fs.FS, appPath, tempDir string, verbose bool) (*Co } func TsunamiRun(opts BuildOpts) error { + oc := opts.OutputCapture buildEnv, err := TsunamiBuildInternal(opts) defer buildEnv.cleanupTempDir(opts.KeepTemp, opts.Verbose) if err != nil { @@ -747,7 +789,7 @@ func TsunamiRun(opts BuildOpts) error { runCmd := exec.Command(appBinPath) runCmd.Dir = buildEnv.TempDir - log.Printf("Running tsunami app from %s", opts.AppPath) + oc.Printf("Running tsunami app from %s", opts.AppPath) runCmd.Stdin = os.Stdin @@ -841,7 +883,7 @@ func ParseTsunamiPort(line string) int { return port } -func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) { +func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool, oc *OutputCapture) (int, error) { fileCount := 0 // Handle node_modules directory - prefer symlink if possible, otherwise copy @@ -855,7 +897,7 @@ func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) return 0, fmt.Errorf("failed to create symlink for node_modules: %w", err) } if verbose { - log.Printf("Symlinked node_modules directory\n") + oc.Printf("Symlinked node_modules directory") } fileCount++ } else { @@ -865,7 +907,7 @@ func copyScaffoldFS(scaffoldFS fs.FS, destDir string, verbose bool) (int, error) return 0, fmt.Errorf("failed to copy node_modules directory: %w", err) } if verbose { - log.Printf("Copied node_modules directory (%d files)\n", dirCount) + oc.Printf("Copied node_modules directory (%d files)", dirCount) } fileCount += dirCount } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index 5da8dc6262..bf8bb0f4e7 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -250,12 +250,24 @@ func convertPropsToVDom(props map[string]any) map[string]any { vdomProps[k] = vdomFunc continue } + if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok { + // ensure Type is set on all VDomFuncs (pointer) + vdomFuncPtr.Type = vdom.ObjectType_Func + vdomProps[k] = vdomFuncPtr + continue + } if vdomRef, ok := v.(vdom.VDomRef); ok { // ensure Type is set on all VDomRefs vdomRef.Type = vdom.ObjectType_Ref vdomProps[k] = vdomRef continue } + if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { + // ensure Type is set on all VDomRefs (pointer) + vdomRefPtr.Type = vdom.ObjectType_Ref + vdomProps[k] = vdomRefPtr + continue + } val := reflect.ValueOf(v) if val.Kind() == reflect.Func { // convert go functions passed to event handlers to VDomFuncs From e53dd9a1bcaa7d9b855f554b2298e5955ba26eae Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 27 Oct 2025 23:53:14 -0700 Subject: [PATCH 07/12] fix editspec json to match tool --- pkg/util/fileutil/fileutil.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/util/fileutil/fileutil.go b/pkg/util/fileutil/fileutil.go index 670da2a765..708eb5c725 100644 --- a/pkg/util/fileutil/fileutil.go +++ b/pkg/util/fileutil/fileutil.go @@ -256,9 +256,9 @@ const ( ) type EditSpec struct { - OldStr string - NewStr string - Desc string + OldStr string `json:"old_str"` + NewStr string `json:"new_str"` + Desc string `json:"desc,omitempty"` } func ReplaceInFile(filePath string, edits []EditSpec) error { From 80c94036e0ff76193192a094e809760a659780b5 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 11:16:48 -0700 Subject: [PATCH 08/12] restart on save or change of app.go --- frontend/builder/store/builder-apppanel-model.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 86b25406f4..882b570f16 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -8,6 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; +import { debounce } from "throttle-debounce"; export type TabType = "preview" | "files" | "code" | "env"; @@ -28,9 +29,13 @@ export class BuilderAppPanelModel { monacoEditorRef: { current: any | null } = { current: null }; statusUnsubFn: (() => void) | null = null; appGoUpdateUnsubFn: (() => void) | null = null; + debouncedRestart: (() => void) & { cancel: () => void }; initialized = false; private constructor() { + this.debouncedRestart = debounce(800, () => { + this.restartBuilder(); + }); this.saveNeededAtom = atom((get) => { return get(this.codeContentAtom) !== get(this.originalContentAtom); }); @@ -99,6 +104,7 @@ export class BuilderAppPanelModel { scope: appId, handler: () => { this.loadAppFile(appId); + this.debouncedRestart(); }, }); } @@ -131,6 +137,7 @@ export class BuilderAppPanelModel { }); globalStore.set(this.originalEnvVarsAtom, envVars); globalStore.set(this.errorAtom, ""); + this.debouncedRestart(); } catch (err) { console.error("Failed to save environment variables:", err); globalStore.set(this.errorAtom, `Failed to save environment variables: ${err.message || "Unknown error"}`); @@ -221,6 +228,7 @@ export class BuilderAppPanelModel { }); globalStore.set(this.originalContentAtom, content); globalStore.set(this.errorAtom, ""); + this.debouncedRestart(); } catch (err) { console.error("Failed to save app.go:", err); globalStore.set(this.errorAtom, `Failed to save app.go: ${err.message || "Unknown error"}`); @@ -257,5 +265,6 @@ export class BuilderAppPanelModel { this.appGoUpdateUnsubFn(); this.appGoUpdateUnsubFn = null; } + this.debouncedRestart.cancel(); } } From 898accca15a485010543c5998fc5c6bb5d345cd8 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 11:29:06 -0700 Subject: [PATCH 09/12] better secret input (pw fields) --- frontend/builder/tabs/builder-envtab.tsx | 34 +++++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/frontend/builder/tabs/builder-envtab.tsx b/frontend/builder/tabs/builder-envtab.tsx index d4be90e40d..2e7eda265e 100644 --- a/frontend/builder/tabs/builder-envtab.tsx +++ b/frontend/builder/tabs/builder-envtab.tsx @@ -18,6 +18,7 @@ const BuilderEnvTab = memo(() => { const error = useAtomValue(model.errorAtom); const [envVars, setEnvVars] = useState([]); + const [visibleValues, setVisibleValues] = useState>({}); useEffect(() => { setEnvVars(Object.entries(envVarsObj).map(([name, value]) => ({ name, value }))); @@ -58,6 +59,13 @@ const BuilderEnvTab = memo(() => { updateModel(newVars); }, [envVars, updateModel]); + const toggleValueVisibility = useCallback((index: number) => { + setVisibleValues((prev) => ({ + ...prev, + [index]: !prev[index], + })); + }, []); + return (
@@ -96,13 +104,25 @@ const BuilderEnvTab = memo(() => { placeholder="Variable Name" className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" /> - handleValueChange(index, e.target.value)} - placeholder="Value" - className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> +
+ handleValueChange(index, e.target.value)} + placeholder="Value" + className="w-full px-3 py-2 pr-10 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> + +
+
+ +
+ ); +}); - const handleBlur = useCallback(() => { - updateModel(envVars); - }, [envVars, updateModel]); +EnvVarRow.displayName = "EnvVarRow"; - const toggleValueVisibility = useCallback((index: number) => { - setVisibleValues((prev) => ({ - ...prev, - [index]: !prev[index], - })); - }, []); +const BuilderEnvTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const envVars = useAtomValue(model.envVarsArrayAtom); + const error = useAtomValue(model.errorAtom); return (
@@ -74,21 +70,18 @@ const BuilderEnvTab = memo(() => {

Environment Variables

- These environment variables are transient and only used during builder testing. They are not bundled with the app. + These environment variables are transient and only used during builder testing. They are not bundled + with the app.
- {error && ( -
- {error} -
- )} + {error &&
{error}
}
@@ -97,44 +90,7 @@ const BuilderEnvTab = memo(() => { No environment variables defined. Click "Add Variable" to create one.
) : ( - envVars.map((envVar, index) => ( -
- handleNameChange(index, e.target.value)} - onBlur={handleBlur} - placeholder="Variable Name" - className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> -
- handleValueChange(index, e.target.value)} - onBlur={handleBlur} - placeholder="Value" - className="w-full px-3 py-2 pr-10 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> - -
- -
- )) + envVars.map((_, index) => ) )}
@@ -144,4 +100,4 @@ const BuilderEnvTab = memo(() => { BuilderEnvTab.displayName = "BuilderEnvTab"; -export { BuilderEnvTab }; \ No newline at end of file +export { BuilderEnvTab }; From bef420f1d15021b64bf7aaa6deccb1e28f5becf1 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 28 Oct 2025 13:57:04 -0700 Subject: [PATCH 12/12] add better error messages about missing builder/app ids... --- pkg/wshrpc/wshserver/wshserver.go | 27 +++++++++++++++++++++++++++ tsunami/engine/render.go | 10 ++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 51e14730b7..df90065157 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -899,6 +899,9 @@ func (ws *WshServer) ListAllEditableAppsCommand(ctx context.Context) ([]string, } func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.CommandListAllAppFilesData) (*wshrpc.CommandListAllAppFilesRtnData, error) { + if data.AppId == "" { + return nil, fmt.Errorf("must provide an appId to ListAllAppFilesCommand") + } result, err := waveappstore.ListAllAppFiles(data.AppId) if err != nil { return nil, err @@ -927,6 +930,9 @@ func (ws *WshServer) ListAllAppFilesCommand(ctx context.Context, data wshrpc.Com } func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.CommandReadAppFileData) (*wshrpc.CommandReadAppFileRtnData, error) { + if data.AppId == "" { + return nil, fmt.Errorf("must provide an appId to ReadAppFileCommand") + } fileData, err := waveappstore.ReadAppFile(data.AppId, data.FileName) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -943,6 +949,9 @@ func (ws *WshServer) ReadAppFileCommand(ctx context.Context, data wshrpc.Command } func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.CommandWriteAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to WriteAppFileCommand") + } contents, err := base64.StdEncoding.DecodeString(data.Data64) if err != nil { return fmt.Errorf("failed to decode data64: %w", err) @@ -951,19 +960,31 @@ func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.Comman } func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to DeleteAppFileCommand") + } return waveappstore.DeleteAppFile(data.AppId, data.FileName) } func (ws *WshServer) RenameAppFileCommand(ctx context.Context, data wshrpc.CommandRenameAppFileData) error { + if data.AppId == "" { + return fmt.Errorf("must provide an appId to RenameAppFileCommand") + } return waveappstore.RenameAppFile(data.AppId, data.FromFileName, data.ToFileName) } func (ws *WshServer) DeleteBuilderCommand(ctx context.Context, builderId string) error { + if builderId == "" { + return fmt.Errorf("must provide a builderId to DeleteBuilderCommand") + } buildercontroller.DeleteController(builderId) return nil } func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.CommandStartBuilderData) error { + if data.BuilderId == "" { + return fmt.Errorf("must provide a builderId to StartBuilderCommand") + } bc := buildercontroller.GetOrCreateController(data.BuilderId) rtInfo := wstore.GetRTInfo(waveobj.MakeORef("builder", data.BuilderId)) if rtInfo == nil { @@ -977,6 +998,9 @@ func (ws *WshServer) StartBuilderCommand(ctx context.Context, data wshrpc.Comman } func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId string) (*wshrpc.BuilderStatusData, error) { + if builderId == "" { + return nil, fmt.Errorf("must provide a builderId to GetBuilderStatusCommand") + } bc := buildercontroller.GetOrCreateController(builderId) status := bc.GetStatus() return &wshrpc.BuilderStatusData{ @@ -989,6 +1013,9 @@ func (ws *WshServer) GetBuilderStatusCommand(ctx context.Context, builderId stri } func (ws *WshServer) GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) { + if builderId == "" { + return nil, fmt.Errorf("must provide a builderId to GetBuilderOutputCommand") + } bc := buildercontroller.GetOrCreateController(builderId) return bc.GetOutput(), nil } diff --git a/tsunami/engine/render.go b/tsunami/engine/render.go index bf8bb0f4e7..145b537dd8 100644 --- a/tsunami/engine/render.go +++ b/tsunami/engine/render.go @@ -102,10 +102,10 @@ func (r *RootElem) renderComponent(cfunc any, elem *vdom.VDomElem, comp **Compon 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 @@ -251,6 +251,9 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } if vdomFuncPtr, ok := v.(*vdom.VDomFunc); ok { + if vdomFuncPtr == nil { + continue // handled typed-nil + } // ensure Type is set on all VDomFuncs (pointer) vdomFuncPtr.Type = vdom.ObjectType_Func vdomProps[k] = vdomFuncPtr @@ -263,6 +266,9 @@ func convertPropsToVDom(props map[string]any) map[string]any { continue } if vdomRefPtr, ok := v.(*vdom.VDomRef); ok { + if vdomRefPtr == nil { + continue // handle typed-nil + } // ensure Type is set on all VDomRefs (pointer) vdomRefPtr.Type = vdom.ObjectType_Ref vdomProps[k] = vdomRefPtr