From c4a4dc95b10b2d8ff7190b64fac285f21e0f1ccc Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 13 Nov 2025 18:39:35 -0800 Subject: [PATCH 1/5] have backend set manifest data (title, shortdesc) in rtinfo instead of the FE... (pull from manifest) --- frontend/app/view/tsunami/tsunami.tsx | 60 ---------------------- frontend/types/gotypes.d.ts | 9 +++- pkg/blockcontroller/tsunamicontroller.go | 20 +++++++- pkg/buildercontroller/buildercontroller.go | 6 ++- pkg/wshrpc/wshrpctypes.go | 8 ++- tsunami/engine/clientimpl.go | 6 +-- 6 files changed, 38 insertions(+), 71 deletions(-) diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index c5c6e47de..263ecc0f6 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -11,11 +11,6 @@ import * as services from "@/store/services"; import * as jotai from "jotai"; import { memo, useEffect } from "react"; -interface TsunamiAppMeta { - title: string; - shortdesc: string; -} - class TsunamiViewModel extends WebViewModel { shellProcFullStatus: jotai.PrimitiveAtom; shellProcStatusUnsubFn: () => void; @@ -126,28 +121,6 @@ class TsunamiViewModel extends WebViewModel { this.doControllerResync(true, "force restart"); } - setAppMeta(meta: TsunamiAppMeta) { - console.log("tsunami app meta:", meta); - - const rtInfo: ObjRTInfo = {}; - if (meta.title) { - rtInfo["tsunami:title"] = meta.title; - } - if (meta.shortdesc) { - rtInfo["tsunami:shortdesc"] = meta.shortdesc; - } - - if (Object.keys(rtInfo).length > 0) { - const oref = WOS.makeORef("block", this.blockId); - const data: CommandSetRTInfoData = { - oref: oref, - data: rtInfo, - }; - - RpcApi.SetRTInfoCommand(TabRpcClient, data).catch((e) => console.log("error setting RT info", e)); - } - } - dispose() { if (this.shellProcStatusUnsubFn) { this.shellProcStatusUnsubFn(); @@ -201,39 +174,6 @@ const TsunamiView = memo((props: ViewComponentProps) => { model.resyncController(); }, [model]); - useEffect(() => { - if (!domReady || !model.webviewRef?.current) return; - - const webviewElement = model.webviewRef.current; - - const handleConsoleMessage = (e: any) => { - const message = e.message; - if (typeof message === "string" && message.startsWith("TSUNAMI_META ")) { - try { - const jsonStr = message.substring("TSUNAMI_META ".length); - const meta = JSON.parse(jsonStr); - if (meta.title || meta.shortdesc) { - model.setAppMeta(meta); - - if (meta.title) { - const truncatedTitle = - meta.title.length > 77 ? meta.title.substring(0, 77) + "..." : meta.title; - globalStore.set(model.viewName, truncatedTitle); - } - } - } catch (error) { - console.error("Failed to parse TSUNAMI_META message:", error); - } - } - }; - - webviewElement.addEventListener("console-message", handleConsoleMessage); - - return () => { - webviewElement.removeEventListener("console-message", handleConsoleMessage); - }; - }, [domReady, model]); - const appPath = blockData?.meta?.["tsunami:apppath"]; const appId = blockData?.meta?.["tsunami:appid"]; const controller = blockData?.meta?.controller; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 9353f79ed..4bf3801eb 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -64,13 +64,18 @@ declare global { // wshrpc.AppManifest type AppManifest = { - apptitle: string; - appshortdesc: string; + appmeta: AppMeta; configschema: {[key: string]: any}; dataschema: {[key: string]: any}; secrets: {[key: string]: SecretMeta}; }; + // wshrpc.AppMeta + type AppMeta = { + title: string; + shortdesc: string; + }; + // waveobj.Block type Block = WaveObj & { parentoref?: string; diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 29d0b500a..7206ee26d 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -139,8 +139,9 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap goPath := settings.TsunamiGoPath appPath := blockMeta.GetString(waveobj.MetaKey_TsunamiAppPath, "") + appId := blockMeta.GetString(waveobj.MetaKey_TsunamiAppId, "") + if appPath == "" { - appId := blockMeta.GetString(waveobj.MetaKey_TsunamiAppId, "") if appId == "" { return fmt.Errorf("tsunami:apppath or tsunami:appid is required") } @@ -160,6 +161,23 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap } } + // Read and set app metadata from manifest if appId is available + if appId != "" { + if manifest, err := waveappstore.ReadAppManifest(appId); err == nil { + blockRef := waveobj.MakeORef(waveobj.OType_Block, c.blockId) + rtInfo := make(map[string]any) + if manifest.AppMeta.Title != "" { + rtInfo["tsunami:title"] = manifest.AppMeta.Title + } + if manifest.AppMeta.ShortDesc != "" { + rtInfo["tsunami:shortdesc"] = manifest.AppMeta.ShortDesc + } + if len(rtInfo) > 0 { + wstore.SetRTInfo(blockRef, rtInfo) + } + } + } + appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 2394a3131..27ff1f9e8 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -512,8 +512,10 @@ func (bc *BuilderController) GetStatus() wshrpc.BuilderStatusData { manifest, err := waveappstore.ReadAppManifest(bc.appId) if err == nil && manifest != nil { wshrpcManifest := &wshrpc.AppManifest{ - AppTitle: manifest.AppTitle, - AppShortDesc: manifest.AppShortDesc, + AppMeta: wshrpc.AppMeta{ + Title: manifest.AppMeta.Title, + ShortDesc: manifest.AppMeta.ShortDesc, + }, ConfigSchema: manifest.ConfigSchema, DataSchema: manifest.DataSchema, Secrets: make(map[string]wshrpc.SecretMeta), diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index bb2a3708f..a329057f1 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -1038,14 +1038,18 @@ type RestartBuilderAndWaitResult struct { BuildOutput string `json:"buildoutput"` } +type AppMeta struct { + Title string `json:"title"` + ShortDesc string `json:"shortdesc"` +} + type SecretMeta struct { Desc string `json:"desc"` Optional bool `json:"optional"` } type AppManifest struct { - AppTitle string `json:"apptitle"` - AppShortDesc string `json:"appshortdesc"` + AppMeta AppMeta `json:"appmeta"` ConfigSchema map[string]any `json:"configschema"` DataSchema map[string]any `json:"dataschema"` Secrets map[string]SecretMeta `json:"secrets"` diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index ffcf5fd73..d9aa6a891 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -51,8 +51,7 @@ type SecretMeta struct { } type AppManifest struct { - AppTitle string `json:"apptitle"` - AppShortDesc string `json:"appshortdesc"` + AppMeta AppMeta `json:"appmeta"` ConfigSchema map[string]any `json:"configschema"` DataSchema map[string]any `json:"dataschema"` Secrets map[string]SecretMeta `json:"secrets"` @@ -500,8 +499,7 @@ func (c *ClientImpl) GetAppManifest() AppManifest { secrets := c.GetSecrets() return AppManifest{ - AppTitle: appMeta.Title, - AppShortDesc: appMeta.ShortDesc, + AppMeta: appMeta, ConfigSchema: configSchema, DataSchema: dataSchema, Secrets: secrets, From 1f6a37bade83d35b40d688fe12001c9fd0c2298e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 13 Nov 2025 21:05:12 -0800 Subject: [PATCH 2/5] only edit "draft" appids, better error handling for publish --- frontend/app/store/wshclientapi.ts | 5 +++ frontend/app/view/tsunami/tsunami.tsx | 34 +++++++++++++- frontend/builder/app-selection-modal.tsx | 20 +++++++-- frontend/builder/builder-app.tsx | 3 +- frontend/builder/builder-apppanel.tsx | 56 ++++++++++++++++++++---- frontend/types/gotypes.d.ts | 10 +++++ pkg/blockcontroller/tsunamicontroller.go | 5 +++ pkg/wps/wpstypes.go | 1 + pkg/wshrpc/wshclient/wshclient.go | 6 +++ pkg/wshrpc/wshrpctypes.go | 10 +++++ pkg/wshrpc/wshserver/wshserver.go | 10 +++++ 11 files changed, 145 insertions(+), 15 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index b1a561604..a453b79d6 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -367,6 +367,11 @@ class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "makedraftfromlocal" [call] + MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { + return client.wshRpcCall("makedraftfromlocal", data, opts); + } + // command "message" [call] MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise { return client.wshRpcCall("message", data, opts); diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index 263ecc0f6..1b502e3d5 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -14,14 +14,15 @@ import { memo, useEffect } from "react"; class TsunamiViewModel extends WebViewModel { shellProcFullStatus: jotai.PrimitiveAtom; shellProcStatusUnsubFn: () => void; + appMeta: jotai.PrimitiveAtom; + appMetaUnsubFn: () => void; isRestarting: jotai.PrimitiveAtom; - viewName: jotai.PrimitiveAtom; + viewName: jotai.Atom; constructor(blockId: string, nodeModel: BlockNodeModel) { super(blockId, nodeModel); this.viewType = "tsunami"; this.viewIcon = jotai.atom("cube"); - this.viewName = jotai.atom("Tsunami"); this.isRestarting = jotai.atom(false); // Hide navigation bar (URL bar, back/forward/home buttons) @@ -43,6 +44,32 @@ class TsunamiViewModel extends WebViewModel { this.updateShellProcStatus(bcRTS); }, }); + + this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom; + this.viewName = jotai.atom((get) => { + const meta = get(this.appMeta); + return meta?.title || "WaveApp"; + }); + const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + }); + initialRTInfo.then((rtInfo) => { + if (rtInfo) { + const meta: AppMeta = { + title: rtInfo["tsunami:title"], + shortdesc: rtInfo["tsunami:shortdesc"], + }; + globalStore.set(this.appMeta, meta); + } + }); + this.appMetaUnsubFn = waveEventSubscribe({ + eventType: "tsunami:updatemeta", + scope: WOS.makeORef("block", blockId), + handler: (event) => { + const meta: AppMeta = event.data; + globalStore.set(this.appMeta, meta); + }, + }); } get viewComponent(): ViewComponent { @@ -125,6 +152,9 @@ class TsunamiViewModel extends WebViewModel { if (this.shellProcStatusUnsubFn) { this.shellProcStatusUnsubFn(); } + if (this.appMetaUnsubFn) { + this.appMetaUnsubFn(); + } } getSettingsMenuItems(): ContextMenuItem[] { diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index ef1856a6f..e11d288ea 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -120,14 +120,28 @@ export function AppSelectionModal() { }; const handleSelectApp = async (appId: string) => { + let appIdToUse = appId; + + // If selecting a local app, convert it to a draft first + if (appId.startsWith("local/")) { + try { + const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); + appIdToUse = result.draftappid; + } catch (err) { + console.error("Failed to create draft from local app:", err); + setError(`Failed to create draft from ${appId}: ${err.message || String(err)}`); + return; + } + } + const builderId = globalStore.get(atoms.builderId); const oref = WOS.makeORef("builder", builderId); await RpcApi.SetRTInfoCommand(TabRpcClient, { oref, - data: { "builder:appid": appId }, + data: { "builder:appid": appIdToUse }, }); - globalStore.set(atoms.builderAppId, appId); - document.title = `WaveApp Builder (${appId})`; + globalStore.set(atoms.builderAppId, appIdToUse); + document.title = `WaveApp Builder (${appIdToUse})`; }; const handleCreateNew = async (appName: string) => { diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index fd1b55fd0..a7f092e9c 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -32,6 +32,7 @@ const BuilderKeyHandlers = () => { function BuilderAppInner() { const builderAppId = useAtomValue(atoms.builderAppId); + const hasDraftApp = !isBlank(builderAppId) && builderAppId.startsWith("draft/"); return (
@@ -45,7 +46,7 @@ function BuilderAppInner() {
- {isBlank(builderAppId) ? : } + {hasDraftApp ? : } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 5b361cc10..dcb3931f2 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -16,7 +16,7 @@ import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; const StatusDot = memo(() => { const model = BuilderAppPanelModel.getInstance(); @@ -102,33 +102,71 @@ ErrorStrip.displayName = "ErrorStrip"; const PublishAppModal = memo(({ appName }: { appName: string }) => { const builderAppId = useAtomValue(atoms.builderAppId); + const [state, setState] = useState<"confirm" | "success" | "error">("confirm"); + const [errorMessage, setErrorMessage] = useState(""); + const [publishedAppId, setPublishedAppId] = useState(""); const handlePublish = async () => { if (!builderAppId) { - console.error("No builder app ID found"); - modalsModel.popModal(); + setErrorMessage("No builder app ID found"); + setState("error"); return; } try { const result = await RpcApi.PublishAppCommand(TabRpcClient, { appid: builderAppId }); - console.log("App published successfully:", result.publishedappid); - modalsModel.popModal(); + setPublishedAppId(result.publishedappid); + setState("success"); } catch (error) { - console.error("Failed to publish app:", error); + setErrorMessage(error instanceof Error ? error.message : String(error)); + setState("error"); } }; - const handleCancel = () => { + const handleClose = () => { modalsModel.popModal(); }; + if (state === "success") { + return ( + +
+

+ + App Published Successfully +

+
+

+ Your app has been published to {publishedAppId} +

+
+
+
+ ); + } + + if (state === "error") { + return ( + +
+

+ + Publish Failed +

+
+

{errorMessage}

+
+
+
+ ); + } + return ( diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4bf3801eb..1b6ad2462 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -336,6 +336,16 @@ declare global { truncated?: boolean; }; + // wshrpc.CommandMakeDraftFromLocalData + type CommandMakeDraftFromLocalData = { + localappid: string; + }; + + // wshrpc.CommandMakeDraftFromLocalRtnData + type CommandMakeDraftFromLocalRtnData = { + draftappid: string; + }; + // wshrpc.CommandMessageData type CommandMessageData = { oref: ORef; diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 7206ee26d..2ee1a349c 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -174,6 +174,11 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap } if len(rtInfo) > 0 { wstore.SetRTInfo(blockRef, rtInfo) + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_TsunamiUpdateMeta, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, c.blockId).String()}, + Data: manifest.AppMeta, + }) } } } diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index cae43908f..076d96470 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -20,6 +20,7 @@ const ( Event_WorkspaceUpdate = "workspace:update" Event_WaveAIRateLimit = "waveai:ratelimit" Event_WaveAppAppGoUpdated = "waveapp:appgoupdated" + Event_TsunamiUpdateMeta = "tsunami:updatemeta" ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 79daaa5ba..7a6eddc69 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -446,6 +446,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr return resp, err } +// command "makedraftfromlocal", wshserver.MakeDraftFromLocalCommand +func MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, "makedraftfromlocal", data, opts) + return resp, err +} + // command "message", wshserver.MessageCommand func MessageCommand(w *wshutil.WshRpc, data wshrpc.CommandMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "message", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a329057f1..c84ad5522 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -171,6 +171,7 @@ const ( Command_GetBuilderOutput = "getbuilderoutput" Command_CheckGoVersion = "checkgoversion" Command_PublishApp = "publishapp" + Command_MakeDraftFromLocal = "makedraftfromlocal" // electron Command_ElectronEncrypt = "electronencrypt" @@ -343,6 +344,7 @@ type WshRpcInterface interface { GetBuilderOutputCommand(ctx context.Context, builderId string) ([]string, error) CheckGoVersionCommand(ctx context.Context) (*CommandCheckGoVersionRtnData, error) PublishAppCommand(ctx context.Context, data CommandPublishAppData) (*CommandPublishAppRtnData, error) + MakeDraftFromLocalCommand(ctx context.Context, data CommandMakeDraftFromLocalData) (*CommandMakeDraftFromLocalRtnData, error) // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) chan RespOrErrorUnion[*vdom.VDomBackendUpdate] @@ -1081,6 +1083,14 @@ type CommandPublishAppRtnData struct { PublishedAppId string `json:"publishedappid"` } +type CommandMakeDraftFromLocalData struct { + LocalAppId string `json:"localappid"` +} + +type CommandMakeDraftFromLocalRtnData struct { + DraftAppId string `json:"draftappid"` +} + type CommandElectronEncryptData struct { PlainText string `json:"plaintext"` } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 41019fe2e..4dca4e41d 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1128,6 +1128,16 @@ func (ws *WshServer) PublishAppCommand(ctx context.Context, data wshrpc.CommandP }, nil } +func (ws *WshServer) MakeDraftFromLocalCommand(ctx context.Context, data wshrpc.CommandMakeDraftFromLocalData) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { + draftAppId, err := waveappstore.MakeDraftFromLocal(data.LocalAppId) + if err != nil { + return nil, fmt.Errorf("error making draft from local: %w", err) + } + return &wshrpc.CommandMakeDraftFromLocalRtnData{ + DraftAppId: draftAppId, + }, nil +} + func (ws *WshServer) RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error { err := telemetry.RecordTEvent(ctx, &data) if err != nil { From e75c95c4c020fb5fcd19b4260a66808983451d1b Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 13 Nov 2025 21:46:35 -0800 Subject: [PATCH 3/5] get gofmt working after writing app.go, get remix app working in context menu, move save button into code editor --- emain/preload.ts | 1 + frontend/app/store/wshclientapi.ts | 5 ++ frontend/app/view/tsunami/tsunami.tsx | 37 ++++++++- frontend/builder/builder-apppanel.tsx | 20 ----- .../builder/store/builder-apppanel-model.ts | 7 +- frontend/builder/tabs/builder-codetab.tsx | 25 +++++- frontend/types/custom.d.ts | 1 + frontend/types/gotypes.d.ts | 11 +++ frontend/wave.ts | 5 ++ pkg/aiusechat/tools_builder.go | 7 +- pkg/blockcontroller/tsunamicontroller.go | 10 +-- pkg/buildercontroller/buildercontroller.go | 8 +- pkg/waveappstore/waveappstore.go | 34 ++++++++ pkg/waveapputil/waveapputil.go | 79 +++++++++++++++++++ pkg/wshrpc/wshclient/wshclient.go | 6 ++ pkg/wshrpc/wshrpctypes.go | 11 +++ pkg/wshrpc/wshserver/wshserver.go | 21 +++++ 17 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 pkg/waveapputil/waveapputil.go diff --git a/emain/preload.ts b/emain/preload.ts index 97a78f7a1..1f8bc94b7 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -65,6 +65,7 @@ contextBridge.exposeInMainWorld("api", { closeBuilderWindow: () => ipcRenderer.send("close-builder-window"), incrementTermCommands: () => ipcRenderer.send("increment-term-commands"), nativePaste: () => ipcRenderer.send("native-paste"), + openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), }); // Custom event for "new-window" diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index a453b79d6..aafaad7b0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -632,6 +632,11 @@ class RpcApiType { return client.wshRpcCall("writeappfile", data, opts); } + // command "writeappgofile" [call] + WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise { + return client.wshRpcCall("writeappgofile", data, opts); + } + // command "writeappsecretbindings" [call] WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { return client.wshRpcCall("writeappsecretbindings", data, opts); diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index 1b502e3d5..ec8f354ef 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import { atoms, globalStore, WOS } from "@/app/store/global"; +import { atoms, getApi, globalStore, WOS } from "@/app/store/global"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -148,6 +148,24 @@ class TsunamiViewModel extends WebViewModel { this.doControllerResync(true, "force restart"); } + async remixInBuilder() { + const blockData = globalStore.get(this.blockAtom); + const appId = blockData?.meta?.["tsunami:appid"]; + + if (!appId || !appId.startsWith("local/")) { + return; + } + + try { + const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); + const draftAppId = result.draftappid; + + getApi().openBuilder(draftAppId); + } catch (err) { + console.error("Failed to create draft from local app:", err); + } + } + dispose() { if (this.shellProcStatusUnsubFn) { this.shellProcStatusUnsubFn(); @@ -170,6 +188,11 @@ class TsunamiViewModel extends WebViewModel { ); }); + // Check if we should show the Remix option + const blockData = globalStore.get(this.blockAtom); + const appId = blockData?.meta?.["tsunami:appid"]; + const showRemixOption = appId && appId.startsWith("local/"); + // Add tsunami-specific menu items at the beginning const tsunamiItems: ContextMenuItem[] = [ { @@ -188,6 +211,18 @@ class TsunamiViewModel extends WebViewModel { type: "separator", }, ]; + + if (showRemixOption) { + tsunamiItems.push( + { + label: "Remix WaveApp in Builder", + click: () => this.remixInBuilder(), + }, + { + type: "separator", + } + ); + } return [...tsunamiItems, ...filteredItems]; } diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index dcb3931f2..a0f0523a2 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -194,7 +194,6 @@ const BuilderAppPanel = memo(() => { const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; - const saveNeeded = useAtomValue(model.saveNeededAtom); const envSaveNeeded = useAtomValue(model.envVarsDirtyAtom); const builderAppId = useAtomValue(atoms.builderAppId); const builderId = useAtomValue(atoms.builderId); @@ -242,12 +241,6 @@ const BuilderAppPanel = memo(() => { [model] ); - const handleSave = useCallback(() => { - if (builderAppId) { - model.saveAppFile(builderAppId); - } - }, [builderAppId, model]); - const handleEnvSave = useCallback(() => { if (builderId) { model.saveEnvVars(builderId); @@ -323,19 +316,6 @@ const BuilderAppPanel = memo(() => { Publish App - {activeTab === "code" && ( - - )} {activeTab === "env" && ( void; // close-builder-window incrementTermCommands: () => void; // increment-term-commands nativePaste: () => void; // native-paste + openBuilder: (appId?: string) => void; // open-builder }; type ElectronContextMenuItem = { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 1b6ad2462..83e31f2aa 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -519,6 +519,17 @@ declare global { data64: string; }; + // wshrpc.CommandWriteAppGoFileData + type CommandWriteAppGoFileData = { + appid: string; + data64: string; + }; + + // wshrpc.CommandWriteAppGoFileRtnData + type CommandWriteAppGoFileRtnData = { + data64: string; + }; + // wshrpc.CommandWriteAppSecretBindingsData type CommandWriteAppSecretBindingsData = { appid: string; diff --git a/frontend/wave.ts b/frontend/wave.ts index 92e77614c..6ebe90795 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -265,6 +265,11 @@ async function initBuilder(initOpts: BuilderInitOpts) { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { oref }); if (rtInfo && rtInfo["builder:appid"]) { appIdToUse = rtInfo["builder:appid"]; + } else if (appIdToUse) { + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref, + data: { "builder:appid": appIdToUse }, + }); } } catch (e) { console.log("Could not load saved builder appId from rtinfo:", e); diff --git a/pkg/aiusechat/tools_builder.go b/pkg/aiusechat/tools_builder.go index 1df1cd022..14e71b8f4 100644 --- a/pkg/aiusechat/tools_builder.go +++ b/pkg/aiusechat/tools_builder.go @@ -15,6 +15,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/fileutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wstore" @@ -119,7 +120,8 @@ func GetBuilderWriteAppFileToolDefinition(appId string, builderId string) uctype return nil, err } - err = waveappstore.WriteAppFile(appId, BuilderAppFileName, []byte(params.Contents)) + formattedContents := waveapputil.FormatGoCode([]byte(params.Contents)) + err = waveappstore.WriteAppFile(appId, BuilderAppFileName, formattedContents) if err != nil { return nil, err } @@ -253,6 +255,9 @@ func GetBuilderEditAppFileToolDefinition(appId string, builderId string) uctypes return nil, err } + // ignore format errors; gofmt can fail due to compilation errors which will be caught in the build step + waveappstore.FormatGoFile(appId, BuilderAppFileName) + wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_WaveAppAppGoUpdated, Scopes: []string{appId}, diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index 2ee1a349c..a3a36fd52 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -21,6 +21,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/tsunamiutil" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -29,8 +30,6 @@ import ( "github.com/wavetermdev/waveterm/tsunami/build" ) -const DefaultTsunamiSdkVersion = "v0.12.2" - type TsunamiAppProc struct { Cmd *exec.Cmd LineBuffer *utilds.MultiReaderLineBuffer @@ -126,15 +125,12 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap c.runLock.Lock() defer c.runLock.Unlock() + scaffoldPath := waveapputil.GetTsunamiScaffoldPath() settings := wconfig.GetWatcher().GetFullConfig().Settings - scaffoldPath := settings.TsunamiScaffoldPath - if scaffoldPath == "" { - scaffoldPath = filepath.Join(wavebase.GetWaveAppPath(), "tsunamiscaffold") - } sdkReplacePath := settings.TsunamiSdkReplacePath sdkVersion := settings.TsunamiSdkVersion if sdkVersion == "" { - sdkVersion = DefaultTsunamiSdkVersion + sdkVersion = waveapputil.DefaultTsunamiSdkVersion } goPath := settings.TsunamiGoPath diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 27ff1f9e8..11ccfe164 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -19,6 +19,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -208,15 +209,12 @@ func (bc *BuilderController) buildAndRun(ctx context.Context, appId string, buil return } + scaffoldPath := waveapputil.GetTsunamiScaffoldPath() settings := wconfig.GetWatcher().GetFullConfig().Settings - scaffoldPath := settings.TsunamiScaffoldPath - if scaffoldPath == "" { - scaffoldPath = filepath.Join(wavebase.GetWaveAppPath(), "tsunamiscaffold") - } sdkReplacePath := settings.TsunamiSdkReplacePath sdkVersion := settings.TsunamiSdkVersion if sdkVersion == "" { - sdkVersion = "v0.12.2" + sdkVersion = waveapputil.DefaultTsunamiSdkVersion } goPath := settings.TsunamiGoPath diff --git a/pkg/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index c9af0a677..87d61302a 100644 --- a/pkg/waveappstore/waveappstore.go +++ b/pkg/waveappstore/waveappstore.go @@ -7,12 +7,14 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "regexp" "strings" "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/util/fileutil" + "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/tsunami/engine" @@ -405,6 +407,38 @@ func RenameAppFile(appId string, fromFileName string, toFileName string) error { return nil } +func FormatGoFile(appId string, fileName string) error { + if err := ValidateAppId(appId); err != nil { + return fmt.Errorf("invalid appId: %w", err) + } + + appDir, err := GetAppDir(appId) + if err != nil { + return err + } + + filePath, err := validateAndResolveFilePath(appDir, fileName) + if err != nil { + return err + } + + if filepath.Ext(filePath) != ".go" { + return fmt.Errorf("file is not a Go file: %s", fileName) + } + + gofmtPath, err := waveapputil.ResolveGoFmtPath() + if err != nil { + return fmt.Errorf("failed to resolve gofmt path: %w", err) + } + + cmd := exec.Command(gofmtPath, "-w", filePath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("gofmt failed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + func ListAllAppFiles(appId string) (*fileutil.ReadDirResult, error) { if err := ValidateAppId(appId); err != nil { return nil, fmt.Errorf("invalid appId: %w", err) diff --git a/pkg/waveapputil/waveapputil.go b/pkg/waveapputil/waveapputil.go new file mode 100644 index 000000000..c1bee8818 --- /dev/null +++ b/pkg/waveapputil/waveapputil.go @@ -0,0 +1,79 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveapputil + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/tsunami/build" +) + +const DefaultTsunamiSdkVersion = "v0.12.2" + +func GetTsunamiScaffoldPath() string { + settings := wconfig.GetWatcher().GetFullConfig().Settings + scaffoldPath := settings.TsunamiScaffoldPath + if scaffoldPath == "" { + scaffoldPath = filepath.Join(wavebase.GetWaveAppPath(), "tsunamiscaffold") + } + return scaffoldPath +} + +func ResolveGoFmtPath() (string, error) { + settings := wconfig.GetWatcher().GetFullConfig().Settings + goPath := settings.TsunamiGoPath + + if goPath == "" { + var err error + goPath, err = build.FindGoExecutable() + if err != nil { + return "", err + } + } + + goDir := filepath.Dir(goPath) + gofmtName := "gofmt" + if runtime.GOOS == "windows" { + gofmtName = "gofmt.exe" + } + gofmtPath := filepath.Join(goDir, gofmtName) + + info, err := os.Stat(gofmtPath) + if err != nil { + return "", fmt.Errorf("gofmt not found at %s: %w", gofmtPath, err) + } + + if info.IsDir() { + return "", fmt.Errorf("gofmt path is a directory: %s", gofmtPath) + } + + if info.Mode()&0111 == 0 { + return "", fmt.Errorf("gofmt is not executable: %s", gofmtPath) + } + + return gofmtPath, nil +} + +func FormatGoCode(contents []byte) []byte { + gofmtPath, err := ResolveGoFmtPath() + if err != nil { + return contents + } + + cmd := exec.Command(gofmtPath) + cmd.Stdin = strings.NewReader(string(contents)) + formattedOutput, err := cmd.Output() + if err != nil { + return contents + } + + return formattedOutput +} \ No newline at end of file diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 7a6eddc69..31d9c4e16 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -755,6 +755,12 @@ func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, return err } +// command "writeappgofile", wshserver.WriteAppGoFileCommand +func WriteAppGoFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppGoFileData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWriteAppGoFileRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandWriteAppGoFileRtnData](w, "writeappgofile", data, opts) + return resp, err +} + // command "writeappsecretbindings", wshserver.WriteAppSecretBindingsCommand func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppSecretBindingsData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "writeappsecretbindings", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index c84ad5522..7a38c1868 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -161,6 +161,7 @@ const ( Command_ListAllAppFiles = "listallappfiles" Command_ReadAppFile = "readappfile" Command_WriteAppFile = "writeappfile" + Command_WriteAppGoFile = "writeappgofile" Command_DeleteAppFile = "deleteappfile" Command_RenameAppFile = "renameappfile" Command_WriteAppSecretBindings = "writeappsecretbindings" @@ -334,6 +335,7 @@ type WshRpcInterface interface { ListAllAppFilesCommand(ctx context.Context, data CommandListAllAppFilesData) (*CommandListAllAppFilesRtnData, error) ReadAppFileCommand(ctx context.Context, data CommandReadAppFileData) (*CommandReadAppFileRtnData, error) WriteAppFileCommand(ctx context.Context, data CommandWriteAppFileData) error + WriteAppGoFileCommand(ctx context.Context, data CommandWriteAppGoFileData) (*CommandWriteAppGoFileRtnData, error) DeleteAppFileCommand(ctx context.Context, data CommandDeleteAppFileData) error RenameAppFileCommand(ctx context.Context, data CommandRenameAppFileData) error WriteAppSecretBindingsCommand(ctx context.Context, data CommandWriteAppSecretBindingsData) error @@ -1010,6 +1012,15 @@ type CommandWriteAppFileData struct { Data64 string `json:"data64"` } +type CommandWriteAppGoFileData struct { + AppId string `json:"appid"` + Data64 string `json:"data64"` +} + +type CommandWriteAppGoFileRtnData struct { + Data64 string `json:"data64"` +} + type CommandDeleteAppFileData struct { AppId string `json:"appid"` FileName string `json:"filename"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 4dca4e41d..ca4bc4932 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -46,6 +46,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/wavefileutil" "github.com/wavetermdev/waveterm/pkg/waveai" "github.com/wavetermdev/waveterm/pkg/waveappstore" + "github.com/wavetermdev/waveterm/pkg/waveapputil" "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wcloud" @@ -1013,6 +1014,26 @@ func (ws *WshServer) WriteAppFileCommand(ctx context.Context, data wshrpc.Comman return waveappstore.WriteAppFile(data.AppId, data.FileName, contents) } +func (ws *WshServer) WriteAppGoFileCommand(ctx context.Context, data wshrpc.CommandWriteAppGoFileData) (*wshrpc.CommandWriteAppGoFileRtnData, error) { + if data.AppId == "" { + return nil, fmt.Errorf("must provide an appId to WriteAppGoFileCommand") + } + contents, err := base64.StdEncoding.DecodeString(data.Data64) + if err != nil { + return nil, fmt.Errorf("failed to decode data64: %w", err) + } + + formattedOutput := waveapputil.FormatGoCode(contents) + + err = waveappstore.WriteAppFile(data.AppId, "app.go", formattedOutput) + if err != nil { + return nil, err + } + + encoded := base64.StdEncoding.EncodeToString(formattedOutput) + return &wshrpc.CommandWriteAppGoFileRtnData{Data64: encoded}, nil +} + func (ws *WshServer) DeleteAppFileCommand(ctx context.Context, data wshrpc.CommandDeleteAppFileData) error { if data.AppId == "" { return fmt.Errorf("must provide an appId to DeleteAppFileCommand") From a3dce9710c95c512b1712ab0af0ff1e7ea7c8679 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 13 Nov 2025 21:58:34 -0800 Subject: [PATCH 4/5] dont open duplicate builder windows... --- .roo/rules/rules.md | 12 ++++++++++ emain/emain-ipc.ts | 28 ++++++++++++++++++++++-- emain/emain-menu.ts | 5 +++-- emain/preload.ts | 1 + frontend/builder/app-selection-modal.tsx | 4 +++- frontend/types/custom.d.ts | 1 + 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 6de67ed5c..d5fd83b9f 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -58,6 +58,18 @@ To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go` For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`. +### Electron API + +From within the FE to get the electron API (e.g. the preload functions): + +``` +import { getApi } from "@/store/global"; + +getApi().getIsDev() +``` + +The full API is defined in custom.d.ts as type ElectronApi. + ### Code Generation - **TypeScript Types**: TypeScript types are automatically generated from Go types. After modifying Go types in `pkg/wshrpc/wshrpctypes.go`, run `task generate` to update the TypeScript type definitions in `frontend/types/gotypes.d.ts`. diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 7ae95ef6c..a4fd24806 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -12,7 +12,7 @@ import { RpcApi } from "../frontend/app/store/wshclientapi"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; import * as keyutil from "../frontend/util/keyutil"; import { fireAndForget, parseDataUrl } from "../frontend/util/util"; -import { createBuilderWindow, getBuilderWindowByWebContentsId } from "./emain-builder"; +import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; @@ -26,6 +26,19 @@ const electronApp = electron.app; let webviewFocusId: number = null; let webviewKeys: string[] = []; +export function openBuilderWindow(appId?: string) { + const normalizedAppId = appId || ""; + const existingBuilderWindows = getAllBuilderWindows(); + const existingWindow = existingBuilderWindows.find( + (win) => win.savedInitOpts?.appId === normalizedAppId + ); + if (existingWindow) { + existingWindow.focus(); + return; + } + fireAndForget(() => createBuilderWindow(normalizedAppId)); +} + type UrlInSessionResult = { stream: Readable; mimeType: string; @@ -405,7 +418,18 @@ export function initIpcHandlers() { }); electron.ipcMain.on("open-builder", (event, appId?: string) => { - fireAndForget(() => createBuilderWindow(appId || "")); + openBuilderWindow(appId); + }); + + electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => { + const bw = getBuilderWindowByWebContentsId(event.sender.id); + if (bw == null) { + return; + } + if (bw.savedInitOpts) { + bw.savedInitOpts.appId = appId; + } + console.log("set-builder-window-appid", bw.builderId, appId); }); electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 83a32b8c2..347e4900f 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -5,7 +5,8 @@ import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import * as electron from "electron"; import { fireAndForget } from "../frontend/util/util"; -import { createBuilderWindow, focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; +import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder"; +import { openBuilderWindow } from "./emain-ipc"; import { isDev, unamePlatform } from "./emain-platform"; import { clearTabCache } from "./emain-tabview"; import { @@ -128,7 +129,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec fileMenu.splice(1, 0, { label: "New WaveApp Builder Window", accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B", - click: () => fireAndForget(() => createBuilderWindow("")), + click: () => openBuilderWindow(""), }); } if (numWaveWindows == 0) { diff --git a/emain/preload.ts b/emain/preload.ts index 1f8bc94b7..3acc23fb4 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -66,6 +66,7 @@ contextBridge.exposeInMainWorld("api", { incrementTermCommands: () => ipcRenderer.send("increment-term-commands"), nativePaste: () => ipcRenderer.send("native-paste"), openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), + setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), }); // Custom event for "new-window" diff --git a/frontend/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index e11d288ea..4e49b2f35 100644 --- a/frontend/builder/app-selection-modal.tsx +++ b/frontend/builder/app-selection-modal.tsx @@ -4,7 +4,7 @@ import { FlexiModal } from "@/app/modals/modal"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, globalStore } from "@/store/global"; +import { atoms, getApi, globalStore } from "@/store/global"; import * as WOS from "@/store/wos"; import { formatRelativeTime } from "@/util/util"; import { useEffect, useState } from "react"; @@ -142,6 +142,7 @@ export function AppSelectionModal() { }); globalStore.set(atoms.builderAppId, appIdToUse); document.title = `WaveApp Builder (${appIdToUse})`; + getApi().setBuilderWindowAppId(appIdToUse); }; const handleCreateNew = async (appName: string) => { @@ -154,6 +155,7 @@ export function AppSelectionModal() { }); globalStore.set(atoms.builderAppId, draftAppId); document.title = `WaveApp Builder (${draftAppId})`; + getApi().setBuilderWindowAppId(draftAppId); }; const isDraftApp = (appId: string) => { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index eca3a27a6..d6941df89 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -127,6 +127,7 @@ declare global { incrementTermCommands: () => void; // increment-term-commands nativePaste: () => void; // native-paste openBuilder: (appId?: string) => void; // open-builder + setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid }; type ElectronContextMenuItem = { From da0de633407e3ea3fbc8412c51095fd0060b65aa Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 13 Nov 2025 22:29:02 -0800 Subject: [PATCH 5/5] get icon/icon color implemented in appmeta and show in wave UI --- frontend/app/block/blockframe.tsx | 16 +++++++++++++--- frontend/app/view/tsunami/tsunami.tsx | 22 ++++++++++++++++------ frontend/types/custom.d.ts | 3 +++ frontend/types/gotypes.d.ts | 4 ++++ pkg/blockcontroller/tsunamicontroller.go | 6 ++++++ pkg/waveobj/objrtinfo.go | 8 +++++--- pkg/wshrpc/wshrpctypes.go | 2 ++ tsunami/engine/clientimpl.go | 12 +++++++----- 8 files changed, 56 insertions(+), 17 deletions(-) diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index d15f5df02..c66b798ea 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -85,10 +85,19 @@ function handleHeaderContextMenu( ContextMenuModel.showContextMenu(menu, e); } -function getViewIconElem(viewIconUnion: string | IconButtonDecl, blockData: Block): React.ReactElement { +function getViewIconElem( + viewIconUnion: string | IconButtonDecl, + blockData: Block, + iconColor?: string +): React.ReactElement { if (viewIconUnion == null || typeof viewIconUnion === "string") { const viewIcon = viewIconUnion as string; - return
{getBlockHeaderIcon(viewIcon, blockData)}
; + const style: React.CSSProperties = iconColor ? { color: iconColor, opacity: 1.0 } : {}; + return ( +
+ {getBlockHeaderIcon(viewIcon, blockData)} +
+ ); } else { return ; } @@ -172,6 +181,7 @@ const BlockFrame_Header = ({ let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view); const showBlockIds = jotai.useAtomValue(getSettingsKeyAtom("blockheader:showblockids")); let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); + const viewIconColor = util.useAtomValueSafe(viewModel?.viewIconColor); const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText); const magnified = jotai.useAtomValue(nodeModel.isMagnified); @@ -208,7 +218,7 @@ const BlockFrame_Header = ({ ); const endIconsElem = computeEndIcons(viewModel, nodeModel, onContextMenu); - const viewIconElem = getViewIconElem(viewIconUnion, blockData); + const viewIconElem = getViewIconElem(viewIconUnion, blockData, viewIconColor); let preIconButtonElem: React.ReactElement = null; if (preIconButton) { preIconButtonElem = ; diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx index ec8f354ef..f55048428 100644 --- a/frontend/app/view/tsunami/tsunami.tsx +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -18,11 +18,11 @@ class TsunamiViewModel extends WebViewModel { appMetaUnsubFn: () => void; isRestarting: jotai.PrimitiveAtom; viewName: jotai.Atom; + viewIconColor: jotai.Atom; constructor(blockId: string, nodeModel: BlockNodeModel) { super(blockId, nodeModel); this.viewType = "tsunami"; - this.viewIcon = jotai.atom("cube"); this.isRestarting = jotai.atom(false); // Hide navigation bar (URL bar, back/forward/home buttons) @@ -46,6 +46,14 @@ class TsunamiViewModel extends WebViewModel { }); this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom; + this.viewIcon = jotai.atom((get) => { + const meta = get(this.appMeta); + return meta?.icon || "cube"; + }); + this.viewIconColor = jotai.atom((get) => { + const meta = get(this.appMeta); + return meta?.iconcolor; + }); this.viewName = jotai.atom((get) => { const meta = get(this.appMeta); return meta?.title || "WaveApp"; @@ -58,6 +66,8 @@ class TsunamiViewModel extends WebViewModel { const meta: AppMeta = { title: rtInfo["tsunami:title"], shortdesc: rtInfo["tsunami:shortdesc"], + icon: rtInfo["tsunami:icon"], + iconcolor: rtInfo["tsunami:iconcolor"], }; globalStore.set(this.appMeta, meta); } @@ -151,15 +161,15 @@ class TsunamiViewModel extends WebViewModel { async remixInBuilder() { const blockData = globalStore.get(this.blockAtom); const appId = blockData?.meta?.["tsunami:appid"]; - + if (!appId || !appId.startsWith("local/")) { return; } - + try { const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); const draftAppId = result.draftappid; - + getApi().openBuilder(draftAppId); } catch (err) { console.error("Failed to create draft from local app:", err); @@ -192,7 +202,7 @@ class TsunamiViewModel extends WebViewModel { const blockData = globalStore.get(this.blockAtom); const appId = blockData?.meta?.["tsunami:appid"]; const showRemixOption = appId && appId.startsWith("local/"); - + // Add tsunami-specific menu items at the beginning const tsunamiItems: ContextMenuItem[] = [ { @@ -211,7 +221,7 @@ class TsunamiViewModel extends WebViewModel { type: "separator", }, ]; - + if (showRemixOption) { tsunamiItems.push( { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index d6941df89..231599f85 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -294,6 +294,9 @@ declare global { // Icon representing the view, can be a string or an IconButton declaration. viewIcon?: jotai.Atom; + // Optional color for the view icon. + viewIconColor?: jotai.Atom; + // Display name for the view, used in UI headers. viewName?: jotai.Atom; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 83e31f2aa..d72680e37 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -74,6 +74,8 @@ declare global { type AppMeta = { title: string; shortdesc: string; + icon: string; + iconcolor: string; }; // waveobj.Block @@ -913,6 +915,8 @@ declare global { type ObjRTInfo = { "tsunami:title"?: string; "tsunami:shortdesc"?: string; + "tsunami:icon"?: string; + "tsunami:iconcolor"?: string; "tsunami:schemas"?: any; "shell:hascurcwd"?: boolean; "shell:state"?: string; diff --git a/pkg/blockcontroller/tsunamicontroller.go b/pkg/blockcontroller/tsunamicontroller.go index a3a36fd52..41daaf760 100644 --- a/pkg/blockcontroller/tsunamicontroller.go +++ b/pkg/blockcontroller/tsunamicontroller.go @@ -168,6 +168,12 @@ func (c *TsunamiController) Start(ctx context.Context, blockMeta waveobj.MetaMap if manifest.AppMeta.ShortDesc != "" { rtInfo["tsunami:shortdesc"] = manifest.AppMeta.ShortDesc } + if manifest.AppMeta.Icon != "" { + rtInfo["tsunami:icon"] = manifest.AppMeta.Icon + } + if manifest.AppMeta.IconColor != "" { + rtInfo["tsunami:iconcolor"] = manifest.AppMeta.IconColor + } if len(rtInfo) > 0 { wstore.SetRTInfo(blockRef, rtInfo) wps.Broker.Publish(wps.WaveEvent{ diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 30afe48b0..fcbef2331 100644 --- a/pkg/waveobj/objrtinfo.go +++ b/pkg/waveobj/objrtinfo.go @@ -6,6 +6,8 @@ package waveobj type ObjRTInfo struct { TsunamiTitle string `json:"tsunami:title,omitempty"` TsunamiShortDesc string `json:"tsunami:shortdesc,omitempty"` + TsunamiIcon string `json:"tsunami:icon,omitempty"` + TsunamiIconColor string `json:"tsunami:iconcolor,omitempty"` TsunamiSchemas any `json:"tsunami:schemas,omitempty"` ShellHasCurCwd bool `json:"shell:hascurcwd,omitempty"` @@ -22,7 +24,7 @@ type ObjRTInfo struct { BuilderAppId string `json:"builder:appid,omitempty"` BuilderEnv map[string]string `json:"builder:env,omitempty"` - WaveAIChatId string `json:"waveai:chatid,omitempty"` - WaveAIThinkingMode string `json:"waveai:thinkingmode,omitempty"` - WaveAIMaxOutputTokens int `json:"waveai:maxoutputtokens,omitempty"` + WaveAIChatId string `json:"waveai:chatid,omitempty"` + WaveAIThinkingMode string `json:"waveai:thinkingmode,omitempty"` + WaveAIMaxOutputTokens int `json:"waveai:maxoutputtokens,omitempty"` } diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 7a38c1868..033346360 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -1054,6 +1054,8 @@ type RestartBuilderAndWaitResult struct { type AppMeta struct { Title string `json:"title"` ShortDesc string `json:"shortdesc"` + Icon string `json:"icon"` + IconColor string `json:"iconcolor"` } type SecretMeta struct { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index d9aa6a891..73aeacfcf 100644 --- a/tsunami/engine/clientimpl.go +++ b/tsunami/engine/clientimpl.go @@ -43,6 +43,8 @@ var defaultClient = makeClient() type AppMeta struct { Title string `json:"title"` ShortDesc string `json:"shortdesc"` + Icon string `json:"icon"` // for waveapps, the icon to use (fontawesome names) + IconColor string `json:"iconcolor"` // for waveapps, the icon color to use (HTML color -- name, hex, rgb) } type SecretMeta struct { @@ -51,10 +53,10 @@ type SecretMeta struct { } type AppManifest struct { - AppMeta AppMeta `json:"appmeta"` - ConfigSchema map[string]any `json:"configschema"` - DataSchema map[string]any `json:"dataschema"` - Secrets map[string]SecretMeta `json:"secrets"` + AppMeta AppMeta `json:"appmeta"` + ConfigSchema map[string]any `json:"configschema"` + DataSchema map[string]any `json:"dataschema"` + Secrets map[string]SecretMeta `json:"secrets"` } type ClientImpl struct { @@ -497,7 +499,7 @@ func (c *ClientImpl) GetAppManifest() AppManifest { configSchema := GenerateConfigSchema(c.Root) dataSchema := GenerateDataSchema(c.Root) secrets := c.GetSecrets() - + return AppManifest{ AppMeta: appMeta, ConfigSchema: configSchema,