Skip to content

Commit 347eef2

Browse files
authored
more builder updates (#2553)
* load manifest metadata into the FE * builder only edits draft/ apps (convert local => draft) * gofmt app.go after saving (AI tools and manual user save) * dont open duplicate builder windows * remix app context menu in waveapp * add icon/iconcolor in appmeta and implement in the wave block frame
1 parent d6578b7 commit 347eef2

File tree

26 files changed

+519
-136
lines changed

26 files changed

+519
-136
lines changed

.roo/rules/rules.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ To define a new RPC call, add the new definition to `pkg/wshrpc/wshrpctypes.go`
5858

5959
For normal "server" RPCs (where a frontend client is calling the main server) you should implement the RPC call in `pkg/wshrpc/wshserver.go`.
6060

61+
### Electron API
62+
63+
From within the FE to get the electron API (e.g. the preload functions):
64+
65+
```
66+
import { getApi } from "@/store/global";
67+
68+
getApi().getIsDev()
69+
```
70+
71+
The full API is defined in custom.d.ts as type ElectronApi.
72+
6173
### Code Generation
6274

6375
- **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`.

emain/emain-ipc.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { RpcApi } from "../frontend/app/store/wshclientapi";
1212
import { getWebServerEndpoint } from "../frontend/util/endpoints";
1313
import * as keyutil from "../frontend/util/keyutil";
1414
import { fireAndForget, parseDataUrl } from "../frontend/util/util";
15-
import { createBuilderWindow, getBuilderWindowByWebContentsId } from "./emain-builder";
15+
import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder";
1616
import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform";
1717
import { getWaveTabViewByWebContentsId } from "./emain-tabview";
1818
import { handleCtrlShiftState } from "./emain-util";
@@ -26,6 +26,19 @@ const electronApp = electron.app;
2626
let webviewFocusId: number = null;
2727
let webviewKeys: string[] = [];
2828

29+
export function openBuilderWindow(appId?: string) {
30+
const normalizedAppId = appId || "";
31+
const existingBuilderWindows = getAllBuilderWindows();
32+
const existingWindow = existingBuilderWindows.find(
33+
(win) => win.savedInitOpts?.appId === normalizedAppId
34+
);
35+
if (existingWindow) {
36+
existingWindow.focus();
37+
return;
38+
}
39+
fireAndForget(() => createBuilderWindow(normalizedAppId));
40+
}
41+
2942
type UrlInSessionResult = {
3043
stream: Readable;
3144
mimeType: string;
@@ -405,7 +418,18 @@ export function initIpcHandlers() {
405418
});
406419

407420
electron.ipcMain.on("open-builder", (event, appId?: string) => {
408-
fireAndForget(() => createBuilderWindow(appId || ""));
421+
openBuilderWindow(appId);
422+
});
423+
424+
electron.ipcMain.on("set-builder-window-appid", (event, appId: string) => {
425+
const bw = getBuilderWindowByWebContentsId(event.sender.id);
426+
if (bw == null) {
427+
return;
428+
}
429+
if (bw.savedInitOpts) {
430+
bw.savedInitOpts.appId = appId;
431+
}
432+
console.log("set-builder-window-appid", bw.builderId, appId);
409433
});
410434

411435
electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow));

emain/emain-menu.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { waveEventSubscribe } from "@/app/store/wps";
55
import { RpcApi } from "@/app/store/wshclientapi";
66
import * as electron from "electron";
77
import { fireAndForget } from "../frontend/util/util";
8-
import { createBuilderWindow, focusedBuilderWindow, getBuilderWindowById } from "./emain-builder";
8+
import { focusedBuilderWindow, getBuilderWindowById } from "./emain-builder";
9+
import { openBuilderWindow } from "./emain-ipc";
910
import { isDev, unamePlatform } from "./emain-platform";
1011
import { clearTabCache } from "./emain-tabview";
1112
import {
@@ -128,7 +129,7 @@ function makeFileMenu(numWaveWindows: number, callbacks: AppMenuCallbacks): Elec
128129
fileMenu.splice(1, 0, {
129130
label: "New WaveApp Builder Window",
130131
accelerator: unamePlatform === "darwin" ? "Command+Shift+B" : "Alt+Shift+B",
131-
click: () => fireAndForget(() => createBuilderWindow("")),
132+
click: () => openBuilderWindow(""),
132133
});
133134
}
134135
if (numWaveWindows == 0) {

emain/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ contextBridge.exposeInMainWorld("api", {
6565
closeBuilderWindow: () => ipcRenderer.send("close-builder-window"),
6666
incrementTermCommands: () => ipcRenderer.send("increment-term-commands"),
6767
nativePaste: () => ipcRenderer.send("native-paste"),
68+
openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId),
69+
setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId),
6870
});
6971

7072
// Custom event for "new-window"

frontend/app/block/blockframe.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,19 @@ function handleHeaderContextMenu(
8585
ContextMenuModel.showContextMenu(menu, e);
8686
}
8787

88-
function getViewIconElem(viewIconUnion: string | IconButtonDecl, blockData: Block): React.ReactElement {
88+
function getViewIconElem(
89+
viewIconUnion: string | IconButtonDecl,
90+
blockData: Block,
91+
iconColor?: string
92+
): React.ReactElement {
8993
if (viewIconUnion == null || typeof viewIconUnion === "string") {
9094
const viewIcon = viewIconUnion as string;
91-
return <div className="block-frame-view-icon">{getBlockHeaderIcon(viewIcon, blockData)}</div>;
95+
const style: React.CSSProperties = iconColor ? { color: iconColor, opacity: 1.0 } : {};
96+
return (
97+
<div className="block-frame-view-icon" style={style}>
98+
{getBlockHeaderIcon(viewIcon, blockData)}
99+
</div>
100+
);
92101
} else {
93102
return <IconButton decl={viewIconUnion} className="block-frame-view-icon" />;
94103
}
@@ -172,6 +181,7 @@ const BlockFrame_Header = ({
172181
let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view);
173182
const showBlockIds = jotai.useAtomValue(getSettingsKeyAtom("blockheader:showblockids"));
174183
let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
184+
const viewIconColor = util.useAtomValueSafe(viewModel?.viewIconColor);
175185
const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton);
176186
let headerTextUnion = util.useAtomValueSafe(viewModel?.viewText);
177187
const magnified = jotai.useAtomValue(nodeModel.isMagnified);
@@ -208,7 +218,7 @@ const BlockFrame_Header = ({
208218
);
209219

210220
const endIconsElem = computeEndIcons(viewModel, nodeModel, onContextMenu);
211-
const viewIconElem = getViewIconElem(viewIconUnion, blockData);
221+
const viewIconElem = getViewIconElem(viewIconUnion, blockData, viewIconColor);
212222
let preIconButtonElem: React.ReactElement = null;
213223
if (preIconButton) {
214224
preIconButtonElem = <IconButton decl={preIconButton} className="block-frame-preicon-button" />;

frontend/app/store/wshclientapi.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,11 @@ class RpcApiType {
367367
return client.wshRpcCall("listalleditableapps", null, opts);
368368
}
369369

370+
// command "makedraftfromlocal" [call]
371+
MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise<CommandMakeDraftFromLocalRtnData> {
372+
return client.wshRpcCall("makedraftfromlocal", data, opts);
373+
}
374+
370375
// command "message" [call]
371376
MessageCommand(client: WshClient, data: CommandMessageData, opts?: RpcOpts): Promise<void> {
372377
return client.wshRpcCall("message", data, opts);
@@ -627,6 +632,11 @@ class RpcApiType {
627632
return client.wshRpcCall("writeappfile", data, opts);
628633
}
629634

635+
// command "writeappgofile" [call]
636+
WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise<CommandWriteAppGoFileRtnData> {
637+
return client.wshRpcCall("writeappgofile", data, opts);
638+
}
639+
630640
// command "writeappsecretbindings" [call]
631641
WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise<void> {
632642
return client.wshRpcCall("writeappsecretbindings", data, opts);

frontend/app/view/tsunami/tsunami.tsx

Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { BlockNodeModel } from "@/app/block/blocktypes";
5-
import { atoms, globalStore, WOS } from "@/app/store/global";
5+
import { atoms, getApi, globalStore, WOS } from "@/app/store/global";
66
import { waveEventSubscribe } from "@/app/store/wps";
77
import { RpcApi } from "@/app/store/wshclientapi";
88
import { TabRpcClient } from "@/app/store/wshrpcutil";
@@ -11,22 +11,18 @@ import * as services from "@/store/services";
1111
import * as jotai from "jotai";
1212
import { memo, useEffect } from "react";
1313

14-
interface TsunamiAppMeta {
15-
title: string;
16-
shortdesc: string;
17-
}
18-
1914
class TsunamiViewModel extends WebViewModel {
2015
shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>;
2116
shellProcStatusUnsubFn: () => void;
17+
appMeta: jotai.PrimitiveAtom<AppMeta>;
18+
appMetaUnsubFn: () => void;
2219
isRestarting: jotai.PrimitiveAtom<boolean>;
23-
viewName: jotai.PrimitiveAtom<string>;
20+
viewName: jotai.Atom<string>;
21+
viewIconColor: jotai.Atom<string>;
2422

2523
constructor(blockId: string, nodeModel: BlockNodeModel) {
2624
super(blockId, nodeModel);
2725
this.viewType = "tsunami";
28-
this.viewIcon = jotai.atom("cube");
29-
this.viewName = jotai.atom("Tsunami");
3026
this.isRestarting = jotai.atom(false);
3127

3228
// Hide navigation bar (URL bar, back/forward/home buttons)
@@ -48,6 +44,42 @@ class TsunamiViewModel extends WebViewModel {
4844
this.updateShellProcStatus(bcRTS);
4945
},
5046
});
47+
48+
this.appMeta = jotai.atom(null) as jotai.PrimitiveAtom<AppMeta>;
49+
this.viewIcon = jotai.atom((get) => {
50+
const meta = get(this.appMeta);
51+
return meta?.icon || "cube";
52+
});
53+
this.viewIconColor = jotai.atom((get) => {
54+
const meta = get(this.appMeta);
55+
return meta?.iconcolor;
56+
});
57+
this.viewName = jotai.atom((get) => {
58+
const meta = get(this.appMeta);
59+
return meta?.title || "WaveApp";
60+
});
61+
const initialRTInfo = RpcApi.GetRTInfoCommand(TabRpcClient, {
62+
oref: WOS.makeORef("block", blockId),
63+
});
64+
initialRTInfo.then((rtInfo) => {
65+
if (rtInfo) {
66+
const meta: AppMeta = {
67+
title: rtInfo["tsunami:title"],
68+
shortdesc: rtInfo["tsunami:shortdesc"],
69+
icon: rtInfo["tsunami:icon"],
70+
iconcolor: rtInfo["tsunami:iconcolor"],
71+
};
72+
globalStore.set(this.appMeta, meta);
73+
}
74+
});
75+
this.appMetaUnsubFn = waveEventSubscribe({
76+
eventType: "tsunami:updatemeta",
77+
scope: WOS.makeORef("block", blockId),
78+
handler: (event) => {
79+
const meta: AppMeta = event.data;
80+
globalStore.set(this.appMeta, meta);
81+
},
82+
});
5183
}
5284

5385
get viewComponent(): ViewComponent {
@@ -126,32 +158,31 @@ class TsunamiViewModel extends WebViewModel {
126158
this.doControllerResync(true, "force restart");
127159
}
128160

129-
setAppMeta(meta: TsunamiAppMeta) {
130-
console.log("tsunami app meta:", meta);
161+
async remixInBuilder() {
162+
const blockData = globalStore.get(this.blockAtom);
163+
const appId = blockData?.meta?.["tsunami:appid"];
131164

132-
const rtInfo: ObjRTInfo = {};
133-
if (meta.title) {
134-
rtInfo["tsunami:title"] = meta.title;
135-
}
136-
if (meta.shortdesc) {
137-
rtInfo["tsunami:shortdesc"] = meta.shortdesc;
165+
if (!appId || !appId.startsWith("local/")) {
166+
return;
138167
}
139168

140-
if (Object.keys(rtInfo).length > 0) {
141-
const oref = WOS.makeORef("block", this.blockId);
142-
const data: CommandSetRTInfoData = {
143-
oref: oref,
144-
data: rtInfo,
145-
};
169+
try {
170+
const result = await RpcApi.MakeDraftFromLocalCommand(TabRpcClient, { localappid: appId });
171+
const draftAppId = result.draftappid;
146172

147-
RpcApi.SetRTInfoCommand(TabRpcClient, data).catch((e) => console.log("error setting RT info", e));
173+
getApi().openBuilder(draftAppId);
174+
} catch (err) {
175+
console.error("Failed to create draft from local app:", err);
148176
}
149177
}
150178

151179
dispose() {
152180
if (this.shellProcStatusUnsubFn) {
153181
this.shellProcStatusUnsubFn();
154182
}
183+
if (this.appMetaUnsubFn) {
184+
this.appMetaUnsubFn();
185+
}
155186
}
156187

157188
getSettingsMenuItems(): ContextMenuItem[] {
@@ -167,6 +198,11 @@ class TsunamiViewModel extends WebViewModel {
167198
);
168199
});
169200

201+
// Check if we should show the Remix option
202+
const blockData = globalStore.get(this.blockAtom);
203+
const appId = blockData?.meta?.["tsunami:appid"];
204+
const showRemixOption = appId && appId.startsWith("local/");
205+
170206
// Add tsunami-specific menu items at the beginning
171207
const tsunamiItems: ContextMenuItem[] = [
172208
{
@@ -186,6 +222,18 @@ class TsunamiViewModel extends WebViewModel {
186222
},
187223
];
188224

225+
if (showRemixOption) {
226+
tsunamiItems.push(
227+
{
228+
label: "Remix WaveApp in Builder",
229+
click: () => this.remixInBuilder(),
230+
},
231+
{
232+
type: "separator",
233+
}
234+
);
235+
}
236+
189237
return [...tsunamiItems, ...filteredItems];
190238
}
191239
}
@@ -201,39 +249,6 @@ const TsunamiView = memo((props: ViewComponentProps<TsunamiViewModel>) => {
201249
model.resyncController();
202250
}, [model]);
203251

204-
useEffect(() => {
205-
if (!domReady || !model.webviewRef?.current) return;
206-
207-
const webviewElement = model.webviewRef.current;
208-
209-
const handleConsoleMessage = (e: any) => {
210-
const message = e.message;
211-
if (typeof message === "string" && message.startsWith("TSUNAMI_META ")) {
212-
try {
213-
const jsonStr = message.substring("TSUNAMI_META ".length);
214-
const meta = JSON.parse(jsonStr);
215-
if (meta.title || meta.shortdesc) {
216-
model.setAppMeta(meta);
217-
218-
if (meta.title) {
219-
const truncatedTitle =
220-
meta.title.length > 77 ? meta.title.substring(0, 77) + "..." : meta.title;
221-
globalStore.set(model.viewName, truncatedTitle);
222-
}
223-
}
224-
} catch (error) {
225-
console.error("Failed to parse TSUNAMI_META message:", error);
226-
}
227-
}
228-
};
229-
230-
webviewElement.addEventListener("console-message", handleConsoleMessage);
231-
232-
return () => {
233-
webviewElement.removeEventListener("console-message", handleConsoleMessage);
234-
};
235-
}, [domReady, model]);
236-
237252
const appPath = blockData?.meta?.["tsunami:apppath"];
238253
const appId = blockData?.meta?.["tsunami:appid"];
239254
const controller = blockData?.meta?.controller;

0 commit comments

Comments
 (0)