diff --git a/Taskfile.yml b/Taskfile.yml index 5b3e520434..1874a2cbc2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -539,7 +539,7 @@ tasks: - mv scaffold/node_modules scaffold/nm - cp -r dist scaffold/ - mkdir -p scaffold/dist/tw - - cp ../templates/app-main.go.tmpl scaffold/app-main.go + - cp ../templates/*.go.tmpl scaffold/ - cp ../templates/tailwind.css scaffold/ - cp ../templates/gitignore.tmpl scaffold/.gitignore - cp src/element/*.tsx scaffold/dist/tw/ @@ -560,7 +560,7 @@ tasks: - powershell Move-Item -Path scaffold/node_modules -Destination scaffold/nm - powershell Copy-Item -Recurse -Force -Path dist -Destination scaffold/ - powershell New-Item -ItemType Directory -Force -Path scaffold/dist/tw - - powershell Copy-Item -Path ../templates/app-main.go.tmpl -Destination scaffold/app-main.go + - powershell Copy-Item -Path '../templates/*.go.tmpl' -Destination scaffold/ - powershell Copy-Item -Path ../templates/tailwind.css -Destination scaffold/ - powershell Copy-Item -Path ../templates/gitignore.tmpl -Destination scaffold/.gitignore - powershell Copy-Item -Path 'src/element/*.tsx' -Destination scaffold/dist/tw/ diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index 3a389cb8e4..33ca244681 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -13,6 +13,7 @@ import { ElectronWshClient } from "./emain-wsh"; export type BuilderWindowType = BrowserWindow & { builderId: string; + builderAppId?: string; savedInitOpts: BuilderInitOpts; }; @@ -39,6 +40,14 @@ export async function createBuilderWindow(appId: string): Promise { diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index a4fd248061..0129d4337f 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -30,7 +30,7 @@ export function openBuilderWindow(appId?: string) { const normalizedAppId = appId || ""; const existingBuilderWindows = getAllBuilderWindows(); const existingWindow = existingBuilderWindows.find( - (win) => win.savedInitOpts?.appId === normalizedAppId + (win) => win.builderAppId === normalizedAppId ); if (existingWindow) { existingWindow.focus(); @@ -426,9 +426,7 @@ export function initIpcHandlers() { if (bw == null) { return; } - if (bw.savedInitOpts) { - bw.savedInitOpts.appId = appId; - } + bw.builderAppId = appId; console.log("set-builder-window-appid", bw.builderId, appId); }); @@ -453,4 +451,8 @@ export function initIpcHandlers() { } bw.destroy(); }); + + electron.ipcMain.on("do-refresh", (event) => { + event.sender.reloadIgnoringCache(); + }); } diff --git a/emain/preload.ts b/emain/preload.ts index 3acc23fb45..c6bdf14988 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -67,6 +67,7 @@ contextBridge.exposeInMainWorld("api", { nativePaste: () => ipcRenderer.send("native-paste"), openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), + doRefresh: () => ipcRenderer.send("do-refresh"), }); // Custom event for "new-window" diff --git a/frontend/app/aipanel/thinkingmode.tsx b/frontend/app/aipanel/thinkingmode.tsx index 99cc85bc6e..1e0fb76be7 100644 --- a/frontend/app/aipanel/thinkingmode.tsx +++ b/frontend/app/aipanel/thinkingmode.tsx @@ -45,6 +45,7 @@ export const ThinkingLevelDropdown = memo(() => { const dropdownRef = useRef(null); const hasPremium = !rateLimitInfo || rateLimitInfo.unknown || rateLimitInfo.preq > 0; + const hideQuick = model.inBuilder && hasPremium; const handleSelect = (mode: ThinkingMode) => { const metadata = ThinkingModeData[mode]; @@ -60,6 +61,9 @@ export const ThinkingLevelDropdown = memo(() => { if (!hasPremium && currentMetadata.premium) { currentMode = "quick"; } + if (hideQuick && currentMode === "quick") { + currentMode = "balanced"; + } return (
@@ -82,39 +86,41 @@ export const ThinkingLevelDropdown = memo(() => { <>
setIsOpen(false)} />
- {(Object.keys(ThinkingModeData) as ThinkingMode[]).map((mode, index) => { - const metadata = ThinkingModeData[mode]; - const isFirst = index === 0; - const isLast = index === Object.keys(ThinkingModeData).length - 1; - const isDisabled = !hasPremium && metadata.premium; - const isSelected = currentMode === mode; - return ( - - ); - })} + {(Object.keys(ThinkingModeData) as ThinkingMode[]) + .filter((mode) => !(hideQuick && mode === "quick")) + .map((mode, index, filteredModes) => { + const metadata = ThinkingModeData[mode]; + const isFirst = index === 0; + const isLast = index === filteredModes.length - 1; + const isDisabled = !hasPremium && metadata.premium; + const isSelected = currentMode === mode; + return ( + + ); + })}
)} diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index cb67820b6a..53fabde064 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -5,6 +5,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel"; +import { SetSecretDialog } from "@/builder/tabs/builder-secrettab"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; @@ -17,6 +18,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, + [SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 557154a710..0715eae699 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -547,6 +547,11 @@ class RpcApiType { return client.wshRpcCall("startbuilder", data, opts); } + // command "stopbuilder" [call] + StopBuilderCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("stopbuilder", data, opts); + } + // command "streamcpudata" [responsestream] StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { return client.wshRpcStream("streamcpudata", data, opts); diff --git a/frontend/builder/builder-app.tsx b/frontend/builder/builder-app.tsx index a7f092e9cd..e72a0578be 100644 --- a/frontend/builder/builder-app.tsx +++ b/frontend/builder/builder-app.tsx @@ -4,7 +4,7 @@ import { AppSelectionModal } from "@/builder/app-selection-modal"; import { BuilderWorkspace } from "@/builder/builder-workspace"; import { ModalsRenderer } from "@/app/modals/modalsrenderer"; -import { atoms, globalStore } from "@/store/global"; +import { atoms, globalStore, isDev } from "@/store/global"; import { appHandleKeyDown } from "@/store/keymodel"; import * as keyutil from "@/util/keyutil"; import { isBlank } from "@/util/util"; @@ -38,9 +38,14 @@ function BuilderAppInner() {
+ {isDev() ? ( +
+ +
+ ) : null}
WaveApp Builder{!isBlank(builderAppId) && ` (${builderAppId})`}
diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 24c1d80041..5bdf0d720b 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -2,15 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 import { Modal } from "@/app/modals/modal"; +import { ContextMenuModel } from "@/app/store/contextmenu"; import { modalsModel } from "@/app/store/modalmodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; -import { BuilderEnvTab } from "@/builder/tabs/builder-envtab"; import { BuilderFilesTab, DeleteFileModal, RenameFileModal } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; +import { BuilderEnvTab } from "@/builder/tabs/builder-secrettab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; import { ErrorBoundary } from "@/element/errorboundary"; import { atoms } from "@/store/global"; @@ -194,9 +195,9 @@ const BuilderAppPanel = memo(() => { const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; - const envSaveNeeded = useAtomValue(model.envVarsDirtyAtom); const builderAppId = useAtomValue(atoms.builderAppId); const builderId = useAtomValue(atoms.builderId); + const hasSecrets = useAtomValue(model.hasSecretsAtom); useEffect(() => { model.initialize(); @@ -241,12 +242,6 @@ const BuilderAppPanel = memo(() => { [model] ); - const handleEnvSave = useCallback(() => { - if (builderId) { - model.saveEnvVars(builderId); - } - }, [builderId, model]); - const handleRestart = useCallback(() => { model.restartBuilder(); }, [model]); @@ -257,6 +252,30 @@ const BuilderAppPanel = memo(() => { modalsModel.pushModal("PublishAppModal", { appName }); }, [builderAppId]); + const handleSwitchAppClick = useCallback(() => { + model.switchBuilderApp(); + }, [model]); + + const handleKebabClick = useCallback( + (e: React.MouseEvent) => { + const menu: ContextMenuItem[] = [ + { + label: "Publish App", + click: handlePublishClick, + }, + { + type: "separator", + }, + { + label: "Switch App", + click: handleSwitchAppClick, + }, + ]; + ContextMenuModel.showContextMenu(menu, e); + }, + [handleSwitchAppClick, handlePublishClick] + ); + return (
{ isAppFocused={isAppFocused} onClick={() => handleTabClick("files")} /> - handleTabClick("env")} - /> + {hasSecrets && ( + handleTabClick("secrets")} + /> + )}
-
+
-
- {activeTab === "env" && ( - )} +
@@ -346,7 +361,7 @@ const BuilderAppPanel = memo(() => {
-
+
diff --git a/frontend/builder/builder-buildpanel.tsx b/frontend/builder/builder-buildpanel.tsx index b080cdb744..1ce2dc7ac3 100644 --- a/frontend/builder/builder-buildpanel.tsx +++ b/frontend/builder/builder-buildpanel.tsx @@ -85,7 +85,7 @@ const BuilderBuildPanel = memo(() => { const filteredLines = showDebug ? outputLines : outputLines.filter((line) => !line.startsWith("[debug]") && line.trim().length > 0); return ( -
+
Build Output
diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 395fdb32a1..94a06fad83 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -107,7 +107,7 @@ const BuilderWorkspace = memo(() => { isAppFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ - borderBottomRightRadius: 10, + borderBottomRightRadius: 8, }} > @@ -115,7 +115,12 @@ const BuilderWorkspace = memo(() => { - + diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 09aec03dbf..9e9e771d00 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -5,12 +5,12 @@ import { globalStore } from "@/app/store/jotaiStore"; import { waveEventSubscribe } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { atoms, WOS } from "@/store/global"; +import { atoms, getApi, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import { debounce } from "throttle-debounce"; -export type TabType = "preview" | "files" | "code" | "env"; +export type TabType = "preview" | "files" | "code" | "secrets"; export type EnvVar = { name: string; @@ -30,6 +30,7 @@ export class BuilderAppPanelModel { isLoadingAtom: PrimitiveAtom = atom(false); errorAtom: PrimitiveAtom = atom(""); builderStatusAtom = atom(null) as PrimitiveAtom; + hasSecretsAtom: PrimitiveAtom = atom(false); saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: any | null } = { current: null }; @@ -85,6 +86,7 @@ export class BuilderAppPanelModel { const currentStatus = globalStore.get(this.builderStatusAtom); if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) { globalStore.set(this.builderStatusAtom, status); + this.updateSecretsLatch(status); } }, }); @@ -92,6 +94,7 @@ export class BuilderAppPanelModel { try { const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId); globalStore.set(this.builderStatusAtom, status); + this.updateSecretsLatch(status); } catch (err) { console.error("Failed to load builder status:", err); } @@ -110,6 +113,14 @@ export class BuilderAppPanelModel { }); } + updateSecretsLatch(status: BuilderStatusData) { + if (!status?.manifest?.secrets) return; + const secrets = status.manifest.secrets; + if (Object.keys(secrets).length > 0) { + globalStore.set(this.hasSecretsAtom, true); + } + } + async loadEnvVars(builderId: string) { try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { @@ -197,14 +208,25 @@ export class BuilderAppPanelModel { } async restartBuilder() { + // the RPC call that starts the builder actually forces a restart, so this works + return this.startBuilder(); + } + + async switchBuilderApp() { const builderId = globalStore.get(atoms.builderId); try { - await RpcApi.ControllerStopCommand(TabRpcClient, builderId); + await RpcApi.StopBuilderCommand(TabRpcClient, builderId); await new Promise((resolve) => setTimeout(resolve, 500)); - await this.startBuilder(); + await RpcApi.SetRTInfoCommand(TabRpcClient, { + oref: WOS.makeORef("builder", builderId), + data: { "builder:appid": null }, + }); + getApi().setBuilderWindowAppId(null); + await new Promise((resolve) => setTimeout(resolve, 100)); + getApi().doRefresh(); } catch (err) { - console.error("Failed to restart builder:", err); - globalStore.set(this.errorAtom, `Failed to restart builder: ${err.message || "Unknown error"}`); + console.error("Failed to switch builder app:", err); + globalStore.set(this.errorAtom, `Failed to switch builder app: ${err.message || "Unknown error"}`); } } diff --git a/frontend/builder/tabs/builder-envtab.tsx b/frontend/builder/tabs/builder-envtab.tsx deleted file mode 100644 index e269c4fcde..0000000000 --- a/frontend/builder/tabs/builder-envtab.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; - -type EnvVarRowProps = { - model: BuilderAppPanelModel; - index: number; -}; - -const EnvVarRow = memo(({ model, index }: EnvVarRowProps) => { - const envVar = useAtomValue(model.getEnvVarIndexAtom(index)); - - if (!envVar) { - return null; - } - - const isValueVisible = envVar.visible ?? false; - - return ( -
- model.setEnvVarAtIndex(index, { ...envVar, name: e.target.value }, true)} - placeholder="Variable Name" - className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> -
- model.setEnvVarAtIndex(index, { ...envVar, value: e.target.value }, true)} - placeholder="Value" - className="w-full px-3 py-2 pr-10 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> - -
- -
- ); -}); - -EnvVarRow.displayName = "EnvVarRow"; - -const BuilderEnvTab = memo(() => { - const model = BuilderAppPanelModel.getInstance(); - const envVars = useAtomValue(model.envVarsArrayAtom); - const error = useAtomValue(model.errorAtom); - - return ( -
-
-

Environment Variables

- -
- -
- These environment variables are transient and only used during builder testing. They are not bundled - with the app. -
- - {error &&
{error}
} - -
-
- {envVars.length === 0 ? ( -
- No environment variables defined. Click "Add Variable" to create one. -
- ) : ( - envVars.map((_, index) => ) - )} -
-
-
- ); -}); - -BuilderEnvTab.displayName = "BuilderEnvTab"; - -export { BuilderEnvTab }; diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 61f8157a07..2258e31441 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -34,9 +34,12 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; const waveAIModel = WaveAIModel.getInstance(); const buildPanelModel = BuilderBuildPanelModel.getInstance(); + const appPanelModel = BuilderAppPanelModel.getInstance(); const outputLines = useAtomValue(buildPanelModel.outputLines); const isStreaming = useAtomValue(waveAIModel.isAIStreaming); + const isSecretError = displayMsg.includes("ERR-SECRET"); + const getBuildContext = () => { const filteredLines = outputLines.filter((line) => !line.startsWith("[debug]")); const buildOutput = filteredLines.join("\n").trim(); @@ -55,6 +58,36 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { await waveAIModel.handleSubmit(); }; + const handleGoToSecrets = () => { + appPanelModel.setActiveTab("secrets"); + }; + + if (isSecretError) { + return ( +
+
+
🔐
+
+

Secrets Required

+

+ This app requires secrets that must be configured. Please use the Secrets tab to set and bind + the required secrets for your app to run. +

+
+
{displayMsg}
+
+ +
+
+
+ ); + } + return (
diff --git a/frontend/builder/tabs/builder-secrettab.tsx b/frontend/builder/tabs/builder-secrettab.tsx new file mode 100644 index 0000000000..dbc2d215c0 --- /dev/null +++ b/frontend/builder/tabs/builder-secrettab.tsx @@ -0,0 +1,294 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { useAtomValue } from "jotai"; +import { memo, useState, useEffect } from "react"; +import { Check, AlertTriangle } from "lucide-react"; +import { Tooltip } from "@/app/element/tooltip"; +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; + +type SecretRowProps = { + secretName: string; + secretMeta: SecretMeta; + currentBinding: string; + availableSecrets: string[]; + onMapDefault: (secretName: string) => void; + onSetAndMapDefault: (secretName: string) => void; +}; + +const SecretRow = memo(({ secretName, secretMeta, currentBinding, availableSecrets, onMapDefault, onSetAndMapDefault }: SecretRowProps) => { + const isMapped = currentBinding.trim().length > 0; + const isValid = isMapped && availableSecrets.includes(currentBinding); + const hasMatchingSecret = availableSecrets.includes(secretName); + + return ( +
+ +
+ {!isMapped && } + {isMapped && isValid && } +
+
+
+ {secretName} + {!secretMeta.optional && ( + Required + )} + {secretMeta.optional && ( + Optional + )} + {secretMeta.desc && — {secretMeta.desc}} +
+
+ {!isMapped && hasMatchingSecret && ( + + )} + {!isMapped && !hasMatchingSecret && ( + + )} +
+
+ ); +}); + +SecretRow.displayName = "SecretRow"; + +type SetSecretDialogProps = { + secretName: string; + onSetAndMap: (secretName: string, secretValue: string) => Promise; +}; + +const SetSecretDialog = memo(({ secretName, onSetAndMap }: SetSecretDialogProps) => { + const [secretValue, setSecretValue] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + if (!secretValue.trim()) return; + setIsSubmitting(true); + setError(""); + try { + await onSetAndMap(secretName, secretValue); + modalsModel.popModal(); + } catch (err) { + console.error("Failed to set secret:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + modalsModel.popModal(); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + if (error) { + return ( + +
+

Error Setting Secret

+
{error}
+
+
+ ); + } + + return ( + +
+

Set and Map Secret

+
+
+ Secret Name: {secretName} +
+