diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 6de67ed5c7..d5fd83b9fa 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 7ae95ef6c7..a4fd248061 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 83a32b8c28..347e4900fd 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 97a78f7a17..3acc23fb45 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -65,6 +65,8 @@ 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), + setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), }); // Custom event for "new-window" diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index d15f5df02b..c66b798ea7 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/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index b1a561604b..aafaad7b0d 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); @@ -627,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 c5c6e47def..f550484289 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"; @@ -11,22 +11,18 @@ 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; + appMeta: jotai.PrimitiveAtom; + appMetaUnsubFn: () => void; isRestarting: jotai.PrimitiveAtom; - viewName: 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.viewName = jotai.atom("Tsunami"); this.isRestarting = jotai.atom(false); // Hide navigation bar (URL bar, back/forward/home buttons) @@ -48,6 +44,42 @@ class TsunamiViewModel extends WebViewModel { this.updateShellProcStatus(bcRTS); }, }); + + 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"; + }); + 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"], + icon: rtInfo["tsunami:icon"], + iconcolor: rtInfo["tsunami:iconcolor"], + }; + 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 { @@ -126,25 +158,21 @@ class TsunamiViewModel extends WebViewModel { this.doControllerResync(true, "force restart"); } - setAppMeta(meta: TsunamiAppMeta) { - console.log("tsunami app meta:", meta); + async remixInBuilder() { + const blockData = globalStore.get(this.blockAtom); + const appId = blockData?.meta?.["tsunami:appid"]; - const rtInfo: ObjRTInfo = {}; - if (meta.title) { - rtInfo["tsunami:title"] = meta.title; - } - if (meta.shortdesc) { - rtInfo["tsunami:shortdesc"] = meta.shortdesc; + if (!appId || !appId.startsWith("local/")) { + return; } - if (Object.keys(rtInfo).length > 0) { - const oref = WOS.makeORef("block", this.blockId); - const data: CommandSetRTInfoData = { - oref: oref, - data: rtInfo, - }; + try { + const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId }); + const draftAppId = result.draftappid; - RpcApi.SetRTInfoCommand(TabRpcClient, data).catch((e) => console.log("error setting RT info", e)); + getApi().openBuilder(draftAppId); + } catch (err) { + console.error("Failed to create draft from local app:", err); } } @@ -152,6 +180,9 @@ class TsunamiViewModel extends WebViewModel { if (this.shellProcStatusUnsubFn) { this.shellProcStatusUnsubFn(); } + if (this.appMetaUnsubFn) { + this.appMetaUnsubFn(); + } } getSettingsMenuItems(): ContextMenuItem[] { @@ -167,6 +198,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[] = [ { @@ -186,6 +222,18 @@ class TsunamiViewModel extends WebViewModel { }, ]; + if (showRemixOption) { + tsunamiItems.push( + { + label: "Remix WaveApp in Builder", + click: () => this.remixInBuilder(), + }, + { + type: "separator", + } + ); + } + return [...tsunamiItems, ...filteredItems]; } } @@ -201,39 +249,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/builder/app-selection-modal.tsx b/frontend/builder/app-selection-modal.tsx index ef1856a6f0..4e49b2f35d 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"; @@ -120,14 +120,29 @@ 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})`; + getApi().setBuilderWindowAppId(appIdToUse); }; const handleCreateNew = async (appName: string) => { @@ -140,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/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index fd1b55fd09..a7f092e9cd 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 5b361cc101..a0f0523a2a 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 ( @@ -156,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); @@ -204,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); @@ -285,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 + setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid }; type ElectronContextMenuItem = { @@ -292,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 9353f79edb..d72680e37c 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -64,13 +64,20 @@ 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; + icon: string; + iconcolor: string; + }; + // waveobj.Block type Block = WaveObj & { parentoref?: string; @@ -331,6 +338,16 @@ declare global { truncated?: boolean; }; + // wshrpc.CommandMakeDraftFromLocalData + type CommandMakeDraftFromLocalData = { + localappid: string; + }; + + // wshrpc.CommandMakeDraftFromLocalRtnData + type CommandMakeDraftFromLocalRtnData = { + draftappid: string; + }; + // wshrpc.CommandMessageData type CommandMessageData = { oref: ORef; @@ -504,6 +521,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; @@ -887,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/frontend/wave.ts b/frontend/wave.ts index 92e77614c9..6ebe907951 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 1df1cd0221..14e71b8f45 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 29d0b500a4..41daaf7601 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,21 +125,19 @@ 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 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 +157,34 @@ 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 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{ + Event: wps.Event_TsunamiUpdateMeta, + Scopes: []string{waveobj.MakeORef(waveobj.OType_Block, c.blockId).String()}, + Data: manifest.AppMeta, + }) + } + } + } + appName := build.GetAppName(appPath) osArch := runtime.GOOS + "-" + runtime.GOARCH diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 2394a31317..11ccfe1642 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 @@ -512,8 +510,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/waveappstore/waveappstore.go b/pkg/waveappstore/waveappstore.go index c9af0a6777..87d61302a9 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 0000000000..c1bee88188 --- /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/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go index 30afe48b07..fcbef2331b 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/wps/wpstypes.go b/pkg/wps/wpstypes.go index cae43908ff..076d964708 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 79daaa5ba8..31d9c4e16c 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) @@ -749,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 bb2a3708f6..033346360d 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" @@ -171,6 +172,7 @@ const ( Command_GetBuilderOutput = "getbuilderoutput" Command_CheckGoVersion = "checkgoversion" Command_PublishApp = "publishapp" + Command_MakeDraftFromLocal = "makedraftfromlocal" // electron Command_ElectronEncrypt = "electronencrypt" @@ -333,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 @@ -343,6 +346,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] @@ -1008,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"` @@ -1038,14 +1051,20 @@ type RestartBuilderAndWaitResult struct { BuildOutput string `json:"buildoutput"` } +type AppMeta struct { + Title string `json:"title"` + ShortDesc string `json:"shortdesc"` + Icon string `json:"icon"` + IconColor string `json:"iconcolor"` +} + 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"` @@ -1077,6 +1096,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 41019fe2e1..ca4bc49329 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") @@ -1128,6 +1149,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 { diff --git a/tsunami/engine/clientimpl.go b/tsunami/engine/clientimpl.go index ffcf5fd736..73aeacfcfb 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,11 +53,10 @@ type SecretMeta struct { } type AppManifest struct { - AppTitle string `json:"apptitle"` - AppShortDesc string `json:"appshortdesc"` - 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 { @@ -498,10 +499,9 @@ func (c *ClientImpl) GetAppManifest() AppManifest { configSchema := GenerateConfigSchema(c.Root) dataSchema := GenerateDataSchema(c.Root) secrets := c.GetSecrets() - + return AppManifest{ - AppTitle: appMeta.Title, - AppShortDesc: appMeta.ShortDesc, + AppMeta: appMeta, ConfigSchema: configSchema, DataSchema: dataSchema, Secrets: secrets,